Value Objects — 03 — Tester les Value Objects : simple et exhaustif

Comment tester des Value Objects en TypeScript avec Vitest et Python avec pytest. Stratégies de test, cas limites, égalité, immuabilité et sérialisation.

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 :

  1. Création valide — les inputs corrects créent l'objet
  2. Rejet des invalides — chaque règle de validation est vérifiée séparément
  3. Égalité — deux VO avec la même valeur sont égaux, deux avec des valeurs différentes ne le sont pas
  4. Immuabilité — l'objet ne peut pas être modifié après création
  5. SérialisationtoJSON() / 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.

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.