Ce test E2E 'instable' a révélé un vrai bug UX

Erik Treviño avatar
Erik Treviño
Cover for Ce test E2E 'instable' a révélé un vrai bug UX

Le test était simple. Cliquer sur un bouton. Attendre qu’une boîte de dialogue se ferme. Vérifier le résultat.

Il passait 95 % du temps. Les 5 % restants, la boîte de dialogue mettait plus de 30 secondes à se fermer. Uniquement en CI. En local, tout allait bien. L’équipe l’avait déjà étiqueté : instable.

La plupart des équipes auraient ajouté un timeout plus long, configuré retries: 2, et seraient passées à autre chose. « La CI est lente, les tests sont instables, on livre. » J’ai vu cette réaction tellement de fois qu’elle est devenue un réflexe. Mais « instable » n’est pas un diagnostic. C’est l’absence d’un diagnostic. Alors j’ai creusé.

Ce que j’ai trouvé n’était pas un problème de test. C’était un vrai bug UX qui affecte chaque utilisateur en production.

Le test

Voici à quoi ressemblait le test — du Playwright classique interagissant avec une boîte de dialogue de mutation :

test('user can update record status', async ({ page }) => {
  await page.goto('/records');

  // Open the status update dialog
  await page.getByRole('row', { name: /Record-1042/ })
    .getByRole('button', { name: 'Update Status' })
    .click();

  // Select new status and confirm
  await page.getByRole('combobox', { name: 'Status' }).selectOption('approved');
  await page.getByRole('button', { name: 'Confirm' }).click();

  // Wait for dialog to close, then verify the result
  await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
  await expect(
    page.getByRole('row', { name: /Record-1042/ }).getByText('Approved')
  ).toBeVisible();
});

L’échec survenait toujours à la même ligne : await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }). La boîte de dialogue restait ouverte pendant plus de 30 secondes. Le timeout de 10 secondes expirait. Le test échouait.

L’investigation

La première étape consistait à vérifier que l’échec était réel et non un artefact de l’infrastructure CI. J’ai exécuté le test 100 fois en local avec --repeat-each=100. Il passait à chaque fois. Je l’ai relancé avec un CPU bridé (ralentissement 6x via l’émulation Playwright) pour simuler les contraintes de ressources de la CI. Il a échoué 3 fois sur 100. L’échec dépendait de l’environnement — la contention des ressources le faisait apparaître.

La deuxième étape consistait à comprendre ce que le gestionnaire de fermeture de la boîte de dialogue faisait réellement. J’ai ouvert le code source frontend et j’ai tracé le flux de mutation.

La cause racine : le cycle de vie de la mutation

La boîte de dialogue utilisait TanStack Query (React Query) pour sa mutation. Quand l’utilisateur clique sur « Confirmer », la mutation se déclenche. En cas de succès, le handler onSuccess de la boîte de dialogue s’exécute. C’est là que se cache le problème :

// The mutation hook — simplified from the actual codebase
const updateStatus = useMutation({
  mutationFn: (data: StatusUpdate) =>
    api.patch(`/records/${data.id}/status`, { status: data.status }),

  onSuccess: async () => {
    // This is the problem: awaiting all cache invalidations
    // before closing the dialog
    await Promise.all([
      queryClient.invalidateQueries({ queryKey: ['records'] }),
      queryClient.invalidateQueries({ queryKey: ['record-detail'] }),
      queryClient.invalidateQueries({ queryKey: ['record-history'] }),
      queryClient.invalidateQueries({ queryKey: ['dashboard-stats'] }),
      queryClient.invalidateQueries({ queryKey: ['team-metrics'] }),
      queryClient.invalidateQueries({ queryKey: ['audit-log'] }),
      queryClient.invalidateQueries({ queryKey: ['notifications'] }),
      queryClient.invalidateQueries({ queryKey: ['pending-reviews'] }),
    ]);

    // Dialog only closes AFTER all 8 invalidations resolve
    onClose();
  },
});

Voilà le coupable. Le handler onSuccess appelle Promise.all() sur 8 invalidations du cache de requêtes. Dans TanStack Query, invalidateQueries ne se contente pas de marquer le cache comme périmé — il déclenche un refetch. Chaque invalidation envoie une requête réseau pour recharger les données.

L’appel à onClose() — celui qui ferme la boîte de dialogue — se trouve après le Promise.all(). La boîte de dialogue ne se ferme pas tant que les 8 refetches ne sont pas terminés.

Sur une machine locale rapide avec une faible latence vers le serveur API, ces 8 requêtes se terminent en moins d’une seconde. En CI, où le runner de tests partage les ressources avec d’autres workers parallèles et où le serveur API tourne dans un conteneur avec un CPU limité, ces 8 requêtes prennent 10 à 30+ secondes pour se résoudre.

Pourquoi c’est un problème utilisateur, pas un problème de test

Le test disait la vérité. Il rapportait exactement ce qui arrive à un vrai utilisateur.

Chaque utilisateur qui clique sur « Confirmer » dans cette boîte de dialogue attend que 8 refetches de cache se terminent avant que la boîte de dialogue ne se ferme. Sur un réseau d’entreprise rapide, ils attendent peut-être 1 à 2 secondes sans s’en rendre compte. Sur une connexion mobile, ou en période de forte charge, ou quand l’API subit un trafic intense — ils fixent une boîte de dialogue ouverte en se demandant si leur action a fonctionné.

Le test E2E en CI vivait ce qu’un utilisateur sur une connexion lente vit. Le test n’était pas instable. L’UX était lente.

La correction

La correction sépare la fermeture de la boîte de dialogue de l’invalidation du cache. La boîte de dialogue doit se fermer dès que la mutation réussit (HTTP 200). L’invalidation du cache est une opération d’arrière-plan — elle met à jour les données sur la page en coulisses, mais l’action de l’utilisateur est déjà terminée.

// AFTER: Dialog closes on success. Cache invalidation is fire-and-forget.
const updateStatus = useMutation({
  mutationFn: (data: StatusUpdate) =>
    api.patch(`/records/${data.id}/status`, { status: data.status }),

  onSuccess: () => {
    // Close the dialog immediately — the user's action succeeded
    onClose();

    // Invalidate caches in the background — don't await
    // TanStack Query will handle the refetches asynchronously
    queryClient.invalidateQueries({ queryKey: ['records'] });
    queryClient.invalidateQueries({ queryKey: ['record-detail'] });
    queryClient.invalidateQueries({ queryKey: ['record-history'] });
    queryClient.invalidateQueries({ queryKey: ['dashboard-stats'] });
    queryClient.invalidateQueries({ queryKey: ['team-metrics'] });
    queryClient.invalidateQueries({ queryKey: ['audit-log'] });
    queryClient.invalidateQueries({ queryKey: ['notifications'] });
    queryClient.invalidateQueries({ queryKey: ['pending-reviews'] });
  },
});

Le changement clé : onClose() passe en haut de onSuccess, et les appels à invalidateQueries ne sont plus attendus. Le wrapper Promise.all() a disparu. TanStack Query gère les refetches de manière asynchrone — les données de la page se mettent à jour en arrière-plan au fur et à mesure que chaque requête se résout, ce qui correspond à ce que l’utilisateur attend.

Après la correction, la boîte de dialogue se ferme en moins de 200 ms. Les données de la page se rafraîchissent sur les 1 à 2 secondes suivantes, au fur et à mesure que les invalidations de cache se terminent. L’utilisateur voit sa action confirmée immédiatement, puis voit les données mises à jour apparaître progressivement.

Le test a passé 680 exécutions consécutives après la correction. Zéro échec.

Le piège du cycle de vie TanStack Query

Ce pattern de bug existe dans toute base de code qui utilise TanStack Query (ou des bibliothèques similaires de gestion de cache) avec la combinaison suivante :

  1. Une mutation avec un callback onSuccess
  2. Des appels invalidateQueries à l’intérieur de onSuccess qui sont await-és
  3. Une action UI (fermeture de boîte de dialogue, navigation, toast) qui dépend de la complétion de onSuccess

Le piège est que invalidateQueries renvoie une Promise. Si votre fonction onSuccess est async et que vous await l’invalidation, la mutation reste dans un état quasi-pending — onSuccess n’a pas encore terminé, donc tout ce qui dépend de sa complétion est bloqué.

La documentation de TanStack Query est explicite à ce sujet : renvoyer une Promise depuis onSuccess maintient isPending à true jusqu’à ce que la Promise se résolve. C’est utile quand vous voulez afficher un état de chargement jusqu’à ce que les données soient rafraîchies. C’est nuisible quand vous enchaînez accidentellement des interactions UI à la latence réseau.

Le pattern de diagnostic

Quand vous rencontrez une boîte de dialogue, une modale ou une navigation qui répond lentement après une mutation, vérifiez le handler onSuccess :

// 🔍 Red flag: async onSuccess with awaited invalidations
onSuccess: async () => {
  await queryClient.invalidateQueries(/* ... */);  // <-- blocks
  closeDialog();  // <-- delayed
}

// ✅ Fix: separate the user-facing action from the background refresh
onSuccess: () => {
  closeDialog();  // <-- immediate
  queryClient.invalidateQueries(/* ... */);  // <-- background
}

Ce pattern s’applique au-delà de TanStack Query. Toute bibliothèque de gestion d’état qui lie des actions UI à des opérations de rafraîchissement de cache peut produire le même bug : l’utilisateur attend des opérations de données qui devraient lui être invisibles.

Le protocole d’investigation des tests instables

Quand un test E2E expire sur une interaction UI — une boîte de dialogue qui ne se ferme pas, un bouton qui ne s’active pas, une navigation qui ne se termine pas — l’instinct est d’augmenter le timeout. Résistez à cet instinct. Le timeout est un symptôme. La question est : qu’est-ce que l’UI attend ?

Étape 1 : Reproduire sous contrainte

Exécutez le test avec un CPU bridé pour simuler les conditions de la CI. S’il échoue sous contrainte mais passe normalement, l’échec dépend des ressources, ce qui signifie qu’il y a un problème de performance caché dans des conditions normales.

// playwright.config.ts — add CPU throttling for investigation
use: {
  launchOptions: {
    args: ['--disable-gpu'],
  },
  // Simulate CI-like resource constraints
  contextOptions: {
    reducedMotion: 'reduce',
  },
},

Étape 2 : Tracer l’interaction

Utilisez le trace viewer de Playwright pour capturer ce qui se passe entre l’action utilisateur et le résultat attendu. Cherchez les requêtes réseau qui se déclenchent entre le clic et la fermeture de la boîte de dialogue. Comptez-les. Chronométrez-les.

# Run with trace on to capture the full interaction timeline
npx playwright test flaky-test.spec.ts --trace=on
# Open the trace viewer
npx playwright show-trace test-results/trace.zip

Étape 3 : Vérifier le cycle de vie de la mutation

Ouvrez le code source frontend du composant concerné. Trouvez le hook de mutation. Lisez les handlers onSuccess, onError et onSettled. Cherchez :

  • await à l’intérieur de onSuccess (bloque l’UI)
  • Promise.all() enveloppant plusieurs opérations asynchrones (multiplie la latence)
  • Des requêtes réseau qui doivent se terminer avant les mises à jour UI (dépendance séquentielle)

Étape 4 : Compter les invalidations de cache

Si vous trouvez des appels invalidateQueries, comptez-les. Chacun est une requête réseau. Sous contrainte de ressources, chaque requête ajoute de la latence. 8 invalidations x 3 secondes chacune = 24 secondes de temps bloqué. Voilà votre test « instable ».

Étape 5 : Proposer la séparation

La correction est presque toujours la même : séparer l’action visible par l’utilisateur du rafraîchissement des données en arrière-plan. Fermer la boîte de dialogue, puis invalider les caches. Naviguer sur la page, puis refetcher les données. Afficher le toast, puis mettre à jour le tableau de bord.

La leçon profonde

Votre suite E2E est ce qui se rapproche le plus d’un vrai utilisateur. Elle clique sur des boutons à la vitesse d’un utilisateur. Elle attend les réponses comme un utilisateur le ferait. Elle subit la latence comme un utilisateur sur un réseau contraint le ferait.

Quand votre suite E2E dit que quelque chose est lent, croyez-la. Quand un test expire sur une interaction UI, ce n’est pas le test qui est impatient — c’est le test qui vit votre application comme un vrai utilisateur la vit dans des conditions non idéales.

Ce test « instable » a évité à chaque utilisateur un blocage de 30 secondes sur une boîte de dialogue qui devrait se fermer en quelques millisecondes. L’investigation a pris quelques heures. La correction était simple. L’amélioration UX touche chaque utilisateur, à chaque fois qu’il utilise cette fonctionnalité.

Arrêtez d’augmenter les timeouts. Commencez à investiguer les causes racines. Le test essaie de vous dire quelque chose.