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
| Stage | Job | Ce qu’il fait |
|---|---|---|
| test | unit-tests | Vitest sur les composants |
| security | npm-audit | Vérifie les vulnérabilités npm |
| build | build | astro build ? produit dist/ |
| e2e | e2e-tests | Playwright sur le build?? |
| deploy | deploy | rsync 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: 1en CI - si un test est flaky (race condition), il est relancé une foiswebServerdifférent selon l’environnement - en CI on sert ledist/avec python, en local on lance le serveur dev Astrotimeout: 30000pour le webServer en CI - augmenté après un bugtrace: '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 :
- Augmenter le timeout du
webServerde 15s à 30s - Ajouter
retries: 1pour 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 ledist/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ège | Solution |
|---|---|
| Serveur pas prêt au premier test | Augmenter timeout du webServer |
| Tests flaky en CI | retries: 1 |
| chromium ne démarre pas | --with-deps chromium pour les libs système |
| Artifacts trop gros | artifacts: when: on_failure |
| Deploy sans test | needs: [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é.