Testcontainers démarre un vrai PostgreSQL dans un conteneur Docker jetable pour la durée d’une suite de tests, puis l’arrête. Pour un SaaS multi-tenant dont la sécurité repose sur la base (RLS, rôles, privilèges), c’est la seule façon de tester ce qui protège réellement les données : un mock de base ne connaît ni les policies, ni les rôles, ni les transactions. Encore faut-il que le test emprunte le chemin réel, de la requête HTTP au rôle PostgreSQL de production, faute de quoi une suite verte peut cacher une garde qui ne garde rien.

Pourquoi tester contre un vrai PostgreSQL plutôt qu’avec des mocks ?

Parce que les garanties qui comptent vivent dans la base, et qu’un mock les remplace par ce que le développeur croit qu’elles font. Une policy RLS qui oublie une commande, un GRANT manquant sur une table jointe, un SET LOCAL posé hors transaction : aucun de ces défauts n’apparaît avec un pool simulé, qui a tous les droits et aucune règle. Tester contre le même moteur, dans la même version majeure qu’en production et avec les mêmes migrations, transforme ces hypothèses en faits vérifiés, au prix d’un démarrage de conteneur par suite.

Comment démarrer PostgreSQL avec Testcontainers ?

Avec le module @testcontainers/postgresql en Node.js, une base se démarre en quelques lignes. Voici l’extrait de la fixture partagée de saas-core (apps/api/test/support/fixture.ts), mot de passe de test omis :

if (!ownerUrl) {
  const { PostgreSqlContainer } = await import("@testcontainers/postgresql");
  // image épinglée par empreinte dans le dépôt, abrégée ici
  const container = await new PostgreSqlContainer("postgres:17-alpine@sha256:…")
    .withDatabase("saas_core")
    .withUsername("saas_owner")
    .start();
  ownerUrl = container.getConnectionUri();
  stop = async () => {
    await container.stop();
  };
}

await bootstrapAppRole(ownerUrl);
await migrate(ownerUrl);

Trois détails comptent :

  • l’image est épinglée par empreinte, pour que la base de test ne change pas sous les pieds d’une exécution à l’autre ;
  • les migrations du dépôt sont appliquées avec la connexion propriétaire, exactement comme en production ;
  • le rôle applicatif est créé NOBYPASSRLS, et l’application de test se connecte avec lui, jamais avec le propriétaire.

Conteneur éphémère ou base fournie ?

La même fixture accepte une base déjà démarrée via TEST_DATABASE_URL_OWNER. C’est le mode de la CI : le workflow GitHub Actions déclare un service PostgreSQL, et chaque suite qui compte des lignes sur toute la base peut demander une base isolée sur ce serveur (options.isolated). En local, sans variable, Testcontainers prend le relais. Le code de test ne change pas d’un mode à l’autre.

Pourquoi une garde testée peut-elle ne rien garder ?

Parce qu’un test peut vérifier la mauvaise chose avec succès. Le dépôt de saas-core consigne un épisode précis : en deux jours, trois gardes de sécurité qui ne gardaient rien (l’état d’un espace, le statut d’une adhésion, un privilège de lecture du relais) avaient toutes des tests verts. Les trois testaient la fonction de décision, pure et exhaustive, ou le texte d’une requête, jamais le chemin réel. La fonction était juste ; l’entrée qu’on lui donnait en production ne l’était pas.

Les règles qui en découlent tiennent en quatre lignes :

Ce qu’on vérifieComment le vérifier
une gardepar une vraie requête HTTP qui reçoit le refus
un privilègesous le vrai rôle PostgreSQL, pas sur un pool simulé
un accès retiréavec une session obtenue après le retrait ; une session ancienne passe même sans garde
une correctionen la retirant : le test doit rougir sans elle

C’est pourquoi les tests API de saas-core passent obligatoirement par createApp(), le câblage de production (CORS, hook de corps brut, montage de l’authentification). Un préflight CORS qui refusait PUT a déjà cassé toutes les écritures de l’admin avec des tests API verts, parce que l’application de test, assemblée à la main, ne contenait pas CORS.

Qu’est-ce qu’un méta-test, et pourquoi en écrire ?

Un méta-test échoue sur une règle, pas sur un cas. Au lieu de vérifier qu’une table donnée est isolée, il interroge le catalogue PostgreSQL et échoue si une table à tenant_id n’a pas RLS, FORCE et une policy. Au lieu de vérifier qu’un handler écrit son audit, il lit l’AST de tous les contrôleurs. La règle tient quand la surface grandit, sans compter sur la mémoire de la relecture.

saas-core en compte plusieurs, listés dans son CLAUDE.md : isolation RLS (rls.spec.ts), couverture de l’audit (audit-coverage), couverture des autorisations (authz-coverage), rattrapage des permissions des propriétaires (owner-backfill-coverage), e-mails uniquement par l’outbox (mail-outbox-coverage), arguments ICU des catalogues de traduction, et couverture des routes. Le détail des deux premiers est dans les articles sur la RLS PostgreSQL multi-tenant et sur le journal d’audit en ajout seul.

Comment vérifier que chaque route publiée est testée ?

En comparant le contrat publié aux routes réellement frappées pendant la suite. Pendant les tests, un hook du bootstrap ajoute chaque route appelée à un fichier .route-trace. Après vitest run, le script apps/api/scripts/check-route-coverage.ts lit openapi.json, en extrait chaque opération, et échoue en listant celles qu’aucun test n’a appelées.

Il vérifie d’abord que openapi.json, régénéré à la main, décrit encore l’application servie : sinon une route ajoutée sans régénération échapperait au contrôle.

Que vérifient les tests de bout en bout dans un navigateur ?

Ils vérifient ce que l’utilisateur reçoit, avec un vrai navigateur, une vraie API et un vrai worker. Dans saas-core, la suite Playwright (apps/admin/e2e/) tourne dans le workflow e2e avec PostgreSQL, Redis et Mailpit en services, pour qu’un parcours d’invitation ne soit prouvé que si l’e-mail est réellement relayé et reçu.

Deux filets s’ajoutent aux assertions écrites :

  • le fixture problems (apps/admin/e2e/fixtures.ts) fait échouer tout test qui produit une erreur console ou une requête rejetée par le navigateur, ce qui attrape une panne d’infrastructure comme un préflight CORS refusé, même quand aucune assertion ne la vise ;
  • apps/admin/e2e/a11y-axe-ecrans.spec.ts passe axe-core sur les écrans réels, avec les tags wcag2a, wcag21a, wcag2aa et wcag21aa ; une violation est un échec, pas un avertissement.

Pourquoi fixer des seuils de charge sur les percentiles ?

Parce qu’une moyenne masque exactement ce que l’utilisateur subit : quelques requêtes très lentes noyées dans une majorité rapide. Le scénario k6 de saas-core (load/api-load.js) fixe ses seuils sur le 95e percentile, par route, et refuse tout 429 qui transformerait la mesure de capacité en mesure du limiteur de débit :

thresholds: {
  "http_req_duration{route:health}": ["p(95)<50"],
  "http_req_duration{route:members}": ["p(95)<300"],
  "http_req_duration{route:abilities}": ["p(95)<250"],
  business_errors: ["rate<0.01"],
  rate_limited: ["rate==0"],
  checks: ["rate>0.99"],
},

Le load/README.md du dépôt insiste sur deux points : mesurer sur l’API compilée et jamais sur le serveur de développement, qui domine la mesure, et lire ces seuils comme un détecteur de régression d’ordre de grandeur (une requête N+1, un index manquant), pas comme une mesure de production.

Comment saas-core l’intègre

  • Tests API : Vitest, PostgreSQL réel via Testcontainers ou via TEST_DATABASE_URL_OWNER, migrations appliquées, application démarrée par createApp() sous le rôle saas_app (apps/api/test/support/fixture.ts). L’image de test est la même image PostgreSQL 17, à la même empreinte, que celle de compose.prod.yml.
  • Méta-tests : dans la suite elle-même, donc dans pnpm test.
  • Couverture des routes : le script test de apps/api/package.json enchaîne vitest run et check-route-coverage.ts.
  • E2E : Playwright et axe-core dans le workflow e2e (.github/workflows/e2e.yml), sur un PostgreSQL de service et un rôle applicatif NOBYPASSRLS.
  • Charge : k6 sur l’API compilée, dans le workflow load, déclenché par une étiquette de version. Ce workflow est informatif : un seuil franchi produit un avertissement et un artefact de résumé, sans bloquer la release.

Les workflows ci et e2e sont bloquants pour la livraison : une étiquette de version n’est mise en ligne que si les deux sont verts sur le commit exact. Ils ne bloquent pas la fusion sur main, dont la protection de branche est aujourd’hui absente. L’enchaînement complet est décrit dans l’article sur le pipeline CI/CD d’un SaaS.

Pour aller plus loin

La fixture Testcontainers, les méta-tests, la suite Playwright et le scénario k6 sont livrés avec le socle. Le contenu de chaque formule est présenté sur la page tarifs.