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/forEachqui construisent les entrées ou itèrent sur les assertions. - Un
try/catchutilisé pour « tester » un chemin d'erreur, avec des appelsexpectcachés dans lecatch. - 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/catchsert à 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 :
- 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. - 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/itchoisi selon la configuration), pas dans le corps du test — les assertions elles-mêmes restent inconditionnelles. - 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.
- 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. - 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. - Remplacez le démontage conditionnel par les hooks de cycle de vie du framework (
afterEach) et un nettoyage automatique/idempotent, afin qu'aucunifne 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
- eslint-jest jest/no-conditional-expect — no-conditional-expect
- eslint-jest jest/no-conditional-in-test — no-conditional-in-test
- eslint-vitest vitest/no-conditional-expect — no-conditional-expect
- eslint-vitest vitest/no-conditional-in-test — no-conditional-in-test
- eslint-vitest vitest/no-conditional-tests — no-conditional-tests