ConstructiCat Logo
CodeBust.
Browse section ▾

Dépendance à l’ordre des tests.

Un test réussit ou échoue selon les autres tests qui se sont exécutés avant lui, parce que les tests laissent fuir un état mutable partagé et s’y appuient, au lieu que chacun mette en place et démonte sa propre fixture.

##Signs and Symptoms

Le résultat d’un test dépend de l’ordre dans lequel la suite s’exécute, et pas uniquement du code testé. Chaque test est censé être autonome, mais ici un test s’appuie silencieusement sur des effets de bord laissés par un autre.

Signes révélateurs :

  • Un test réussit dans la suite complète mais échoue lorsqu’il est exécuté seul (via .only, un filtre par nom, ou en n’exécutant que ce fichier). Meszaros appelle cela un test solitaire (Lonely Test).
  • Les tests cassent lorsque vous mélangez, partitionnez (shard) ou parallélisez l’exécution, ou après la mise à niveau d’un exécuteur qui change l’ordre par défaut.
  • Supprimer, ignorer ou réordonner un test fait échouer un autre test apparemment sans rapport.
  • Un échec déclenche une cascade d’échecs en aval (les tests interagissants (Interacting Tests) de Meszaros ; la guerre des exécutions de tests (Test Run War) de van Deursen lorsque des exécuteurs parallèles entrent en collision sur une fixture partagée).
  • Les tests sont instables par intermittence (flaky) sans aucun changement de code — la dépendance à l’ordre est l’une des causes profondes les plus courantes des tests instables.

L’indice structurel est l’état mutable partagé lu à travers les tests : variables globales/de niveau module/static, un beforeAll qui remplit l’état une seule fois, ou une ressource externe non réinitialisée (lignes de base de données, fichiers, caches, variables d’environnement, faux minuteurs, mocks).

let users = []; // état partagé au niveau du module

test('creates a user', () => {
  users.push({ id: 1, name: 'Ada' });
  expect(users).toHaveLength(1);
});

// Vert uniquement parce que le test ci-dessus s'est exécuté en premier et a modifié `users`.
// Exécutez ce test seul, ou mélangez l'ordre, et il échoue.
test('finds the created user', () => {
  expect(users.find(u => u.name === 'Ada')).toBeDefined();
});

##Reasons for the Problem

Pourquoi cela arrive

  • Une fixture partagée (mise en place une seule fois dans beforeAll, une variable de portée module, un static de classe, ou une véritable base de données/un fichier réel) est modifiée par les tests et n’est jamais réinitialisée entre eux, si bien que chaque test hérite des restes du précédent.
  • Confort et rapidité : on réutilise une mise en place coûteuse ou on « s’appuie » sur les données du test précédent pour éviter de les recréer (parfois formalisé sous le nom de tests chaînés).
  • La dépendance est accidentelle et invisible — rien dans le code de test ne déclare « exécute-moi après celui-là », elle survit donc jusqu’à ce que l’ordre change.

Pourquoi c’est nuisible

  • Fiabilité / fausse confiance. La suite est verte par chance d’ordonnancement. Réordonnez, parallélisez ou exécutez un sous-ensemble, et les tests échouent (ou, pire, une vraie régression se cache parce qu’un test antérieur a laissé un état « correct » derrière lui). Les tests dépendants de l’ordre sont une source majeure de tests instables (flaky).
  • Maintenabilité. Vous ne pouvez pas exécuter, déboguer ou relancer un seul test défaillant de façon isolée — le test prérequis doit s’exécuter d’abord. Ajouter, supprimer ou réordonner des tests provoque des effets à distance déroutants.
  • Lisibilité. Un test ne documente plus un seul comportement avec des entrées explicites ; pour le comprendre, il faut lire tout ce qui s’est exécuté avant lui (un invité mystère (Mystery Guest) caché dans l’ordre d’exécution).
  • Bloque le parallélisme et la sélection. Le partitionnement (sharding) des tests, les exécuteurs parallèles et l’outillage d’impact/sélection de tests supposent tous l’indépendance ; le couplage à l’ordre les rend non fiables.

##Treatment

Rendez chaque test indépendant : il met en place tout ce dont il a besoin, vérifie, puis nettoie, de sorte qu’il produise le même résultat dans n’importe quel ordre, seul ou au sein d’une suite.

  1. Donnez à chaque test une fixture neuve (Fresh Fixture). Déplacez la mise en place partagée de beforeAll vers beforeEach (ou construisez-la à l’intérieur du test), afin que l’état soit reconstruit pour chaque test plutôt qu’accumulé.
  2. Éliminez l’état mutable partagé. Ne lisez/n’écrivez pas de variables de niveau module, static ou globales entre les tests. Construisez les objets localement ; passez les données explicitement.
  3. Réinitialisez les ressources externes lors du nettoyage (teardown). Annulez la transaction de la base de données (une transaction par test) ou utilisez des données uniques par test ; supprimez les fichiers temporaires ; restaurez les variables d’environnement, les globales et les faux minuteurs ; réinitialisez les mocks (jest.clearAllMocks() / vi.restoreAllMocks(), jest.resetModules()). Préférez un nettoyage automatique/garanti à un nettoyage manuel.
  4. Prouvez l’indépendance en randomisant l’ordre. C’est le véritable détecteur — il s’agit d’une propriété dynamique, pas de quelque chose qu’un linter peut voir :
    • Jest : --randomize / randomize: true.
    • Vitest : sequence.shuffle (configuration ou --sequence.shuffle).
    • pytest : pytest-randomly ou pytest-random-order.
    • Maven Surefire : -Dsurefire.runOrder=random. Exécutez aussi un sous-ensemble/un test unique en CI, afin que les tests solitaires (Lonely Tests) se révèlent.
  5. Si une mise en place partagée est réellement nécessaire (fixture coûteuse, en lecture seule), rendez-la immuable et partagez-la en lecture seule, ou utilisez en dernier recours une suite de tests chaînés (Chained Test) délibérément documentée — jamais une dépendance accidentelle. Notez que la règle no-hooks d’eslint-plugin-jest/eslint-plugin-vitest peut décourager les hooks de mise en place/nettoyage qui ont tendance à favoriser l’état partagé, mais elle ne détecte pas la dépendance à l’ordre elle-même.
// Avant — dépendant de l'ordre : le test 2 a besoin des restes du test 1
let users = [];
test('creates a user', () => {
  users.push({ id: 1, name: 'Ada' });
  expect(users).toHaveLength(1);
});
test('finds the created user', () => {
  expect(users.find(u => u.name === 'Ada')).toBeDefined();
});

// Après — chaque test possède sa propre fixture
function makeRepo(seed = []) {
  return { users: [...seed] };
}

test('creates a user', () => {
  const repo = makeRepo();
  repo.users.push({ id: 1, name: 'Ada' });
  expect(repo.users).toHaveLength(1);
});

test('finds an existing user', () => {
  const repo = makeRepo([{ id: 1, name: 'Ada' }]); // met en place sa propre précondition
  expect(repo.users.find(u => u.name === 'Ada')).toBeDefined();
});