Article 6 de la série “De la spécification à l’exécution”. Cinq articles plus tôt, j’ai posé la spec, les rôles, la mesure et le property-based testing. Il reste à voir à quoi ressemble le workflow sur une vraie pile.

Je prends ici un stack volontairement courant. React côté front, Go avec Gin, GORM et PostgreSQL côté back. Le cas d’usage est simple. C’est justement ce qui le rend utile. Le repo public contient un boilerplate runnable dans examples/billing-react-go/.

Je veux montrer trois choses. D’abord, comment je répartis les tests par couche. Ensuite, quels choix changent vraiment la fiabilité. Enfin, comment je peux repartir de ce squelette sans réécrire le workflow à la main.

Cartographie de la pile de tests

Je garde une pyramide de tests classique, avec une couche de property-based testing au niveau bas.

CoucheOutil principalVitesseCe que je teste
Front, utils et formattersVitestmsLogique métier isolée
Front, composants ReactVitest + RTLms-sRendu et interactions
Front, vues avec APIVitest + RTL + MSWsComposants avec données simulées
Front, invariants logiquesfast-checksParsing, calcul, état
Back, fonctions purestesting + testifyµs-msHelpers, calcul, validation
Back, handlers HTTPhttptest + GinmsRouting, validation, réponses
Back, repository GORMtestcontainers-go + PostgreSQLsSQL réel, migrations, transactions
Back, invariants de logiquerapidsPropriétés sur pricing, routing, state
Cross-stack, contract APIPactsCompatibilité front-back sans E2E complet
Cross-stack, E2EPlaywrights-minParcours critiques

La pyramide reste valable. Je ne cherche pas à tout mettre en bout de chaîne. Le property-based testing me permet surtout d’augmenter la force des tests en bas de pile, pas leur volume.

Trois choix structurants font la différence ici. Ils comptent plus que le nom du framework.

Choix structurant 1, testcontainers-go plutôt que sqlmock

Le piège classique, côté backend Go avec GORM, consiste à tester le repository en mockant *sql.DB avec sqlmock. C’est rapide. C’est isolé. C’est aussi fragile.

Le problème est simple. Je ne teste pas PostgreSQL. Je teste le SQL que GORM a généré, puis je fais semblant que la base se comporte comme prévu. Dès qu’une migration change, qu’une contrainte s’ajoute ou qu’une version de GORM modifie le SQL produit, le test peut continuer à passer alors que la base réelle ne suivra pas.

Je préfère testcontainers-go. Je démarre un vrai PostgreSQL dans un container, j’applique les migrations, puis je teste le repository contre la base réelle. Le coût reste raisonnable. Quelques secondes de plus par package me semblent un bon échange contre des erreurs de base de données évitées.

Pattern de base :

//go:build integration

func setupDB(t *testing.T) (*gorm.DB, func()) {
    ctx := context.Background()
    pg, _ := tcpostgres.Run(ctx, "postgres:16-alpine", ...)
    dsn, _ := pg.ConnectionString(ctx, "sslmode=disable")
    m, _ := migrate.New("file://migrations", dsn)
    m.Up()
    db, _ := gorm.Open(gpostgres.Open(dsn), &gorm.Config{})
    return db, func() { ... }
}

Les bénéfices sont concrets.

  • Les migrations sont testées avec golang-migrate, dans le même ordre qu’en production.
  • Les erreurs de base sont classées sémantiquement, par exemple avec IsUniqueViolation(err), pas par comparaison de chaînes.
  • GORM est testé pour de vrai. Je ne découvre pas au moment du déploiement qu’un sqlmock me racontait une histoire trop propre.

Le tag //go:build integration permet de séparer les tests rapides des tests d’intégration. Je garde go test ./... pour les unit tests rapides, puis go test -tags=integration ./... pour la base réelle.

Choix structurant 2, MSW strict côté React

Côté front, je fais l’inverse d’un mock de fetch au niveau applicatif. Je mocke au niveau réseau avec MSW.

MSW intercepte les requêtes HTTP au niveau du transport, pas au niveau du code métier. Le code de production ne voit pas le mock. Je peux aussi réutiliser les mêmes handlers en test Node et en développement navigateur via Service Worker. Je limite ainsi les écarts entre ce que je teste et ce que je lance en local.

La ligne importante est celle-ci :

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));

Avec cette option, toute requête non déclarée fait échouer le test. Sans elle, MSW laisse parfois passer une requête vers une vraie URL. J’obtiens alors des API fantômes, des tests verts qui ne testent pas vraiment le chemin attendu, et un faux sentiment de sécurité.

Je garde aussi un handler par défaut qui répond avec une erreur claire si je n’ai rien déclaré pour la requête.

export const handlers = [
  http.all('*', () =>
    HttpResponse.json(
      { error: 'no MSW handler declared for this request' },
      { status: 599 },
    ),
  ),
];

Chaque test surcharge ensuite les handlers avec server.use(...). Je préfère cette contrainte explicite à un mock permissif qui masque les oublis.

Choix structurant 3, Pact pour le contract testing

Les tests unitaires du front et du back ne voient pas les cassures de contrat entre les deux. Les E2E les voient, mais ils coûtent plus cher, prennent plus de temps, et dépendent d’une infrastructure complète.

Pact comble ce trou. Le pattern est classique, consumer-driven contract testing.

  1. Le consumer, ici React, écrit un test qui décrit ce qu’il attend de l’API.
  2. Ce test génère un fichier pact JSON.
  3. Le provider, ici Go, vérifie qu’il satisfait ce pact.

Côté consumer, je décris l’interaction attendue.

await provider
  .addInteraction()
  .given('a tariff for prefix 336 exists at rate 1500')
  .uponReceiving('a rate request for a French mobile number')
  .withRequest('POST', '/api/rate', builder => builder.jsonBody({
    duration_seconds: 120,
    destination: '+33612345678',
  }))
  .willRespondWith(200, builder => builder.jsonBody({
    cost_millicents: integer(3000),
    tariff_prefix: string('336'),
  }))
  .executeTest(async (mockServer) => {
    const result = await rateCall({...}, mockServer.url);
    expect(result.tariff_prefix).toBe('336');
  });

Côté provider, je vérifie le contrat contre l’API réelle.

verifier.VerifyProvider(t, provider.VerifyRequest{
    ProviderBaseURL: "http://localhost:8181",
    PactFiles:       []string{pactFile},
    StateHandlers: models.StateHandlers{
        "a tariff for prefix 336 exists at rate 1500": func(setup bool, _ models.ProviderState) (models.ProviderStateResponse, error) {
            if setup { seedTariff(336, 1500) }
            return nil, nil
        },
    },
})

Je garde les matchers les plus permissifs possibles. integer(3000) plutôt que 3000, par exemple. Pact me sert surtout à détecter les cassures de contrat au build, pas à remplacer tout le reste.

Le boilerplate à forker

Le squelette complet est dans examples/billing-react-go/ du repo public.

Le domaine est volontairement simple. Un endpoint POST /api/rate calcule le coût d’un appel téléphonique à partir d’un tarif stocké en base. C’est assez petit pour comprendre vite, assez concret pour voir le workflow en vrai.

Je couvre six couches.

  1. Unit + PBT Go dans api/pricing/, avec testify pour les tests table-driven et rapid pour trois propriétés, positivité, monotonie, métamorphique.
  2. Handler HTTP Go dans api/handlers/, avec httptest et gin.Engine, plus un fakeLookup en mémoire.
  3. Repository DB Go dans api/repository/, avec testcontainers-go, golang-migrate et le tag //go:build integration.
  4. Provider Pact Go dans api/pacts/, avec pact-go et des StateHandlers qui seedent la base.
  5. Component React dans frontend/src/components/, avec Vitest, RTL et MSW strict.
  6. Consumer Pact React dans frontend/src/api/, avec pact-js V4 et des matchers permissifs.

Je garde aussi une chaîne CI simple.

  • frontend-unit
  • backend-unit
  • backend-integration
  • mutation sur les packages modifiés
  • contract
  • e2e seulement sur main

Je ne veux pas un pipeline spectaculaire. Je veux un pipeline lisible.

Lancement local :

git clone https://github.com/mwolff44/spec-to-tests
cd spec-to-tests/examples/billing-react-go

# Backend rapide, sans Docker
cd api && go mod tidy
go test -race ./pricing/... ./handlers/...

# Backend intégration, avec Docker
go test -race -tags=integration ./repository/...

# Frontend
cd ../frontend && pnpm install && pnpm test

# Contract testing cross-stack
pnpm test:pact
cd ../api && go test -tags=pact ./pacts/...

Si je veux monter la pile complète, docker-compose.yml est là pour ça.

Trois points à retenir

1. Je garde une pile de tests simple, mais je la répartis proprement. Vitest, RTL, MSW et fast-check côté front, testify, httptest, testcontainers-go et rapid côté back, Pact en cross-stack, Playwright pour quelques parcours critiques.

2. Trois choix structurants gardent la pile fiable. testcontainers-go plutôt que sqlmock, MSW strict avec onUnhandledRequest: 'error', Pact pour détecter les cassures de contrat au build.

3. L’IA peut produire le code vite. La fiabilité vient du workflow, pas de la vitesse seule. Le boilerplate runnable existe déjà, je peux le cloner et l’adapter.

La semaine prochaine, j’attaque le dernier article de la série. Je parlerai du plan.md persistant, des hooks pre-commit et du passage du plan aux PR.


Pour aller plus loin


Cet article est le sixième d’une série de sept. L’article précédent traitait du property-based testing. Le prochain parlera du plan.md, des hooks pre-commit et du passage du plan aux PR.