Doublures de test
Moq et NSubstitute, stub contre mock contre fake, FluentAssertions.
Vérifié en septembre 2026 · .NET 10 (SDK 10.0.300), C# 14 · environ 17 min
Une doublure de test remplace, le temps d'un test, une dépendance réelle du code testé : une base de données, un service de messagerie, une horloge. On y recourt quand la vraie dépendance est lente, non déterministe, ou qu'elle a des effets qu'un test ne doit pas produire, comme envoyer un e-mail. Le cours dotnet new xunit du SDK .NET 10 (xUnit 2.9.3, l'objet du cours
Cinq doublures, cinq rôles
Le terme de doublure (« test double ») vient de Gerard Meszaros, dans « xUnit Test Patterns » (2007), par analogie avec la doublure du cinéma. Il distingue cinq variantes selon ce qu'elles font pour le test, et l'article de Martin Fowler « Mocks Aren't Stubs » a popularisé ce vocabulaire. Les bibliothèques ne le suivent pas : un Mock<T> de Moq sert aussi bien de stub que de mock. Les mots comptent pourtant, parce qu'ils disent ce que le test vérifie.
Le service relance chaque facture échue, au plus une fois par semaine. Il lit les factures, envoie les relances, enregistre leur date et journalise les envois impossibles : trois dépendances, trois interfaces.
namespace Facturation;
public record Facture(string Numero, string Client, DateOnly Echeance, DateOnly? DerniereRelance = null);
public interface IFactures
{
IReadOnlyList<Facture> NonPayees();
void Enregistrer(Facture facture);
}
public interface IMessagerie
{
void Envoyer(string destinataire, string sujet);
}
public interface IJournal
{
void Erreur(string message);
}
public sealed class EnvoiImpossible(string message) : Exception(message);
public sealed class Relances(IFactures factures, IMessagerie messagerie, IJournal journal)
{
// Relance chaque facture echue, au plus une fois par semaine, et rend le
// nombre de relances envoyees.
public int Relancer(DateOnly aujourdhui)
{
var envoyees = 0;
foreach (var facture in factures.NonPayees())
{
var echue = facture.Echeance < aujourdhui;
// DerniereRelance nulle : la comparaison est fausse, la facture n'a jamais ete relancee.
var relanceeCetteSemaine = facture.DerniereRelance > aujourdhui.AddDays(-7);
if (!echue || relanceeCetteSemaine)
{
continue;
}
try
{
messagerie.Envoyer(facture.Client, $"Relance de la facture {facture.Numero}");
factures.Enregistrer(facture with { DerniereRelance = aujourdhui });
envoyees++;
}
catch (EnvoiImpossible ex)
{
journal.Erreur($"{facture.Numero} : {ex.Message}");
}
}
return envoyees;
}
} Un dummy est exigé par une signature et jamais utilisé par le chemin testé. Celui-ci lève une exception si on s'en sert : un dummy qui échoue bruyamment révèle un test qui ne couvre pas le chemin qu'on croyait. Un stub fournit au code testé ses entrées indirectes, ici les factures, sous forme de réponses préparées, sans logique. Le test ne vérifie que la valeur rendue par Relancer.
namespace Facturation.Tests;
// Dummy : exige par le constructeur, jamais utilise par le chemin teste.
// Il leve une exception si on s'en sert, pour que le test le dise.
public sealed class JournalInutilise : IJournal
{
public void Erreur(string message) =>
throw new InvalidOperationException("Ce test ne devait rien journaliser.");
}
// Stub : fournit au code teste ses entrees indirectes, des reponses preparees.
public sealed class FacturesFixes(params Facture[] factures) : IFactures
{
public IReadOnlyList<Facture> NonPayees() => factures;
public void Enregistrer(Facture facture) { } // ignore : aucun test ne relit ce stub
}
public sealed class MessagerieMuette : IMessagerie
{
public void Envoyer(string destinataire, string sujet) { }
}
public class StubEtDummyTests
{
[Fact]
public void Seule_la_facture_echue_est_comptee()
{
var relances = new Relances(
new FacturesFixes(
new("F-001", "Durand", Echeance: new(2026, 9, 30)),
new("F-002", "Martin", Echeance: new(2026, 9, 10))),
new MessagerieMuette(),
new JournalInutilise());
Assert.Equal(1, relances.Relancer(new DateOnly(2026, 9, 25)));
}
} Un spy enregistre les appels qu'il reçoit, et le test les inspecte après coup. Un mock reçoit ses attentes avant l'exécution et les vérifie lui-même : un envoi inattendu échoue au moment de l'appel, un envoi manquant à Verifier(). La différence tient à l'endroit où vit la vérification : dans le test pour le spy, dans la doublure pour le mock.
namespace Facturation.Tests;
// Spy : enregistre les appels recus. C'est le test qui les verifie, ensuite.
public sealed class MessagerieEspionne : IMessagerie
{
public List<(string Destinataire, string Sujet)> Envois { get; } = [];
public void Envoyer(string destinataire, string sujet) => Envois.Add((destinataire, sujet));
}
// Mock : recoit ses attentes avant l'appel et les verifie lui-meme. Un envoi
// inattendu echoue sur-le-champ, un envoi manquant echoue a Verifier().
public sealed class MessagerieAttendue(params string[] destinataires) : IMessagerie
{
private readonly List<string> recus = [];
public void Envoyer(string destinataire, string sujet)
{
if (!destinataires.Contains(destinataire))
{
throw new InvalidOperationException($"Envoi inattendu a {destinataire}.");
}
recus.Add(destinataire);
}
public void Verifier()
{
var manquants = destinataires.Except(recus).ToList();
if (manquants.Count > 0)
{
throw new InvalidOperationException("Envoi manquant a " + string.Join(", ", manquants));
}
}
}
public class EspionEtMockTests
{
private readonly FacturesFixes factures = new(
new("F-001", "Durand", Echeance: new(2026, 9, 30)),
new("F-002", "Martin", Echeance: new(2026, 9, 10)));
[Fact]
public void L_espion_rapporte_la_relance_envoyee()
{
var messagerie = new MessagerieEspionne();
var relances = new Relances(factures, messagerie, new JournalInutilise());
relances.Relancer(new DateOnly(2026, 9, 25));
Assert.Equal(["Martin"], messagerie.Envois.Select(e => e.Destinataire));
}
[Fact]
public void Le_mock_verifie_ses_attentes()
{
var messagerie = new MessagerieAttendue("Martin");
var relances = new Relances(factures, messagerie, new JournalInutilise());
relances.Relancer(new DateOnly(2026, 9, 25));
messagerie.Verifier();
}
}Un fake, enfin, est une implémentation qui fonctionne vraiment mais prend un raccourci, comme un dictionnaire à la place d'une base. Il a sa section plus bas, parce qu'il change la manière d'écrire le test.
| Doublure | Ce qu'elle fait | Ce que le test vérifie |
|---|---|---|
| dummy | occupe un paramètre | rien qui la concerne |
| stub | rend des réponses préparées | le résultat du code testé |
| spy | enregistre les appels | les appels, dans le test, après coup |
| mock | porte des attentes | les appels, par la doublure elle-même |
| fake | fonctionne, avec un raccourci | l'état du fake, ou le résultat |
Le même test avec Moq et avec NSubstitute
Écrire ces classes à la main pour chaque interface coûte vite cher. Moq et NSubstitute les génèrent à l'exécution ; tous deux s'appuient sur Castle DynamicProxy (paquet Castle.Core), qui fabrique une classe implémentant l'interface doublée, ou dérivant de la classe doublée. Les deux sont sous licence BSD-3-Clause. Les versions courantes sont Moq 4.21.0, publiée le 24 septembre 2026, et NSubstitute 6.2.0, du 11 août 2026 ; les exemples tournent avec Moq 4.20.72 et NSubstitute 6.0.0. Voici la règle d'échéance vérifiée avec chacun.
using Moq;
namespace Facturation.Tests;
public class RelancesMoqTests
{
[Fact]
public void Seule_la_facture_echue_est_relancee()
{
// Mock<T> est l'objet de configuration ; .Object est la doublure elle-meme.
var factures = new Mock<IFactures>();
factures.Setup(f => f.NonPayees()).Returns([
new Facture("F-001", "Durand", Echeance: new(2026, 9, 30)),
new Facture("F-002", "Martin", Echeance: new(2026, 9, 10)),
]);
var messagerie = new Mock<IMessagerie>();
var relances = new Relances(factures.Object, messagerie.Object, Mock.Of<IJournal>());
var envoyees = relances.Relancer(new DateOnly(2026, 9, 25));
messagerie.Verify(m => m.Envoyer("Martin", It.IsAny<string>()), Times.Once());
messagerie.Verify(m => m.Envoyer("Durand", It.IsAny<string>()), Times.Never());
Assert.Equal(1, envoyees);
}
} Moq sépare l'objet de configuration, Mock<T>, de la doublure, sa propriété Object. Setup et Verify prennent des expressions lambda qui décrivent l'appel, et It.IsAny accepte n'importe quel argument. Mock.Of<IJournal>() tient lieu de dummy.
using NSubstitute;
namespace Facturation.Tests;
public class RelancesNSubstituteTests
{
[Fact]
public void Seule_la_facture_echue_est_relancee()
{
// La doublure est directement un IFactures : on la configure en l'appelant.
var factures = Substitute.For<IFactures>();
factures.NonPayees().Returns([
new Facture("F-001", "Durand", Echeance: new(2026, 9, 30)),
new Facture("F-002", "Martin", Echeance: new(2026, 9, 10)),
]);
var messagerie = Substitute.For<IMessagerie>();
var relances = new Relances(factures, messagerie, Substitute.For<IJournal>());
var envoyees = relances.Relancer(new DateOnly(2026, 9, 25));
messagerie.Received(1).Envoyer("Martin", Arg.Any<string>());
messagerie.DidNotReceive().Envoyer("Durand", Arg.Any<string>());
Assert.Equal(1, envoyees);
}
} NSubstitute rend directement un IFactures. On le configure en appelant la méthode suivie de Returns, et on vérifie par Received suivi de l'appel attendu. La syntaxe est plus courte, au prix d'un mécanisme implicite : Returns s'applique au dernier appel intercepté, ce qui explique le piège décrit plus bas.
Qu'une version fautive de Relancer relance toutes les factures, et les deux tests échouent sur la même vérification, chacun listant les appels réellement reçus. Voici ces deux messages, extraits de la sortie du Terminal Logger sans leurs piles d'appels :
$ dotnet test --filter "FullyQualifiedName~RelancesMoqTests|FullyQualifiedName~RelancesNSubstituteTests"
[xUnit.net 00:00:01.30] Facturation.Tests.RelancesNSubstituteTests.Seule_la_facture_echue_est_relancee [FAIL]
[xUnit.net 00:00:01.30] NSubstitute.Exceptions.ReceivedCallsException : Expected to receive no calls matching:
Envoyer("Durand", any String)
[xUnit.net 00:00:01.30] Actually received 1 matching call:
[xUnit.net 00:00:01.30] Envoyer("Durand", "Relance de la facture F-001")
[xUnit.net 00:00:01.32] Facturation.Tests.RelancesMoqTests.Seule_la_facture_echue_est_relancee [FAIL]
[xUnit.net 00:00:01.32] Moq.MockException :
[xUnit.net 00:00:01.32] Expected invocation on the mock should never have been performed, but was 1 times: m => m.Envoyer("Durand", It.IsAny<string>())
[xUnit.net 00:00:01.32]
[xUnit.net 00:00:01.32] Performed invocations:
[xUnit.net 00:00:01.32]
[xUnit.net 00:00:01.32] Mock<IMessagerie:1> (m):
[xUnit.net 00:00:01.32]
[xUnit.net 00:00:01.32] IMessagerie.Envoyer("Durand", "Relance de la facture F-001")
[xUnit.net 00:00:01.32] IMessagerie.Envoyer("Martin", "Relance de la facture F-002")
Récapitulatif du test : total : 2; échec : 2; réussi : 0; ignoré : 0; durée : 4,7s| Besoin | Moq 4.20 | NSubstitute 6.0 |
|---|---|---|
| créer | new Mock<T>(), puis .Object | Substitute.For<T>() |
| rendre une valeur | Setup(x => x.M()).Returns(v) | x.M().Returns(v) |
| argument quelconque, filtré | It.IsAny<T>(), It.Is<T>(prédicat) | Arg.Any<T>(), Arg.Is<T>(prédicat) |
| vérifier un appel, son absence | Verify(…, Times.Once()), Times.Never() | Received(1), DidNotReceive() |
lever une exception depuis une méthode void | Setup(…).Throws(ex) | When(…).Do(_ => throw ex) |
NonPayees() non configuré | rend null | rend un substitut vide (Count à 0) |
| échouer sur tout appel non configuré | new Mock<T>(MockBehavior.Strict) | aucun mode strict intégré |
L'avant-dernière ligne a une conséquence. Sans configuration de NonPayees, le foreach de Relancer lève NullReferenceException sous Moq ; sous NSubstitute, il parcourt un substitut de IReadOnlyList<Facture> qui ne contient rien, et un test qui n'attend aucune relance passe sans avoir rien testé. Moq, par ailleurs, a un antécédent à connaître : sa version 4.20.0, en août 2023, embarquait SponsorLink, qui lisait à la compilation l'adresse e-mail de la configuration git et en envoyait une empreinte SHA-256 à un service distant ; la 4.20.2 l'a retiré dès le lendemain.
Ce qu'une bibliothèque ne peut pas doubler
Une classe générée ne redéfinit que ce que C# permet de redéfinir : les membres d'une interface, et les membres virtual ou abstract d'une classe. L'exemple suivant double une classe dont une méthode a perdu son virtual, et les deux bibliothèques y échouent différemment. Moq refuse la configuration. NSubstitute exécute le vrai code de Reduit, puis son Returns ne trouve aucun appel intercepté à configurer.
using Moq;
using NSubstitute;
namespace Facturation.Tests;
public class Taux
{
public virtual decimal Normal() => 0.20m;
public decimal Reduit() => 0.055m; // virtual oublie
}
public class TauxTests
{
[Fact]
public void NSubstitute_ne_configure_pas_un_membre_non_virtuel()
{
var taux = Substitute.For<Taux>();
taux.Normal().Returns(0.30m);
// Reduit() n'est pas intercepte : c'est le vrai code qui s'execute, et
// Returns ne trouve aucun appel a configurer.
// CouldNotSetReturnDueToNoLastCallException : Could not find a call to return from.
taux.Reduit().Returns(0.10m);
}
[Fact]
public void Moq_refuse_un_membre_non_virtuel()
{
var taux = new Mock<Taux>();
// NotSupportedException : Unsupported expression: t => t.Reduit()
// Non-overridable members (here: Taux.Reduit) may not be used in setup / verification expressions.
taux.Setup(t => t.Reduit()).Returns(0.10m);
}
} L'exception de NSubstitute n'est pas garantie. Si un appel intercepté et non configuré précède, comme un taux.Normal() seul sur sa ligne, Returns s'y applique en silence : c'est Normal() qui rend 0,10, et Reduit() garde sa vraie valeur. Le paquet NSubstitute.Analyzers.CSharp signale ces usages à la compilation. Le remède est de doubler une interface :
using Moq;
using NSubstitute;
namespace Facturation.Tests;
// Une interface n'a que des membres redefinissables : rien ne peut y manquer de virtual.
public interface ITaux
{
decimal Normal();
decimal Reduit();
}
public class InterfaceTauxTests
{
[Fact]
public void NSubstitute_configure_chaque_membre()
{
var taux = Substitute.For<ITaux>();
taux.Normal().Returns(0.30m);
taux.Reduit().Returns(0.10m);
Assert.Equal(0.10m, taux.Reduit());
}
[Fact]
public void Moq_configure_chaque_membre()
{
var taux = new Mock<ITaux>();
taux.Setup(t => t.Reduit()).Returns(0.10m);
Assert.Equal(0.10m, taux.Object.Reduit());
}
}Vérifier l'état ou vérifier les interactions
Un test vérifie l'état quand il regarde un résultat après coup : la valeur rendue, le contenu d'un fake. Il vérifie les interactions quand il regarde les appels que le code a faits à ses dépendances, par un spy ou par Verify et Received. Fowler oppose sur ce point une école classique, qui emploie les vrais objets quand c'est possible et une doublure quand c'est malcommode, et une école mockiste, qui emploie un mock pour tout objet au comportement intéressant.
Les deux se rejoignent quand l'interaction est l'effet voulu. Envoyer une relance est une commande : l'appel à Envoyer est exactement ce que le service doit produire. Lire les factures est une requête : elle n'a aucun effet, et la vérifier revient à tester comment le code s'y prend, pas ce qu'il fait. D'où la règle : on stubbe les requêtes sans les vérifier, et on vérifie les commandes qui comptent pour ce test-ci, rien d'autre.
Le test suivant enfreint cette règle. Il recopie tout ce que le code a fait : la lecture des factures, le libellé exact du sujet, l'enregistrement, et l'absence de tout autre appel. Il passe, et il cassera à la première retouche qui ne touche pas à la règle qu'il porte dans son nom.
using NSubstitute;
namespace Facturation.Tests;
public class RelancesFragilesTests
{
[Fact]
public void Seule_la_facture_echue_est_relancee()
{
var echue = new Facture("F-002", "Martin", Echeance: new(2026, 9, 10));
var factures = Substitute.For<IFactures>();
factures.NonPayees().Returns([new Facture("F-001", "Durand", Echeance: new(2026, 9, 30)), echue]);
var messagerie = Substitute.For<IMessagerie>();
var relances = new Relances(factures, messagerie, Substitute.For<IJournal>());
relances.Relancer(new DateOnly(2026, 9, 25));
// Tout ce que le code a fait, recopie appel par appel.
factures.Received(1).NonPayees();
messagerie.Received(1).Envoyer("Martin", "Relance de la facture F-002");
factures.Received(1).Enregistrer(echue with { DerniereRelance = new(2026, 9, 25) });
Assert.Single(messagerie.ReceivedCalls());
}
}Le test juste vérifie ce que son nom promet, qui reçoit une relance, et confie le sujet à un test à part qui n'en exige que l'essentiel :
using NSubstitute;
namespace Facturation.Tests;
public class RelancesRobustesTests
{
[Fact]
public void Seule_la_facture_echue_est_relancee()
{
var factures = Substitute.For<IFactures>();
factures.NonPayees().Returns([
new Facture("F-001", "Durand", Echeance: new(2026, 9, 30)),
new Facture("F-002", "Martin", Echeance: new(2026, 9, 10)),
]);
var messagerie = Substitute.For<IMessagerie>();
var relances = new Relances(factures, messagerie, Substitute.For<IJournal>());
var envoyees = relances.Relancer(new DateOnly(2026, 9, 25));
// Ce que le nom du test promet, et rien d'autre : qui recoit une relance.
messagerie.Received(1).Envoyer("Martin", Arg.Any<string>());
messagerie.DidNotReceive().Envoyer("Durand", Arg.Any<string>());
Assert.Equal(1, envoyees);
}
[Fact]
public void Le_sujet_cite_le_numero_de_facture()
{
var factures = Substitute.For<IFactures>();
factures.NonPayees().Returns([new Facture("F-002", "Martin", Echeance: new(2026, 9, 10))]);
var messagerie = Substitute.For<IMessagerie>();
var relances = new Relances(factures, messagerie, Substitute.For<IJournal>());
relances.Relancer(new DateOnly(2026, 9, 25));
// La garde s != null ne sert qu'a eviter CS8602 : NSubstitute 6.0.0 annote s en string?.
messagerie.Received(1).Envoyer("Martin", Arg.Is<string>(s => s != null && s.Contains("F-002")));
}
}Que le métier demande d'ajouter l'échéance au sujet, « Relance de la facture F-002 du 10/09/2026 », et le service relance toujours les mêmes factures. Le test fragile échoue pourtant, quand les deux tests justes et ceux de la section précédente passent (même extrait, sans la pile d'appels) :
$ dotnet test --filter "FullyQualifiedName~Relances"
[xUnit.net 00:00:01.08] Facturation.Tests.RelancesFragilesTests.Seule_la_facture_echue_est_relancee [FAIL]
[xUnit.net 00:00:01.08] NSubstitute.Exceptions.ReceivedCallsException : Expected to receive exactly 1 call matching:
Envoyer("Martin", "Relance de la facture F-002")
[xUnit.net 00:00:01.09] Actually received no matching calls.
[xUnit.net 00:00:01.09] Received 1 non-matching call (non-matching arguments indicated with '*' characters):
[xUnit.net 00:00:01.09] Envoyer("Martin", *"Relance de la facture F-002 du 10/09/2026"*)
Récapitulatif du test : total : 5; échec : 1; réussi : 4; ignoré : 0; durée : 4,6s Un test fragile coûte deux fois : il faut le réparer à chaque changement anodin, et à force de réparations, on recopie le nouveau comportement dans l'assertion sans le relire ; le test ne protège plus rien. Assert.Single(messagerie.ReceivedCalls()), qui joue le rôle du VerifyNoOtherCalls() de Moq, est le cas extrême : il interdit d'ajouter un appel, quel qu'il soit. L'enregistrement de la date mérite un test, mais le sien : c'est l'objet de la section suivante.
Le fake écrit à la main
Certaines règles ne se voient qu'au fil de plusieurs appels. « Au plus une relance par semaine » suppose que le service relise ce qu'il a enregistré. Avec un mock, le test doit alors jouer le stockage lui-même, en scriptant ce que NonPayees rendra à chaque appel. Ce script ment : il rend la facture marquée comme relancée au second appel, que le service ait appelé Enregistrer ou non.
using NSubstitute;
namespace Facturation.Tests;
public class RelanceHebdomadaireScripteeTests
{
[Fact]
public void Une_relance_par_semaine_au_plus()
{
var facture = new Facture("F-002", "Martin", Echeance: new(2026, 9, 10));
var factures = Substitute.For<IFactures>();
// Le test joue lui-meme le stockage : au premier appel la facture brute,
// au second la facture telle qu'Enregistrer est cense l'avoir laissee.
factures.NonPayees().Returns([facture], [facture with { DerniereRelance = new(2026, 9, 25) }]);
var messagerie = Substitute.For<IMessagerie>();
var relances = new Relances(factures, messagerie, Substitute.For<IJournal>());
relances.Relancer(new DateOnly(2026, 9, 25));
relances.Relancer(new DateOnly(2026, 9, 26));
messagerie.Received(1).Envoyer("Martin", Arg.Any<string>());
}
} Le fake implémente IFactures pour de bon, avec un dictionnaire. Le test n'y décrit plus aucun appel : il fait tourner le service trois jours et regarde ce qui a été envoyé et enregistré. Le troisième jour, sept jours après la première relance, vérifie la borne.
namespace Facturation.Tests;
// Fake : une implementation qui marche vraiment, avec un raccourci. Ici, un
// dictionnaire tient lieu de base de donnees.
public sealed class FacturesEnMemoire : IFactures
{
private readonly Dictionary<string, Facture> parNumero = [];
public FacturesEnMemoire(params Facture[] factures)
{
foreach (var facture in factures)
{
Enregistrer(facture);
}
}
public IReadOnlyList<Facture> NonPayees() => [.. parNumero.Values];
public void Enregistrer(Facture facture) => parNumero[facture.Numero] = facture;
public Facture Lire(string numero) => parNumero[numero];
}
// MessagerieEspionne et JournalInutilise sont les doublures de la premiere section.
public class RelanceHebdomadaireTests
{
[Fact]
public void Une_relance_par_semaine_au_plus()
{
var factures = new FacturesEnMemoire(new Facture("F-002", "Martin", Echeance: new(2026, 9, 10)));
var messagerie = new MessagerieEspionne();
var relances = new Relances(factures, messagerie, new JournalInutilise());
relances.Relancer(new DateOnly(2026, 9, 25));
relances.Relancer(new DateOnly(2026, 9, 26)); // le lendemain : rien
relances.Relancer(new DateOnly(2026, 10, 2)); // sept jours apres : nouvelle relance
Assert.Equal(["Martin", "Martin"], messagerie.Envois.Select(e => e.Destinataire));
Assert.Equal(new DateOnly(2026, 10, 2), factures.Lire("F-002").DerniereRelance);
}
} Qu'on supprime l'appel à Enregistrer dans Relancer, et la différence apparaît : le test au fake échoue, le test scripté passe toujours. Le test au fake a vu trois relances là où il en attendait deux (extrait, sans la pile d'appels) :
$ dotnet test --filter "FullyQualifiedName~RelanceHebdomadaireTests|FullyQualifiedName~RelanceHebdomadaireScripteeTests"
[xUnit.net 00:00:00.89] Facturation.Tests.RelanceHebdomadaireTests.Une_relance_par_semaine_au_plus [FAIL]
[xUnit.net 00:00:00.90] Assert.Equal() Failure: Collections differ
[xUnit.net 00:00:00.90] Expected: <generated> ["Martin", "Martin"]
[xUnit.net 00:00:00.90] Actual: ListSelectIterator<ValueTuple<string, string>, string> ["Martin", "Martin", "Martin"]
[xUnit.net 00:00:00.90] ↑ (pos 2)
Récapitulatif du test : total : 2; échec : 1; réussi : 1; ignoré : 0; durée : 4,6s Un fake vaut mieux qu'un mock quand la dépendance a un état que le code relit, quand beaucoup de tests s'en servent (un seul fake remplace un script par test), et quand son interface est petite et stable. Il a un coût : c'est du code, qui doit se comporter comme l'implémentation réelle. La façon sûre de le garantir est de faire passer au fake et à l'implémentation réelle une même classe de tests de contrat, écrite une fois contre IFactures.
Cela suppose de doubler sa propre interface, pas l'outil qu'elle cache. La documentation d'EF Core déconseille fortement le fournisseur en mémoire comme fake de base de données : il ne gère pas les transactions (la documentation les dit ignorées ; sous EF Core 10, en ouvrir une lève une exception), refuse le SQL brut, et ses requêtes ne se comportent pas comme celles d'une base relationnelle. Elle recommande de tester les requêtes contre la vraie base, et, si une doublure est nécessaire, de doubler un dépôt placé au-dessus d'EF Core, comme IFactures ici. Le cours
FluentAssertions, sa licence, et une alternative
FluentAssertions remplace Assert.Equal(attendu, réel) par une phrase, réel.Should().Be(attendu), et apporte surtout des messages qui nomment l'expression vérifiée, une comparaison structurelle détaillée par BeEquivalentTo, et une portée qui rapporte tous les échecs d'un bloc.
Sa licence a changé. Jusqu'à la version 7, FluentAssertions était sous Apache 2.0. Depuis la 8.0.0, publiée le 14 janvier 2025 à la suite d'un partenariat avec l'éditeur Xceed, elle est sous la licence « Xceed Community License » : gratuite pour l'usage non commercial, payante pour l'usage commercial, par siège de développeur. Le texte définit l'usage non commercial comme celui « where the primary objective is not to gain commercial advantage or monetary compensation ». Il en donne quatre exemples, non limitatifs : un projet open source, un projet personnel ou expérimental, un usage qui n'est pas fait par ou pour une organisation qui facture ou perçoit des revenus, et une œuvre produite qui n'est ni vendue ni louée. Il conclut : « Any use outside of these parameters requires a paid Commercial License from Xceed. »
À la lecture de ce texte, un projet interne d'entreprise en sort, et demande donc une licence payante. La FAQ d'Xceed ne traite pas ce cas explicitement : c'est une interprétation, à faire confirmer par Xceed ou par un juriste. La licence s'accepte par l'usage même du logiciel. D'après sa documentation, la version 8 n'exige aucune clé mais affiche un avertissement à chaque exécution des tests ; License.Accepted = true fait seulement taire cet avertissement et n'accorde aucune licence. La version courante est la 8.11.0, du 14 septembre 2026. La branche 7 reste sous Apache 2.0 et reçoit des correctifs (7.2.2, le 16 mars 2026), sans les nouveautés.
AwesomeAssertions est un fork communautaire de FluentAssertions, repris de son dernier état sous Apache 2.0, avant le changement de licence. Il publie une branche 7 qui remplace FluentAssertions 7, puis sa propre version 8, bâtie sur les commits antérieurs à ce changement, puis la 9. Ses mainteneurs s'engagent à ne jamais changer la licence. La version courante est la 9.6.0, du 20 août 2026 ; l'exemple utilise la 9.4.0. La syntaxe Should() est la même, sous l'espace de noms AwesomeAssertions. Shouldly, sous BSD-3-Clause, est une autre bibliothèque, avec sa propre syntaxe (ShouldBe), et les assertions d'xUnit, décrites dans le cours
using AwesomeAssertions;
using AwesomeAssertions.Execution;
namespace Facturation.Tests;
// FacturesEnMemoire, MessagerieEspionne et JournalInutilise sont les doublures
// des sections precedentes.
public class RelanceHebdomadaireAssertionsTests
{
[Fact]
public void Une_relance_par_semaine_au_plus()
{
var factures = new FacturesEnMemoire(new Facture("F-002", "Martin", Echeance: new(2026, 9, 10)));
var messagerie = new MessagerieEspionne();
var relances = new Relances(factures, messagerie, new JournalInutilise());
relances.Relancer(new DateOnly(2026, 9, 25));
relances.Relancer(new DateOnly(2026, 9, 26));
relances.Relancer(new DateOnly(2026, 10, 2));
// Une portee d'assertions rapporte tous les echecs du bloc, pas seulement le premier.
using (new AssertionScope())
{
messagerie.Envois.Should().HaveCount(2).And.OnlyContain(e => e.Destinataire == "Martin");
factures.Lire("F-002").Should().BeEquivalentTo(
new { Numero = "F-002", DerniereRelance = new DateOnly(2026, 10, 2) });
}
}
} Avec le même oubli d'Enregistrer, la portée rapporte les deux échecs, et chaque message nomme l'expression en cause. La bibliothèque retrouve ce nom en relisant le fichier source à l'endroit de l'appel ; quand le fichier n'est pas au chemin qu'indiquent les symboles de débogage, le message dit seulement collection. Le tuple s'affiche avec Item1 et Item2, parce que les noms de ses éléments n'existent qu'à la compilation. L'extrait omet la pile d'appels et la liste de configuration que le message affiche ensuite.
$ dotnet test --filter "FullyQualifiedName~RelanceHebdomadaireAssertionsTests"
[xUnit.net 00:00:00.82] Facturation.Tests.RelanceHebdomadaireAssertionsTests.Une_relance_par_semaine_au_plus [FAIL]
[xUnit.net 00:00:00.82] Expected messagerie.Envois to contain 2 item(s), but found 3:
[xUnit.net 00:00:00.82] {
[xUnit.net 00:00:00.82] {
[xUnit.net 00:00:00.82] Item1 = "Martin",
[xUnit.net 00:00:00.82] Item2 = "Relance de la facture F-002"
[xUnit.net 00:00:00.82] },
[xUnit.net 00:00:00.82] {
[xUnit.net 00:00:00.82] Item1 = "Martin",
[xUnit.net 00:00:00.82] Item2 = "Relance de la facture F-002"
[xUnit.net 00:00:00.82] },
[xUnit.net 00:00:00.82] {
[xUnit.net 00:00:00.82] Item1 = "Martin",
[xUnit.net 00:00:00.82] Item2 = "Relance de la facture F-002"
[xUnit.net 00:00:00.82] }
[xUnit.net 00:00:00.82] }.
[xUnit.net 00:00:00.82] Expected property factures.Lire("F-002").DerniereRelance to be <2026-10-02>, but found <null>.
Récapitulatif du test : total : 1; échec : 1; réussi : 0; ignoré : 0; durée : 3,4s