📐 Construire une app en JS
38. Un test, deux implémentations

Un même test pour deux implémentations

Nous avons deux BookingRepository : un en mémoire, un en SQL.

Nos quarante tests de domaine utilisent le premier. La production utilisera le second.

Formulons le problème en une phrase : tous nos tests passent contre une implémentation que personne n'exécutera jamais en production.

La divergence est inévitable

Ce n'est pas une hypothèse pessimiste, c'est une certitude, et les occasions ne manquent pas.

Le chapitre 29 en a créé une volontairement : Stay.overlaps en JavaScript, daterange && daterange en SQL. Deux expressions de la même règle, dans deux langages, écrites à neuf chapitres d'intervalle.

Le chapitre 33 en a créé une autre : save fait un upsert. En mémoire, un findIndex puis une affectation. En SQL, un on conflict do update qui ne met à jour que le statut. Sont-ils d'accord sur ce qui se passe quand on sauve deux fois la même réservation avec des dates différentes ? Personne n'a vérifié.

Et il y en a une troisième, que nous n'avons jamais remarquée. Regardez :

async findByEmail(email) {
  return this._users.find((user) => user.email === email);
}
select * from users where email = $1

Un utilisateur saisit FakeTenant@Mail.com dans le formulaire de connexion. En mémoire, === distingue les majuscules : il n'est pas trouvé, connexion refusée. En SQL, = distingue aussi… sauf si la colonne est en citext, ou si quelqu'un a écrit lower(email) dans un index, ou si la base a été créée avec une collation particulière.

Nous ne savons pas ce que fait notre application. Et la question — une adresse email est-elle sensible à la casse ? — est une règle métier (la réponse est non, en pratique) que personne n'a jamais posée.

Le test appartient au port, pas à l'implémentation

Voici l'idée, et elle est simple : écrire une seule suite de tests, qui décrit ce que doit faire n'importe quel BookingRepository, puis l'exécuter contre chaque implémentation.

// tests/contracts/bookingRepository.contract.js
import { it, expect, describe, beforeEach } from "vitest";
import { Booking, bookingStatus } from "../../domain/entities/Booking";
import { Stay } from "../../domain/values/Stay";
import { Occupancy } from "../../domain/values/Occupancy";
 
const stay = (from, to) => Stay.parse({ from, to }).value;
const guests = Occupancy.of({ adults: 2, children: 0 }).value;
 
const aBooking = (id, from, to, status = bookingStatus.confirmed) =>
  new Booking({
    id, tenantId: "tenant-1", accommodationId: "accommodation-1",
    guests, stay: stay(from, to), status,
  });
 
/**
 * @param makeRepository une fonction qui retourne un repository vide, prêt à l'emploi
 */
export function bookingRepositoryContract(makeRepository) {
  let repository;
  beforeEach(async () => { repository = await makeRepository(); });
 
  describe("save / findById", () => {
    it("retourne null quand rien ne correspond", async () => {
      expect(await repository.findById("booking-404")).toBe(null);
    });
 
    it("relit une réservation identique à celle enregistrée", async () => {
      await repository.save(aBooking("booking-1", "2024-06-02", "2024-06-04"));
      const found = await repository.findById("booking-1");
 
      expect(found.id).toBe("booking-1");
      expect(found.stay.from.toString()).toBe("2024-06-02");
      expect(found.stay.to.toString()).toBe("2024-06-04");
      expect(found.guests.total).toBe(2);
      expect(found.isActive()).toBe(true);
    });
 
    it("met à jour au lieu de dupliquer", async () => {
      const booking = aBooking("booking-1", "2024-06-02", "2024-06-04");
      await repository.save(booking);
      await repository.save(booking.cancel());
 
      const all = await repository.listBookingsForTenantId("tenant-1");
      expect(all).toHaveLength(1);
      expect(all[0].status).toBe(bookingStatus.cancelled);
    });
  });
 
  describe("findOverlapping", () => {
    beforeEach(async () => {
      await repository.save(aBooking("booking-1", "2024-06-02", "2024-06-04"));
    });
 
    it("trouve un chevauchement partiel", async () => {
      const found = await repository.findOverlapping("accommodation-1", stay("2024-06-03", "2024-06-06"));
      expect(found).toHaveLength(1);
    });
 
    it("autorise la rotation le jour du départ", async () => {
      const found = await repository.findOverlapping("accommodation-1", stay("2024-06-04", "2024-06-06"));
      expect(found).toHaveLength(0);
    });
 
    it("autorise la rotation le jour de l'arrivée", async () => {
      const found = await repository.findOverlapping("accommodation-1", stay("2024-05-30", "2024-06-02"));
      expect(found).toHaveLength(0);
    });
 
    it("ignore un autre logement", async () => {
      const found = await repository.findOverlapping("accommodation-2", stay("2024-06-03", "2024-06-06"));
      expect(found).toHaveLength(0);
    });
 
    it("ignore les réservations annulées", async () => {
      await repository.save(aBooking("booking-1", "2024-06-02", "2024-06-04").cancel());
      const found = await repository.findOverlapping("accommodation-1", stay("2024-06-03", "2024-06-06"));
      expect(found).toHaveLength(0);
    });
  });
}

Puis deux fichiers de trois lignes :

// tests/MemoryBookingRepository.test.js
import { describe } from "vitest";
import { bookingRepositoryContract } from "./contracts/bookingRepository.contract";
import { MemoryBookingRepository } from "../infra/MemoryBookingRepository";
 
describe("MemoryBookingRepository", () => {
  bookingRepositoryContract(async () => new MemoryBookingRepository(fakeAccommodations()));
});
// tests/SQLBookingRepository.integration.test.js
import { describe, beforeAll, afterAll } from "vitest";
import { bookingRepositoryContract } from "./contracts/bookingRepository.contract";
import { SQLBookingRepository } from "../infra/SQLBookingRepository";
import { testDatabase } from "./testDatabase";
 
describe("SQLBookingRepository", () => {
  const db = testDatabase();          // démarre / réutilise une vraie base
  beforeAll(db.migrate);
  afterAll(db.close);
 
  bookingRepositoryContract(async () => {
    await db.truncate();              // chaque test repart d'une base vide
    return new SQLBookingRepository(db.pool);
  });
});

Lancez la suite. Les tests de rotation passent des deux côtés — nous avons eu de la chance, ou plutôt : nous avons su ce que nous écrivions.

Le test d'annulation, lui, va probablement échouer côté mémoire ou côté SQL selon l'ordre dans lequel vous les avez écrits. C'est exactement ce qu'on lui demande.

Ce qu'un test de contrat teste

Regardez la liste : aucune assertion ne parle de tableau, de Map, de select ou de Pool.

C'est la règle absolue :

Un test de contrat décrit un comportement observable, jamais une implémentation.

S'il connaît _bookings, il ne tourne plus contre SQL. S'il connaît db.query, il ne tourne plus en mémoire. Et un test qui ne tourne que d'un côté n'est pas un test de contrat : c'est un test d'implémentation, il a le droit d'exister, ailleurs.

Écrivez-y en priorité tout ce qui vous a coûté une discussion :

  • les cas limites — la rotation, l'égalité des bornes, la liste vide
  • les valeurs de retour dans le cas "rien trouvé" — null ? undefined ? un tableau vide ? Choisissez, et écrivez-le. C'est la moitié des divergences.
  • les effets d'un second appel — sauver deux fois, annuler deux fois
  • l'ordre de tri, quand une méthode en promet un
  • la casse et les espaces, sur tout ce qui vient d'un formulaire

Et la question de l'email ? Elle mérite maintenant sa ligne dans le contrat de UserRepository :

it("trouve un utilisateur quelle que soit la casse de son email", async () => {
  await repository.save({ id: "tenant-1", email: "faketenant@mail.com", hashedPassword: "x" });
  expect(await repository.findByEmail("FakeTenant@Mail.com")).not.toBe(null);
});

En mémoire : user.email.toLowerCase() === email.toLowerCase(). En SQL : une colonne citext, ou un index sur lower(email).

Une question métier, posée une fois, répondue une fois, garantie partout. C'est exactement ce que nous cherchions depuis le début.

Une vraie base, et rien d'autre

Une objection revient toujours : ne peut-on pas simuler PostgreSQL ?

Non. Et c'est même toute la raison d'être de ce chapitre.

Un faux PostgreSQL partagerait les bugs de votre compréhension de PostgreSQL. Il vous dirait ce que vous croyez, pas ce qui est. La question qui nous occupe — comment daterange traite-t-il des bornes égales ? — n'a qu'une seule autorité : PostgreSQL.

En pratique, deux approches, et elles se valent :

Un docker-compose.yml de dix lignes, une base qui tourne pendant que vous développez, une variable d'environnement TEST_DATABASE_URL. Simple, rapide, et il faut penser à la lancer.

Testcontainers, qui démarre un conteneur au début de la suite et le détruit à la fin. Rien à installer, quelques secondes de démarrage, et cela fonctionne à l'identique sur le poste d'un nouveau développeur et dans l'intégration continue.

Sur l'isolation entre tests, deux techniques :

truncate bookings, accommodations, users restart identity cascade;

C'est brutal, c'est lisible, et sur des tables vides c'est instantané.

L'alternative élégante consiste à ouvrir une transaction avant chaque test et à faire un rollback après. Plus rapide encore — mais elle interdit de tester ce qui touche aux transactions, c'est-à-dire justement le chapitre 39. Commencez par truncate.

Deux vitesses, deux suites

Nos tests de domaine tournent en quelques millisecondes. Les tests de contrat SQL demandent une base, donc des dixièmes de seconde, parfois plus.

Ne les mélangez pas dans la même commande. Une suite lente est une suite qu'on cesse de lancer — nous l'avons déjà dit à propos d'argon2 au chapitre 30, et c'est encore plus vrai ici.

{
  "scripts": {
    "test": "vitest --exclude '**/*.integration.test.js'",
    "test:integration": "vitest --run '**/*.integration.test.js'",
    "test:all": "yarn test --run && yarn test:integration"
  }
}

yarn test tourne en continu pendant que vous codez. yarn test:all tourne avant de pousser, et dans l'intégration continue.

Et si l'intégration continue est le seul endroit qui exécute les tests SQL, alors elle doit bloquer la fusion quand ils échouent. Sinon vous avez juste construit un système d'alerte que personne ne regarde.

Le vrai bénéfice

Un test de contrat coûte une demi-journée à mettre en place. Voici ce qu'il achète.

Il rend le remplacement possible. Migrer vers une autre base, ajouter un cache, découper un service : la suite existe déjà, elle dit si la nouvelle implémentation tient ses promesses. Sans elle, tout remplacement est un pari.

Il transforme les décisions implicites en décisions. "findById retourne null" n'était écrit nulle part. C'était une habitude, différente selon le fichier. C'est maintenant une ligne de test.

Il documente le port mieux qu'une interface. Une signature TypeScript dit findById(id: string): Promise<Booking | null>. Elle ne dit pas ce qui arrive quand on sauve deux fois. Le contrat, si.

C'est le moment de le reconnaître : notre MemoryBookingRepository, écrit "juste pour faire passer un test" au chapitre 12, est devenu la spécification exécutable de notre couche de données.

Il ne reste plus qu'un it.fails dans notre suite. Allons le régler.