Teste Baseado em Saída, em Estado, e em Comunicação
Objective
Aprender as três formas pelas quais um teste unitário pode verificar que um pedaço de código fez a coisa certa: checando uma saída (output) retornada, checando o estado (state) resultante, ou checando que o SUT se comunicou com um colaborador de uma certa forma, e entender o ranking de Khorikov entre os três: o teste baseado em saída produz os testes de mais alta qualidade, o teste baseado em estado é o padrão razoável para todo o resto, e o teste baseado em comunicação (verificação de mock) deveria ser reservado para o caso raro.
Use Cases
- Decidir, para um novo teste, qual dos três estilos de fato se encaixa no comportamento sendo verificado, em vez de usar por padrão o estilo que a classe de teste ao redor já usa.
- Reconhecer por que uma suíte de testes cheia de chamadas
verify(...)tende a ser a mais cara de manter e a mais propensa a quebrar em refatorações inofensivas, sem precisar relitigar a fragilidade de mocks do zero. - Explicar por que "simplesmente faça disso uma função pura" é um argumento de testabilidade, não só uma preferência de programação funcional; uma função pura é testável por saída quase por definição.
Deep Dive
Os três estilos, testando o mesmo comportamento de três formas
Os três estilos conseguem verificar exatamente o mesmo pedaço de comportamento (adicionar um item a um carrinho), mas checam três coisas diferentes: o valor que um método retorna, o estado deixado para trás depois, ou a chamada que um método faz a um colaborador.
Baseado em saída: a operação do carrinho é uma função pura. Alimente-a com uma entrada, cheque o que volta. Não há estado mutável a inspecionar; o valor de retorno é a única coisa que o teste precisa verificar.
javastatic List<String> addItem(List<String> items, String item) {
List<String> result = new ArrayList<>(items);
result.add(item);
return result;
}
@Test
void addingAnItemReturnsAnExtendedList() {
List<String> updated = addItem(List.of("bread"), "milk");
assertEquals(List.of("bread", "milk"), updated);
}Baseado em estado: o carrinho é um objeto com estado. A operação o muta, e o teste chama um método de consulta depois para inspecionar o que mudou.
javaclass Cart {
private final List<String> items = new ArrayList<>();
void addItem(String item) {
items.add(item);
}
List<String> getItems() {
return List.copyOf(items);
}
}
@Test
void addingAnItemUpdatesTheCartState() {
Cart cart = new Cart();
cart.addItem("milk");
assertEquals(List.of("milk"), cart.getItems());
}Baseado em comunicação: o carrinho não guarda os itens ele mesmo, delega a um colaborador, e a única coisa que vale a pena checar é se essa delegação aconteceu corretamente.
javainterface InventoryReserver {
void reserve(String sku);
}
class Cart {
private final InventoryReserver reserver;
Cart(InventoryReserver reserver) {
this.reserver = reserver;
}
void addItem(String sku) {
reserver.reserve(sku);
}
}
@ExtendWith(MockitoExtension.class)
class CartTest {
@Mock
InventoryReserver reserverMock;
@Test
void addingAnItemReservesInventory() {
Cart cart = new Cart(reserverMock);
cart.addItem("SKU-42");
Mockito.verify(reserverMock).reserve("SKU-42");
}
}Mesma ideia subjacente ("adicionar um item"), três asserções diferentes: um valor de retorno, uma fotografia de estado, e uma chamada registrada.
Por que o teste baseado em saída vence, e sua limitação real
Resistência à refatoração se resume a quanto do código de produção um teste está acoplado. Um teste baseado em saída se acopla a exatamente uma coisa: o mapeamento de entrada para saída do método sob teste. Não sabe nem se importa com como esse mapeamento é calculado internamente, então quase qualquer refatoração que preserve o mapeamento (renomear um helper, trocar um loop por um stream, reestruturar a classe inteira) deixa o teste verde. A única forma de um teste baseado em saída quebrar em uma refatoração é se o próprio método sob teste for um detalhe de implementação sendo renomeado ou removido, o que é um modo de falha muito mais estreito do que "o teste por acaso afirmava sobre algo que mudou".
Testes baseados em saída também vencem em manutenibilidade por um motivo estrutural: eles se resumem a "chame, cheque o valor de retorno", o que quase sempre são poucas linhas, e como o código subjacente não pode tocar estado compartilhado ou fora de processo, não há nada extra para preparar ou desmontar.
A pegadinha é a restrição que o Objective já nomeou: esse estilo só funciona quando o código sob teste não tem efeitos colaterais observáveis: nenhuma escrita em um campo, nenhuma chamada a um banco de dados, nenhuma mutação de um argumento. Essa é uma limitação real, não uma preferência de estilo. Um Cart que precisa rastrear quais itens estão nele, um Order que precisa se persistir, um Controller que precisa enviar um e-mail: nenhum desses pode ser verificado puramente pelo seu valor de retorno, porque seu valor de retorno não é o ponto de chamá-los. A maior parte do código orientado a objetos é escrita especificamente para causar algum efeito, que é exatamente o que o teste baseado em saída não consegue ver.
Teste baseado em estado como padrão, e baseado em comunicação como exceção
Para o código que o teste baseado em saída não consegue alcançar, que, na maioria das bases de código, é a maior parte do código, o teste baseado em estado é o substituto razoável. Ele ainda checa um resultado: o estado do SUT (ou de um colaborador, ou de uma dependência fora de processo) depois que a operação rodou. É uma versão menor, mas real, do mesmo argumento de resistência à refatoração: o teste não sabe como addItem atualizou a lista, só que a lista agora contém o item. O custo aparece na manutenibilidade em vez disso: o estado pode ser grande, então verificar "a coisa certa aconteceu com o objeto inteiro" pode levar várias linhas de asserção onde um teste baseado em saída precisaria de uma (compare o bloco de asserção de quatro linhas que uma checagem de estado em uma coleção Comments precisa contra o único assertEquals que uma checagem baseada em saída equivalente precisaria). Value objects com igualdade adequada, ou pequenos helpers de asserção, conseguem reduzir essa verbosidade, mas não removem a diferença de tamanho subjacente.
Teste baseado em comunicação, verificar que o SUT chamou um colaborador de uma forma particular, como no exemplo InventoryReserver acima, deveria ser a exceção, não o padrão. O raciocínio se conecta diretamente a por que mockar detalhes de implementação torna os testes frágeis: um padrão de chamada normalmente não é o comportamento observável com que um cliente se importa, é como esse comportamento por acaso é implementado hoje. Recorra a uma verificação de mock só quando a própria interação cruza a fronteira da aplicação e é o efeito observável: enviar um e-mail, publicar um evento, escrever em uma API externa da qual o sistema de outra pessoa depende. Qualquer coisa mais profunda sobre esse trade-off pertence especificamente à fragilidade de mocks, não a esta comparação.
Lado a lado, através das duas métricas que de fato diferem entre os estilos:
| Baseado em saída | Baseado em estado | Baseado em comunicação | |
|---|---|---|---|
| Diligência necessária para resistência à refatoração | Baixa | Média | Média (alta se usado em excesso) |
| Custo de manutenibilidade | Baixo | Médio | Alto |
(Proteção contra regressões e velocidade de feedback não dependem de forma significativa do estilo escolhido; dependem de quanto código roda e quão rápido roda, o que qualquer um dos três consegue atingir.)
Trade-offs
Baseado em saída é o teste mais barato de escrever, mas exige pureza que você pode não ter: um método que só mapeia entrada para saída é trivial de testar e quase à prova de refatoração, mas muito comportamento real (persistir um pedido, atualizar um carrinho) é definido pelo seu efeito colateral, não pelo seu valor de retorno, então esse estilo simplesmente não se aplica ali.
O custo de verificação baseada em estado escala com o tamanho do estado que você está checando: uma mudança de campo único é quase tão barata quanto uma checagem de saída, mas um objeto com múltiplos campos pode forçar várias linhas de asserção para um único comportamento:
javaassertEquals(1, article.getComments().size()); assertEquals("Comment text", article.getComments().get(0).getText()); assertEquals("John Doe", article.getComments().get(0).getAuthor());contra uma única linha se esse mesmo comentário fosse comparado como um value object com
assertEquals(expectedComment, article.getComments().get(0)).Testes baseados em comunicação são os mais caros de manter verdes: todo mock precisa ser preparado, e todo
verify(...)prende o teste a uma forma específica de chamada; cadeias de mock (um mock retornando um mock retornando um mock) multiplicam esse custo rápido e são um sinal de que o design, não só o teste, precisa de uma revisão.Usar mocks em excesso não custa só manutenibilidade, pode esconder superficialidade: um teste que mocka tudo, exceto uma fatia fina do SUT, ainda pode passar sem verificar quase nenhum comportamento real; isso é um sintoma de depender do teste baseado em comunicação como padrão, em vez de como ferramenta ocasional.
Os três estilos podem aparecer no mesmo teste, e tudo bem: um teste pode chamar um método, checar seu valor de retorno, e inspecionar o estado que deixou para trás; o que importa é saber qual asserção está de fato fazendo o trabalho de capturar uma regressão, e qual está só de carona.