Teste de transição de estado

Como testar sistemas cujo comportamento depende do status do objeto e encontrar defeitos na lógica do fluxo de trabalho.

Atualizado:

Sumário

O teste de transição de estado ajuda quando o comportamento do sistema depende do estado atual de um objeto. Usuários, pedidos, tickets, pagamentos, assinaturas e sessões têm status e transições permitidas.

Quando usar

  • →Existem estados explícitos, como criado, ativo, bloqueado, cancelado e concluído.
  • →Algumas transições são proibidas.
  • →O comportamento e as ações disponíveis dependem do estado atual.
  • →Os bugs geralmente envolvem violações do ciclo de vida, em vez de campos individuais.

O que documentar

  • →Os estados do objeto
  • →Transições permitidas
  • →Eventos que desencadeiam transições
  • →Restrições: quem pode iniciar uma transição, sob quais condições e o que se segue

Exemplo

Suponha que um relatório de bug tenha os status Aberto, Em Andamento, Pronto para Teste, Reaberto e Fechado. O teste de transição de estado verifica se o fechamento direto é permitido, quem pode reabri-lo e se outro ciclo de correção preserva a lógica correta.

O que QA deve examinar

  • →Transições proibidas: o sistema deve bloqueá-las ou explicá-las adequadamente.
  • →Efeitos colaterais da transição: notificações, auditoria, alterações de acesso e registro.
  • →Transições repetidas e movimento para trás.
  • →Estados após falha: o que acontece se uma operação for interrompida no meio?
🔁

Se um objeto tiver um ciclo de vida, desenhar até mesmo um diagrama de estado simples geralmente é útil. Ele revela rapidamente pontos cegos que são difíceis de ver em uma lista de casos.

Ciclo de vida de um pedido demonstrado com contrato explícito

Este é um modelo fictício para um único pedido, não o comportamento de toda loja ou API de pagamento. Draft é um pedido não enviado, Placed é enviado, Paid é pago, Shipped é despachado e Cancelled é terminal. Somente as cinco transições abaixo são permitidas. Cancelamento após pagamento, reembolsos, permissões, erros de transporte e requisições concorrentes ficam fora deste modelo pequeno e exigem regras adicionais.

plain text
Draft --submit--> Placed --pay--> Paid --ship--> Shipped
Draft --cancel--> Cancelled
Placed --cancel--> Cancelled

Alternativa em texto: submit move Draft para Placed; pay move Placed para Paid; ship move Paid para Shipped. cancel move Draft ou Placed para Cancelled. Shipped e Cancelled não têm transições de saída. Todas as outras combinações de estado/evento são rejeitadas sem mudar o estado ou produzir um efeito colateral de negócio.

Uma tabela completa de teste de evento único

Restaure o pedido ao estado da primeira coluna antes de cada linha e use uma chave de requisição nova. A contagem é um contador do modelo para uma transição de negócio confirmada; não representa uma quantidade universal de logs, notificações ou cobranças externas. Uma transição aceita incrementa o contador em um; uma rejeição acrescenta zero.

plain text
Estado inicial	Evento	Resultado	Estado final	Novos efeitos
Draft	submit	aceito	Placed	1
Draft	pay	rejeitado	Draft	0
Draft	ship	rejeitado	Draft	0
Draft	cancel	aceito	Cancelled	1
Placed	submit	rejeitado	Placed	0
Placed	pay	aceito	Paid	1
Placed	ship	rejeitado	Placed	0
Placed	cancel	aceito	Cancelled	1
Paid	submit	rejeitado	Paid	0
Paid	pay	rejeitado	Paid	0
Paid	ship	aceito	Shipped	1
Paid	cancel	rejeitado	Paid	0
Shipped	submit	rejeitado	Shipped	0
Shipped	pay	rejeitado	Shipped	0
Shipped	ship	rejeitado	Shipped	0
Shipped	cancel	rejeitado	Shipped	0
Cancelled	submit	rejeitado	Cancelled	0
Cancelled	pay	rejeitado	Cancelled	0
Cancelled	ship	rejeitado	Cancelled	0
Cancelled	cancel	rejeitado	Cancelled	0

As novas tentativas são um contrato separado, não um estado extra

Neste modelo, uma requisição bem-sucedida armazena seu evento e resultado sob uma chave limitada a este pedido. Repetir a mesma chave e evento retorna o resultado armazenado sem novo efeito colateral. Reutilizar uma chave bem-sucedida para outro evento retorna key-conflict. Requisições rejeitadas não são armazenadas. Essa regra intencionalmente simplificada não afirma nada sobre códigos de status HTTP ou provedores reais de pagamento.

plain text
Sequência	Resposta esperada	Estado atual	Total de efeitos confirmados
submit / s1	aceito, Placed	Placed	1
pay / p1	aceito, Paid	Paid	2
pay / p1 novamente	aceito, Paid (resultado registrado)	Paid	2
ship / p1	key-conflict, Paid	Paid	2
pay / p2	rejeitado, Paid	Paid	2
ship / s2	aceito, Shipped	Shipped	3

Um resultado armazenado é um snapshot da requisição original, não necessariamente o estado atual do pedido após transições posteriores. Uma API real deve especificar escopo da chave, retenção, comparação de payload, ordenação e persistência atômica. Um modelo didático apenas em memória não demonstra proteção contra cobranças duplicadas concorrentes.

Execute o modelo de referência localmente

Salve como order_state_model.py e execute python3 order_state_model.py. Ele verifica todas as 20 combinações de estado/evento e depois a sequência de novas tentativas. Saída esperada: Transitions: 5 accepted, 15 rejected; replay checks: passed. Essas verificações validam este modelo didático; teste a aplicação separadamente conforme as mesmas regras declaradas.

plain text
STATES = ("Draft", "Placed", "Paid", "Shipped", "Cancelled")
EVENTS = ("submit", "pay", "ship", "cancel")
TRANSITIONS = {
    ("Draft", "submit"): "Placed", ("Draft", "cancel"): "Cancelled",
    ("Placed", "pay"): "Paid", ("Placed", "cancel"): "Cancelled",
    ("Paid", "ship"): "Shipped",
}
class Order:
    def __init__(self, state="Draft"):
        self.state, self.effects, self.saved = state, 0, {}
    def apply(self, event, key):
        if key in self.saved:
            old_event, old_result = self.saved[key]
            return old_result if event == old_event else ("key-conflict", self.state)
        target = TRANSITIONS.get((self.state, event))
        if target is None:
            return ("rejected", self.state)
        self.state = target
        self.effects += 1
        result = ("accepted", target)
        self.saved[key] = (event, result)
        return result

accepted = 0
for state in STATES:
    for event in EVENTS:
        order = Order(state)
        target = TRANSITIONS.get((state, event))
        expected = ("accepted", target) if target else ("rejected", state)
        assert order.apply(event, "unique") == expected
        assert order.state == (target or state)
        assert order.effects == int(target is not None)
        accepted += target is not None
order = Order()
assert order.apply("submit", "s1") == ("accepted", "Placed")
assert order.apply("pay", "p1") == ("accepted", "Paid")
assert order.apply("pay", "p1") == ("accepted", "Paid")
assert order.effects == 2
assert order.apply("ship", "p1") == ("key-conflict", "Paid")
assert order.apply("pay", "p2") == ("rejected", "Paid")
assert order.effects == 2
assert order.apply("ship", "s2") == ("accepted", "Shipped")
assert order.effects == 3
print(f"Transitions: {accepted} accepted, {20-accepted} rejected; replay checks: passed")

Em um sistema real, amplie a cobertura de sequências: timeout entre pagamento e persistência, callbacks duplicados, versões antigas e cancelamento concorrente com pagamento. Defina o que deve acontecer antes de escrever as asserções. Cinco transições cobertas não provam que todos os caminhos de várias etapas ou recuperações de falhas funcionem.

Continuar aprendendo

Use tabelas de decisão para as condições de guarda das transições

Verifique ações rejeitadas da API sem efeitos colaterais

ISTQB CTFL 4.0.1: seção 4.2.4, teste de transição de estado