Retour à l'accueil

Stabiliser les Tests Playwright Sans Flakes

L'article analyse les causes des flakes dans les tests E2E Playwright et propose des solutions : polling dynamique expect.poll, idempotence, mocks multi-niveaux jusqu'à Pact, stratégies de nettoyage des données de test. Exemples de code pratiques pour la stabilité en CI.

Combattre les Flakes dans Playwright : poll, mocks, contrats
Advertisement 728x90

Stabiliser les tests E2E Playwright : Stratégies sans flakiness ni attentes fixes

Dans les pipelines CI/CD, les tests E2E Playwright flakent souvent à cause de charges élevées, de retards réseau et de concurrence sur les ressources. En local, des CPU puissants et une faible latence masquent les conditions de course : le frontend rend l'interface, mais le backend n'a pas encore validé la transaction en base. En CI, les conditions sont plus rudes — les tests détectent des états incohérents et échouent de manière aléatoire.

La clé de la fiabilité : passer des délais statiques à un polling dynamique, des requêtes idempotentes et un isolement robuste des dépendances. Plongeons dans des techniques qui réduisent drastiquement la flakiness à grande échelle.

Polling dynamique au lieu de timeouts rigides

waitForTimeout(5000) est un anti-pattern. Dans 90 % des cas, les flakes viennent d'un décalage temporel : le backend répond à 5001 ms au lieu des 5000 attendus. La solution ? expect.poll, qui vérifie l'état jusqu'au succès ou au timeout.

Google AdInline article slot
await expect.poll(async () => {
  const order = await api.getOrder(id);
  return order.status;
}, {
  message: 'Attente du statut PAID',
  timeout: 30000,
}).toBe('PAID');

Playwright ajuste automatiquement les intervalles de polling. Pour les scénarios lents, augmentez test.setTimeout(60000), mais ne modifiez pas manuellement les intervalles.

Le piège de networkidle et de meilleures alternatives

waitUntil: 'networkidle' attend 500 ms sans activité réseau. Problème : les analytics, widgets de chat ou pings brisent le silence, bloquant le test jusqu'au timeout global.

Règle : Attendez des éléments spécifiques ou des réponses API :

Google AdInline article slot
  • expect(page.getByText('Prêt')).toBeVisible()
  • expect.poll sur le statut backend

Pour réessayer des blocs d'actions, utilisez expect.toPass :

await expect(async () => {
  await page.getByRole('button', { name: 'Actualiser' }).click();
  await expect(page.getByText('Statut : Prêt')).toBeVisible();
}).toPass({
  timeout: 15000
});

Idempotence pour se protéger des pannes réseau

Dans les systèmes distribués, les retries sur pannes réseau créent des doublons : le premier POST réussit, la réponse est perdue, le retry duplique l'entité. Résultat ? Erreurs 400 ou états incohérents.

Solution : Générer une Idempotency-Key à partir de la méthode, de l'URL et du payload.

Google AdInline article slot
import { createHash } from 'crypto';

export function generateIdempotencyKey(method: string, url: string, data: any): string {
  const payload = `${method}:${url}:${JSON.stringify(data)}`;
  return createHash('sha256').update(payload).digest('hex').slice(0, 16);
}

// Dans BaseApiClient
protected async post(url: string, data?: any) {
  const key = generateIdempotencyKey('POST', url, data);
  return await this.request.post(url, {
    data,
    headers: { 'X-Idempotency-Key': key }
  });
}

Le backend renvoie les résultats cachés par clé. Les flakes dus aux retries disparaissent.

Mocks : Niveaux d'isolement du basique au piloté par contrat

Mocks natifs avec page.route

Pour isoler l'UI du backend :

await page.route('**/api/orders', route => {
  route.fulfill({
    status: 500,
    body: JSON.stringify({ error: 'Erreur serveur interne' })
  });
});

await page.goto('/orders');
await expect(page.getByText('Quelque chose s’est mal passé')).toBeVisible();

Limite : Ignore les requêtes serveur via le fixture request.

Mocks d'infrastructure (WireMock)

Le backend dépend de services externes (paiements, SMS) ? Lancez WireMock :

# docker-compose.yml
services:
  wiremock:
    image: wiremock/wiremock:3.3.1
    ports:
      - "8080:8080"
    volumes:
      - ./wiremock/mappings:/home/wiremock/mappings

Exemple de mapping (payment.json) :

{
  "request": {
    "method": "POST",
    "url": "/v1/payments"
  },
  "response": {
    "status": 200,
    "jsonBody": {
      "payment_id": "pay_test_123",
      "status": "succeeded"
    }
  }
}

Les tests restent indépendants de la disponibilité externe.

Tests de contrat vs mocks mensongers

Les mocks mentent : le frontend attend order_id, le backend envoie orderId — les tests passent au vert, la prod plante.

Les Consumer-Driven Contracts (Pact) verrouillent les attentes dans des contrats JSON :

import { PactV3, MatchersV3 } from '@pact-foundation/pact';

const provider = new PactV3({
  consumer: 'frontend-tests',
  provider: 'order-service',
  dir: './pacts',
});

it('returns order by id', async () => {
  await provider
    .given('order ord_123 exists')
    .uponReceiving('GET /orders/ord_123')
    .withRequest({ method: 'GET', path: '/orders/ord_123' })
    .willRespondWith({
      status: 200,
      body: {
        order_id: MatchersV3.string('ord_123'),
        status: MatchersV3.string('PAID'),
      },
    })
    .executeTest(async (mockServer) => {
      const order = await fetchOrder(mockServer.url, 'ord_123');
      expect(order.status).toBe('PAID');
    });
});

Le backend vérifie le contrat dans son CI. Les changements d'API sont détectés avant déploiement.

Hygiène des données de test

Les tests encombrent la base — les perfs chutent, les contraintes d'unicité causent des flakes.

1. TTL avec nettoyage en arrière-plan

await api.createUser({
  email: `test_${Date.now()}@example.com`, 
  expires_at: new Date(Date.now() + 24*60*60*1000).toISOString()
});

PostgreSQL pg_cron :

SELECT cron.schedule('cleanup-test-data', '0 * * * *', $$
  DELETE FROM users WHERE expires_at < NOW() AND is_test = true;
$$);

2. File d'attente de nettoyage

Collectez les ID dans une file à la création, purgez au teardown global :

protected async post(url: string, data?: any) {
  const response = await this.request.post(url, { data });
  const body = await response.json();
  
  if (body.id) {
    cleanupQueue.push({ url, id: body.id });
  }
  return response;
}

3. Partitionnement de tables

CREATE TABLE orders_test (
  id UUID, created_at TIMESTAMP
) PARTITION BY RANGE (created_at);

CREATE TABLE orders_test_2024_06 
  PARTITION OF orders_test
  FOR VALUES FROM ('2024-06-01') TO ('2024-07-01');

Supprimer des partitions est O(1).

Points clés :

  • Remplacez waitForTimeout par expect.poll et expect.toPass pour des attentes intelligentes.
  • Idempotence via X-Idempotency-Key élimine les doublons de retry.
  • Évolution des mocks : page.route → WireMock → Pact pour un vrai isolement.
  • TTL + cron/files/partition pour des données propres.
  • La stabilité est systémique : corrigez backend et infra en parallèle des tests.

— Editorial Team

Advertisement 728x90

Lire ensuite