03 — Tester les Value Objects : simple et exhaustif
Ce que tu vas apprendre
- Pourquoi les Value Objects sont parmi les objets les plus faciles à tester
- Les 5 axes de test d'un Value Object
- Des exemples complets avec Vitest (TypeScript) et pytest (Python)
- Les cas limites à ne pas oublier
Prérequis
Pourquoi les VO sont faciles à tester
Un Value Object n'a pas de dépendances externes. Pas de base de données, pas d'API, pas de filesystem. C'est une fonction pure déguisée en classe : tu donnes des inputs, tu obtiens des outputs déterministes.
Résultat : les tests sont rapides, sans mock, sans setup complexe.
| Sans dépendances | Déterministe | Sans mock |
|---|---|---|
| Pas de BDD, pas d'API | Même input → même output | Tests unitaires purs |
Les 5 axes de test
Un Value Object a 5 comportements à tester :
- Création valide — les inputs corrects créent l'objet
- Rejet des invalides — chaque règle de validation est vérifiée séparément
- Égalité — deux VO avec la même valeur sont égaux, deux avec des valeurs différentes ne le sont pas
- Immuabilité — l'objet ne peut pas être modifié après création
- Sérialisation —
toJSON()/fromJSON()font l'aller-retour sans perte
Tests TypeScript avec Vitest
Setup
typescript// email.test.ts
import { describe, it, expect } from "vitest";
import { Email } from "./email";
Axe 1 : création valide
typescriptdescribe("Email.create", () => {
it("crée un email valide", () => {
const email = Email.create("alice@exemple.fr");
expect(email.toString()).toBe("alice@exemple.fr");
});
it("normalise en minuscules", () => {
const email = Email.create("Alice@EXEMPLE.FR");
expect(email.toString()).toBe("alice@exemple.fr");
});
it("supprime les espaces autour", () => {
const email = Email.create(" alice@exemple.fr ");
expect(email.toString()).toBe("alice@exemple.fr");
});
});
Axe 2 : rejet des invalides
Chaque règle de validation mérite son propre test. Si tu les regroupes, tu ne sais pas quelle règle est couverte.
typescriptdescribe("Email.create — validation", () => {
it("rejette un email vide", () => {
expect(() => Email.create("")).toThrow("ne peut pas être vide");
});
it("rejette une string vide après trim", () => {
expect(() => Email.create(" ")).toThrow("ne peut pas être vide");
});
it("rejette un email sans @", () => {
expect(() => Email.create("pas-un-email")).toThrow("n'est pas une adresse email valide");
});
it("rejette un email sans domaine", () => {
expect(() => Email.create("alice@")).toThrow("n'est pas une adresse email valide");
});
it("rejette un email sans TLD", () => {
expect(() => Email.create("alice@exemple")).toThrow("n'est pas une adresse email valide");
});
});
Axe 3 : égalité
typescriptdescribe("Email.equals", () => {
it("deux emails identiques sont égaux", () => {
const a = Email.create("alice@exemple.fr");
const b = Email.create("alice@exemple.fr");
expect(a.equals(b)).toBe(true);
});
it("deux emails avec casse différente sont égaux (normalisés)", () => {
const a = Email.create("Alice@Exemple.fr");
const b = Email.create("alice@exemple.fr");
expect(a.equals(b)).toBe(true);
});
it("deux emails différents ne sont pas égaux", () => {
const a = Email.create("alice@exemple.fr");
const b = Email.create("bob@exemple.fr");
expect(a.equals(b)).toBe(false);
});
});
Axe 4 : immuabilité
typescriptdescribe("Email — immuabilité", () => {
it("n'expose pas de setter", () => {
const email = Email.create("alice@exemple.fr");
// TypeScript empêche la modification à la compilation
// On vérifie que la propriété est en lecture seule
expect(() => {
(email as any).value = "autre@exemple.fr";
}).toThrow(); // ou ne pas changer la valeur selon l'implémentation
});
});
Axe 5 : sérialisation
typescriptdescribe("Money — sérialisation", () => {
it("sérialise en JSON", () => {
const money = Money.create(1999, "EUR");
expect(money.toJSON()).toEqual({ amount: 1999, currency: "EUR" });
});
it("désérialise depuis JSON avec validation", () => {
const money = Money.fromJSON({ amount: 1999, currency: "EUR" });
expect(money.toJSON()).toEqual({ amount: 1999, currency: "EUR" });
});
it("rejette une devise invalide à la désérialisation", () => {
expect(() =>
Money.fromJSON({ amount: 100, currency: "YOLO" })
).toThrow("Devise inconnue");
});
});
Tests Python avec pytest
Setup
bashpip install pytest
Structure du fichier de test
python# test_email.py
import pytest
from email_vo import Email
Axe 1 : création valide
pythonclass TestEmailCreate:
def test_cree_un_email_valide(self):
email = Email("alice@exemple.fr")
assert email.value == "alice@exemple.fr"
def test_normalise_en_minuscules(self):
email = Email("Alice@EXEMPLE.FR")
assert email.value == "alice@exemple.fr"
def test_supprime_les_espaces(self):
email = Email(" alice@exemple.fr ")
assert email.value == "alice@exemple.fr"
Axe 2 : rejet des invalides
pythonclass TestEmailValidation:
def test_rejette_email_vide(self):
with pytest.raises(ValueError, match="ne peut pas être vide"):
Email("")
def test_rejette_string_espaces(self):
with pytest.raises(ValueError, match="ne peut pas être vide"):
Email(" ")
def test_rejette_email_sans_at(self):
with pytest.raises(ValueError, match="n'est pas une adresse email valide"):
Email("pas-un-email")
def test_rejette_email_sans_domaine(self):
with pytest.raises(ValueError, match="n'est pas une adresse email valide"):
Email("alice@")
def test_rejette_email_sans_tld(self):
with pytest.raises(ValueError, match="n'est pas une adresse email valide"):
Email("alice@exemple")
Axe 3 : égalité
pythonclass TestEmailEgalite:
def test_emails_identiques_sont_egaux(self):
a = Email("alice@exemple.fr")
b = Email("alice@exemple.fr")
assert a == b
def test_casse_differente_est_egale(self):
a = Email("Alice@Exemple.fr")
b = Email("alice@exemple.fr")
assert a == b
def test_emails_differents_ne_sont_pas_egaux(self):
a = Email("alice@exemple.fr")
b = Email("bob@exemple.fr")
assert a != b
def test_utilisable_comme_cle_de_dict(self):
# frozen=True génère __hash__ — on peut utiliser l'Email comme clé
d = {Email("alice@exemple.fr"): "Alice"}
assert d[Email("alice@exemple.fr")] == "Alice"
Axe 4 : immuabilité
pythonclass TestEmailImmutabilite:
def test_ne_peut_pas_etre_modifie(self):
email = Email("alice@exemple.fr")
with pytest.raises(Exception): # FrozenInstanceError ou AttributeError
email.value = "autre@exemple.fr" # type: ignore
Axe 5 : sérialisation
pythonclass TestMoneySerialization:
def test_serialise_en_dict(self):
money = Money(amount=1999, currency="EUR")
assert money.to_dict() == {"amount": 1999, "currency": "EUR"}
def test_deserialise_depuis_dict(self):
money = Money.from_dict({"amount": 1999, "currency": "EUR"})
assert money == Money(amount=1999, currency="EUR")
def test_rejette_devise_invalide_a_la_deserialisation(self):
with pytest.raises(ValueError, match="Devise inconnue"):
Money.from_dict({"amount": 100, "currency": "YOLO"})
Tester les opérations
Au-delà des 5 axes de base, teste les opérations métier :
pythonclass TestMoneyOperations:
def test_addition_meme_devise(self):
a = Money(amount=1000, currency="EUR")
b = Money(amount=500, currency="EUR")
result = a.add(b)
assert result == Money(amount=1500, currency="EUR")
def test_addition_retourne_nouveau_vo(self):
a = Money(amount=1000, currency="EUR")
b = Money(amount=500, currency="EUR")
result = a.add(b)
# a et b sont inchangés — immuabilité
assert a == Money(amount=1000, currency="EUR")
assert b == Money(amount=500, currency="EUR")
assert result == Money(amount=1500, currency="EUR")
def test_addition_devises_incompatibles(self):
a = Money(amount=1000, currency="EUR")
b = Money(amount=500, currency="USD")
with pytest.raises(ValueError, match="Devises incompatibles"):
a.add(b)
En TypeScript :
typescriptdescribe("Money.add", () => {
it("additionne deux montants de même devise", () => {
const a = Money.create(1000, "EUR");
const b = Money.create(500, "EUR");
const result = a.add(b);
expect(result.equals(Money.create(1500, "EUR"))).toBe(true);
});
it("retourne un nouveau VO sans modifier les originaux", () => {
const a = Money.create(1000, "EUR");
const b = Money.create(500, "EUR");
a.add(b);
// a et b inchangés
expect(a.equals(Money.create(1000, "EUR"))).toBe(true);
expect(b.equals(Money.create(500, "EUR"))).toBe(true);
});
it("lève une erreur pour des devises incompatibles", () => {
const eur = Money.create(1000, "EUR");
const usd = Money.create(500, "USD");
expect(() => eur.add(usd)).toThrow("Devises incompatibles");
});
});
Paramétrer les tests
Quand plusieurs cas de validation suivent le même pattern, it.each (Vitest) et @pytest.mark.parametrize (pytest) évitent la répétition.
typescript// Vitest
describe.each([
["", "ne peut pas être vide"],
[" ", "ne peut pas être vide"],
["pas-un-email", "n'est pas une adresse email valide"],
["alice@", "n'est pas une adresse email valide"],
["alice@exemple", "n'est pas une adresse email valide"],
])("Email invalide : %s", (input, expectedError) => {
it(`rejette "${input}"`, () => {
expect(() => Email.create(input)).toThrow(expectedError);
});
});
python# pytest
@pytest.mark.parametrize("input,expected_error", [
("", "ne peut pas être vide"),
(" ", "ne peut pas être vide"),
("pas-un-email", "n'est pas une adresse email valide"),
("alice@", "n'est pas une adresse email valide"),
("alice@exemple", "n'est pas une adresse email valide"),
])
def test_email_invalides(input, expected_error):
with pytest.raises(ValueError, match=expected_error):
Email(input)
Résumé
Tester un Value Object couvre 5 axes :
| Axe | Ce qu'on vérifie |
|---|---|
| Création valide | Les inputs corrects créent l'objet et normalisent les valeurs |
| Rejet des invalides | Chaque règle de validation est testée séparément |
| Égalité | equals / == fonctionne correctement pour les cas vrais et faux |
| Immuabilité | Aucun setter ne modifie l'objet après création |
| Sérialisation | toJSON / fromJSON font l'aller-retour sans perte |
Les tests de Value Objects sont rapides à écrire et s'exécutent en quelques millisecondes — pas de mock, pas de base de données.
Étape suivante : 04 — Value Objects dans un projet réel — composer plusieurs VOs dans un domaine complet.