Tests e2e avec Playwright sur un site Astro : mon setup CI/CD complet


TL;DR : Playwright + Astro + GitLab CI = tests e2e automatisés qui tournent à chaque push. Mais il y a des pièges - notamment les race conditions au démarrage du serveur. Voici comment j’ai tout mis en place, et ce que j’ai dû corriger.

Pourquoi des tests e2e sur un site statique ?

Mon site sevan.zone est construit avec Astro 4 et déployé en statique sur nginx via Unraid. On pourrait se dire : “c’est statique, pas besoin de tests”. Sauf que :

  • Le build Astro peut casser du jour au lendemain (dépendances, configs, composants)
  • Les liens de navigation peuvent être cassés silencieusement
  • Les pages blog, projets, apps ont des logiques dynamiques (filtres, FAQ, navigation)
  • Le deploy se fait automatiquement - mieux vaut tester avant de pousser en prod

J’ai donc mis en place une suite de 25 tests e2e avec Playwright qui vérifient l’intégralité du site à chaque push sur master.

L’architecture du pipeline

Voici ce que mon .gitlab-ci.yml fait à chaque commit :

test ? security ? build ? e2e ? deploy
StageJobCe qu’il fait
testunit-testsVitest sur les composants
securitynpm-auditVérifie les vulnérabilités npm
buildbuildastro build ? produit dist/
e2ee2e-testsPlaywright sur le build??
deploydeployrsync dist/ vers nginx (Unraid)

Le point clé : les tests e2e tournent sur le build?? (dist/), pas sur le serveur dev. Ça reproduit exactement ce qui sera déployé.

La config Playwright

Voici mon playwright.config.ts :

import { defineConfig, devices } from '@playwright/test';

const isCI = !!process.env.CI;

export default defineConfig({
  testDir: './src/tests/e2e',
  outputDir: './test-results',
  retries: isCI ? 1 : 0,
  use: {
    baseURL: 'http://localhost:4321',
    trace: 'on-first-retry',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
  webServer: isCI
    ? {
        command: 'python3 -m http.server 4321 --directory dist',
        url: 'http://localhost:4321',
        timeout: 30000,
      }
    : {
        command: 'npm run dev',
        url: 'http://localhost:4321',
        reuseExistingServer: true,
        timeout: 60000,
      },
});

Les choix importants :

  • retries: 1 en CI - si un test est flaky (race condition), il est relancé une fois
  • webServer différent selon l’environnement - en CI on sert le dist/ avec python, en local on lance le serveur dev Astro
  • timeout: 30000 pour le webServer en CI - augmenté après un bug
  • trace: 'on-first-retry' - ne capture les traces que si le test échoue (économie d’espace)

Le piège des race conditions

Le problème que j’ai rencontré : le premier test échouait systématiquement avec un timeout de 30 secondes sur page.goto('/blog/'). Les 24 autres tests passaient parfaitement.

Symptôme :

page.goto: Test timeout of 30000ms exceeded.
navigating to "http://localhost:4321/blog/", waiting until "load"

Cause : Le serveur python (python3 -m http.server 4321 --directory dist) mettait trop de temps à démarrer. Le webServer de Playwright attendait que le port soit ouvert (timeout 15s), mais le serveur n’était pas encore prêt à servir les fichiers.

La solution :

  1. Augmenter le timeout du webServer de 15s à 30s
  2. Ajouter retries: 1 pour tolérer les échecs transitoires
webServer: isCI
  ? {
      command: 'python3 -m http.server 4321 --directory dist',
      url: 'http://localhost:4321',
      timeout: 30000,  // ? augmenté de 15000
    }
  : { /* ... */ },
retries: isCI ? 1 : 0,  // ? ajouté

Depuis, le pipeline passe à chaque fois.

Structure des tests

Mes 25 tests couvrent toutes les pages du site :

// src/tests/e2e/blog.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Page Blog', () => {
  test('se charge avec le bon titre', async ({ page }) => {
    await page.goto('/blog/');
    await expect(page).toHaveTitle(/[Bb]log/);
    await expect(page.locator('h1')).toContainText('Blog');
  });

  test('liste des articles', async ({ page }) => {
    await page.goto('/blog/');
    const cards = page.locator('.post-card');
    await expect(cards.first()).toBeVisible();
    await expect(await cards.count()).toBeGreaterThan(0);
  });

  test('les filtres de tags sont présents', async ({ page }) => {
    await page.goto('/blog/');
    await expect(page.locator('.tag-btn').first()).toBeVisible();
  });

  test('filtrer par tag cache les articles sans ce tag', async ({ page }) => {
    await page.goto('/blog/');
    const tagBtn = page.locator('.tag-btn').nth(1);
    await tagBtn.click();
    await expect(tagBtn).toHaveClass(/active/);
  });
});

Les tests vérifient :

  • Le chargement de chaque page (titre, contenu)
  • La navigation entre les pages
  • Les filtres par tag
  • Les liens externes
  • La FAQ interactive
  • Les stats hero

Le job e2e dans GitLab CI

e2e-tests:
  stage: e2e
  image: node:22-bookworm
  needs: [build]
  script:
    - npm ci --no-fund
    - npx playwright install --with-deps chromium
    - npx playwright test --reporter=line
  artifacts:
    when: on_failure
    paths:
      - test-results/
    expire_in: 3 days
  rules:
    - if: $CI_COMMIT_BRANCH == "master"

Points importants :

  • needs: [build] - télécharge le dist/ généré par le stage build
  • --with-deps chromium - installe les dépendances système de Chrome (fonts, libs graphiques)
  • artifacts: when: on_failure - ne garde les screenshots/traces qu’en cas d’échec
  • --reporter=line - output minimal en CI (pas de HTML report)

Le deploy automatique

Si tous les tests passent, le deploy se fait en automatique :

deploy:
  stage: deploy
  image: alpine:latest
  needs: [build, e2e-tests]
  before_script:
    - apk add --no-cache openssh-client rsync
    - mkdir -p ~/.ssh
    - echo "$SSH_DEPLOY_KEY" > ~/.ssh/id_ed25519
    - chmod 600 ~/.ssh/id_ed25519
  script:
    - rsync -avz --delete
        --exclude='projects/'
        --exclude='fring/'
        -e "ssh -p 2222 -o StrictHostKeyChecking=no"
        dist/
        root@$DEPLOY_HOST:$DEPLOY_PATH/

Le deploy utilise rsync over SSH pour pousser le dist/ sur le serveur Unraid. La clé SSH est stockée dans les variables CI/CD de GitLab.

Les leçons apprises

PiègeSolution
Serveur pas prêt au premier testAugmenter timeout du webServer
Tests flaky en CIretries: 1
chromium ne démarre pas--with-deps chromium pour les libs système
Artifacts trop grosartifacts: when: on_failure
Deploy sans testneeds: [build, e2e-tests]

Conclusion

Mettre en place des tests e2e sur un site statique peut sembler overkill, mais ça a changé ma façon de travailler. Avant : je pushais, je vérifiais manuellement, je corrigeais les bugs en prod. Maintenant : je push, le pipeline tourne, et si ça passe c’est bon.

Le vrai gain n’est pas tant la couverture de tests que la confiance. Quand je modifie un composant, un fichier de config, ou un article de blog, je sais que le pipeline vérifiera que tout fonctionne encore.

Et avec l’IA (OpenCode dans mon cas), écrire des tests devient quasi gratuit - je lui demande de tester une fonctionnalité, elle écrit le test, je review, et c’est pushé.

Mon workflow est maintenant : écrire ? tester ? push ? deploy. Sans passer par la case “bug en prod à 23h”.


Cet article a été écrit avec l’aide d’OpenCode, un assistant IA de développement. Le site sevan.zone est construit avec Astro 4, testé avec Playwright, et déployé via GitLab CI sur un serveur Unraid self-hosté.