ConstructiCat Logo
CodeBust.
Browse section ▾

Conditional Test Logic.

Un test qui recourt à des `if`/`switch`/ternaires, à des boucles ou à des `try`/`catch` pour décider quoi exécuter ou vérifier, de sorte que son comportement — et le fait même qu'il vérifie quoi que ce soit — dépend de la branche exécutée à l'exécution.

##Signs and Symptoms

Un test se lit comme un petit programme plutôt que comme un script linéaire « arrange → act → assert ». Cherchez un flot de contrôle dans le corps du test :

  • Des if/else, switch, ternaires, ou des court-circuits &&/|| qui conditionnent les assertions exécutées.
  • Des boucles for/while/forEach qui construisent les entrées ou itèrent sur les assertions.
  • Un try/catch utilisé pour « tester » un chemin d'erreur, avec des appels expect cachés dans le catch.
  • Un même test réutilisé pour plusieurs cas en se ramifiant sur un drapeau ou sur l'état de l'environnement.
  • Des valeurs attendues calculées dans le test (souvent avec une boucle ou une formule) au lieu d'être codées en dur.
// Mauvaise odeur : les assertions vivent dans du code conditionnel/à branches
test('user discount', () => {
  const user = getUser();
  if (user.isPremium) {
    expect(price(user)).toBe(80);   // pourrait ne jamais s'exécuter
  } else {
    expect(price(user)).toBe(100);  // pourrait ne jamais s'exécuter
  }
});

test('throws on bad input', () => {
  try {
    parse('!!!');
    // si parse() ne lève PAS, on passe sans rien vérifier → le test passe
  } catch (err) {
    expect(err.message).toMatch(/invalid/);
  }
});

Le mode d'échec révélateur : le test reste vert même quand le code est cassé, parce que la branche contenant l'assertion n'a jamais été empruntée.

##Reasons for the Problem

Pourquoi cela se produit

  • Le principe DRY poussé trop loin. Les auteurs tentent de couvrir plusieurs scénarios avec un seul test « flexible », en se ramifiant selon les entrées ou un drapeau au lieu d'écrire un test par cas (Meszaros : Flexible Test).
  • Couplage à l'environnement. Le SUT n'a pas été découplé de ses dépendances, de sorte que le test s'adapte à l'état qu'il rencontre à l'exécution.
  • Démontage défensif. Un if (resource) resource.close() s'insère pour éviter de démonter des fixtures qui pourraient ne pas exister (Complex Teardown).
  • Valeurs attendues calculées. Le résultat attendu est dérivé avec le même algorithme que le code de production, ce qui fait entrer cette logique — boucles comprises — dans le test (Production Logic in Test).
  • Test du chemin d'erreur à la main. Un try/catch sert à vérifier une erreur levée au lieu d'un matcher intégré.

Pourquoi c'est nuisible

  • Fausse confiance (le pire). La plupart des exécuteurs ne font échouer un test que lorsqu'une assertion lève une exception. Si la branche qui vérifie est ignorée — ou si le code testé ne lève rien dans le try — le test passe sans avoir vérifié quoi que ce soit.
  • Code de test non testé. Les branches et les boucles d'un test constituent une logique qui n'est elle-même pas testée ; un bogue dans le flot de contrôle du test passe inaperçu.
  • Diagnostic médiocre. Quand un test à branches échoue, vous devez d'abord déterminer quel chemin s'est exécuté avant de pouvoir interpréter l'échec.
  • Lisibilité et maintenabilité réduites. Un test linéaire documente un comportement avec un seul résultat attendu ; un test à branches oblige le lecteur à simuler l'exécution pour savoir ce qui est réellement garanti.
  • Fragilité. Les tests qui dépendent de l'état d'exécution/de l'environnement passent ou échouent de façon non déterministe.

##Treatment

Faites de chaque test un chemin unique, inconditionnel et linéaire. Concrètement :

  1. Un scénario par test. Scindez un test à branches en tests distincts, ou utilisez l'API pilotée par les données du framework (test.each, it.each, tests paramétrés) pour que chaque cas soit une exécution clairement nommée et rapportée indépendamment.
  2. Sortez les branches hors du test. Si un cas ne s'applique que sous certaines conditions, décidez-le au moment de la définition (par ex. un describe/it choisi selon la configuration), pas dans le corps du test — les assertions elles-mêmes restent inconditionnelles.
  3. Codez en dur les valeurs attendues. Remplacez les valeurs attendues calculées par des résultats attendus littéraux (ou un Expected Object / matcher personnalisé). Ne réimplémentez pas la logique de production dans le test.
  4. Testez les chemins d'erreur avec des matchers, pas avec try/catch : expect(fn).toThrow(...), await expect(p).rejects.toThrow(...). Ceux-ci échouent bruyamment quand aucune erreur n'est levée.
  5. Si une assertion conditionnelle est vraiment inévitable, figez le décompte avec expect.assertions(n) / expect.hasAssertions() pour qu'une branche ignorée échoue au lieu de passer silencieusement.
  6. Remplacez le démontage conditionnel par les hooks de cycle de vie du framework (afterEach) et un nettoyage automatique/idempotent, afin qu'aucun if ne soit nécessaire pour protéger le démontage.
// Avant — test à branches, des assertions peuvent être ignorées
test('user discount', () => {
  const user = getUser();
  if (user.isPremium) expect(price(user)).toBe(80);
  else                expect(price(user)).toBe(100);
});

// Après — un cas explicite par ligne, chaque assertion s'exécute toujours
test.each([
  ['premium', { isPremium: true },  80],
  ['regular', { isPremium: false }, 100],
])('price for %s user', (_label, user, expected) => {
  expect(price(user)).toBe(expected);
});

// Avant — try/catch qui passe quand rien n'est levé
try { parse('!!!'); } catch (e) { expect(e.message).toMatch(/invalid/); }

// Après — échoue si parse() ne lève pas
expect(() => parse('!!!')).toThrow(/invalid/);

##Detected by