ConstructiCat Logo
CodeBust.
Browse section ▾

Conditional Test Logic.

Um teste que usa `if`/`switch`/ternários, laços ou `try`/`catch` para decidir o que executar ou verificar, de modo que seu comportamento — e se ele verifica algo de fato — depende de qual ramo é executado em tempo de execução.

##Signs and Symptoms

Um teste se lê como um pequeno programa em vez de um script linear "arrange → act → assert". Procure por fluxo de controle dentro do corpo do teste:

  • if/else, switch, ternários ou curto-circuitos &&/|| que controlam quais asserções rodam.
  • Laços for/while/forEach que constroem entradas ou iteram sobre asserções.
  • try/catch usado para "testar" um caminho de erro, com chamadas expect escondidas dentro do catch.
  • Um único teste reutilizado para vários casos ramificando com base em uma flag ou no estado do ambiente.
  • Valores esperados calculados no teste (muitas vezes com um laço ou fórmula) em vez de fixos no código.
// Smell: as asserções ficam dentro de código condicional/ramificado
test('user discount', () => {
  const user = getUser();
  if (user.isPremium) {
    expect(price(user)).toBe(80);   // pode nunca rodar
  } else {
    expect(price(user)).toBe(100);  // pode nunca rodar
  }
});

test('throws on bad input', () => {
  try {
    parse('!!!');
    // se parse() NÃO lançar erro, caímos fora e não verificamos nada → o teste passa
  } catch (err) {
    expect(err.message).toMatch(/invalid/);
  }
});

O modo de falha revelador: o teste continua verde mesmo quando o código está quebrado, porque o ramo que contém a asserção nunca foi tomado.

##Reasons for the Problem

Por que acontece

  • DRY levado longe demais. Os autores tentam cobrir vários cenários com um único teste "flexível", ramificando com base nas entradas ou em uma flag em vez de escrever um teste por caso (Meszaros: Flexible Test).
  • Acoplamento ao ambiente. O SUT não foi desacoplado de suas dependências, então o teste se adapta a qualquer estado que encontrar em tempo de execução.
  • Teardown defensivo. if (resource) resource.close() se infiltra para evitar desmontar fixtures que podem não existir (Complex Teardown).
  • Expectativas calculadas. O resultado esperado é derivado com o mesmo algoritmo do código de produção, arrastando essa lógica — laços e tudo — para dentro do teste (Production Logic in Test).
  • Teste de caminho de erro na mão. try/catch é usado para verificar um erro lançado em vez de um matcher nativo.

Por que prejudica

  • Falsa confiança (a pior parte). A maioria dos executores só reprova um teste quando uma asserção lança erro. Se o ramo que faz a asserção for pulado — ou se o código sob teste não lançar erro dentro de um try — o teste passa tendo verificado nada.
  • Código de teste sem testes. Ramos e laços em um teste são lógica que, por si só, não tem testes; um bug no próprio fluxo de controle do teste passa despercebido.
  • Diagnóstico ruim. Quando um teste com ramificações falha, você precisa primeiro descobrir qual caminho rodou antes de conseguir interpretar a falha.
  • Menor legibilidade e manutenibilidade. Um teste linear documenta um comportamento com um único resultado esperado; um teste com ramificações força o leitor a simular a execução para saber o que de fato é garantido.
  • Fragilidade. Testes que dependem do estado de execução/ambiente passam ou falham de forma não determinística.

##Treatment

Faça cada teste ser um único caminho linear e incondicional. Concretamente:

  1. Um cenário por teste. Quebre um teste com ramificações em testes separados, ou use a API orientada a dados do framework (test.each, it.each, testes parametrizados) para que cada caso seja sua própria execução, com nome claro e reporte independente.
  2. Eleve as ramificações para fora do teste. Se um caso só se aplica sob alguma condição, decida isso no momento da definição (por exemplo, describe/it escolhidos pela configuração), não dentro do corpo do teste — as próprias asserções permanecem incondicionais.
  3. Fixe os valores esperados no código. Substitua expectativas calculadas por resultados esperados literais (ou um Expected Object / matcher personalizado). Não reimplemente a lógica de produção no teste.
  4. Teste caminhos de erro com matchers, não com try/catch: expect(fn).toThrow(...), await expect(p).rejects.toThrow(...). Eles falham de forma evidente quando nenhum erro é lançado.
  5. Se uma asserção condicional for realmente inevitável, fixe a contagem com expect.assertions(n) / expect.hasAssertions() para que um ramo pulado falhe em vez de passar silenciosamente.
  6. Substitua o teardown condicional por hooks de ciclo de vida do framework (afterEach) e limpeza automática/idempotente, de modo que nenhum if seja necessário para proteger o teardown.
// Antes — teste com ramificações, as asserções podem ser puladas
test('user discount', () => {
  const user = getUser();
  if (user.isPremium) expect(price(user)).toBe(80);
  else                expect(price(user)).toBe(100);
});

// Depois — um caso explícito por linha, toda asserção sempre roda
test.each([
  ['premium', { isPremium: true },  80],
  ['regular', { isPremium: false }, 100],
])('price for %s user', (_label, user, expected) => {
  expect(price(user)).toBe(expected);
});

// Antes — try/catch que passa quando nada lança erro
try { parse('!!!'); } catch (e) { expect(e.message).toMatch(/invalid/); }

// Depois — falha se parse() não lançar erro
expect(() => parse('!!!')).toThrow(/invalid/);

##Detected by