JustDummies
Juste des dummies, mais redoutablement efficaces.
Des valeurs de test ciblées grâce à une API fluent, pour .NET.
string reference = Any.String()
.AlphaNumeric()
.InUpperCase()
.StartingWith("ORD-")
.WithLengthBetween(8, 20)
.Generate();environ 1,2 Mo, téléchargés seulement si vous le demandez
CLI .NET
La bibliothèque
Voir sur NuGet — La bibliothèqueouvre un nouvel ongletdotnet add package JustDummies --prereleaseConsole du gestionnaire de packages
La bibliothèque
Voir sur NuGet — La bibliothèqueouvre un nouvel ongletInstall-Package JustDummies -IncludePrereleaseLa valeur dont votre test se moque
Elle doit quand même être valide.
En général, votre test ressemble à ça
[Fact]
public void A_pending_order_can_be_cancelled() {
// Arrange
OrderReference anyReference = OrderReference.Create("ORD-54XEM4545");
CustomerId anyCustomerId = CustomerId.Create(Guid.NewGuid());
Money anyTotal = Money.Create(42.00m);
Order order = new Order(anyReference, anyCustomerId, anyTotal, OrderStatus.Pending);
// Act
order.Cancel();
// Assert
Assert.Equal(OrderStatus.Cancelled, order.Status);
}De quoi parle ce test ? Une commande en attente peut être annulée — mais il faut chercher pour le voir. Trois de ses quatre lignes d'arrange construisent une référence, un client et un total dont le test ne reparlera jamais : le constructeur les exige, c'est tout. Et elles mentent : ORD-54XEM4545 et 42.00 se lisent comme des valeurs choisies, alors que n'importe lesquelles auraient fait l'affaire, pourvu qu'elles soient valides. Le sujet du test, lui, est le dernier argument de la ligne qui construit la commande.
Un premier nettoyage
[Fact]
public void A_pending_order_can_be_cancelled_with_factories() {
// Arrange
OrderReference anyReference = AnyOrderReference.Generate();
CustomerId anyCustomerId = AnyCustomerId.Generate();
Money anyTotal = AnyMoney.Generate();
Order order = new Order(anyReference, anyCustomerId, anyTotal, OrderStatus.Pending);
// Act
order.Cancel();
// Assert
Assert.Equal(OrderStatus.Cancelled, order.Status);
}
public static class AnyOrderReference {
public static OrderReference Generate() {
return OrderReference.Create("ORD-54XEM4545");
}
}
// ... and AnyCustomerId and AnyMoney, which say the same thingC'est déjà mieux : les factories disent « any », les variables aussi, et l'arrange tient sur trois lignes qui annoncent leur intention. Un bon début — sauf que rien n'a bougé en dessous. AnyOrderReference renvoie toujours la même chaîne qu'avant : le code annonce « n'importe laquelle » et en donne une seule, toujours la même. Le mensonge n'a pas disparu, il a changé de fichier.
Faire dire vrai à la factory
using JustDummies;
public static class AnyOrderReference {
public static OrderReference Generate() {
return OrderReference.Create(Any.String().Generate());
}
}La factory appelle maintenant la bibliothèque : Any.String() tire une chaîne vraiment quelconque, différente à chaque exécution. Le nom AnyOrderReference ne ment plus. Tirer au hasard surprend, mais une valeur tapée à la main ne prouve qu'une chose : que le test passe avec celle-là. Le domaine, lui, refuse la chaîne tirée. Elle ne commence pas par ORD-, et OrderReference.Create le dit dès la construction, pas trois assertions plus loin.
Ce que le domaine réclame
public static OrderReference Create(string value) {
ArgumentException.ThrowIfNullOrWhiteSpace(value);
if (!value.StartsWith("ORD-", StringComparison.Ordinal)) {
throw new ArgumentException("An order reference must start with ORD-.", nameof(value));
}
if (value.Length < 8) {
throw new ArgumentException("An order reference cannot be shorter than 8 characters.", nameof(value));
}
if (value.Length > 20) {
throw new ArgumentException("An order reference cannot exceed 20 characters.", nameof(value));
}
if (!value[4..].All(character => char.IsAsciiLetterUpper(character) || char.IsAsciiDigit(character))) {
throw new ArgumentException("An order reference holds only uppercase letters and digits after ORD-.", nameof(value));
}
return new OrderReference(value);
}Ces règles n'ont rien d'exotique, et elles sont écrites au bon endroit. Mais chacune doit être respectée. C'est donc au générateur de s'y conformer, sans que le test ait à en parler.
Déclarez les contraintes, pas la valeur
using JustDummies;
public static class AnyOrderReference {
public static OrderReference Generate() {
string reference = Any.String()
.AlphaNumeric()
.InUpperCase()
.StartingWith("ORD-")
.WithLengthBetween(8, 20)
.Generate();
return OrderReference.Create(reference);
}
}Chaque règle métier devient un appel dans la chaîne : commence par ORD-, alphanumérique en majuscules après le préfixe, d'une longueur comprise entre huit et vingt caractères. La valeur produite change à chaque exécution, et elle est valide à chaque fois. Le hasard prend son sens ici. Cette valeur n'a jamais été le sujet du test, elle devait seulement être valide. N'importe laquelle qui respecte les règles fait donc l'affaire. Vous décrivez ce que la valeur doit respecter, pas ce que vous allez vérifier.
Installez-la maintenant
CLI .NET
La bibliothèque
Voir sur NuGet — La bibliothèqueouvre un nouvel ongletdotnet add package JustDummies --prereleaseL'adaptateur xUnit
Voir sur NuGet — L'adaptateur xUnitouvre un nouvel ongletdotnet add package JustDummies.Xunit --prereleaseConsole du gestionnaire de packages
La bibliothèque
Voir sur NuGet — La bibliothèqueouvre un nouvel ongletInstall-Package JustDummies -IncludePrereleaseL'adaptateur xUnit
Voir sur NuGet — L'adaptateur xUnitouvre un nouvel ongletInstall-Package JustDummies.Xunit -IncludePrereleaseTout ce qui précède, c'est la bibliothèque seule. Si c'est ce que vous cherchiez, installez-la maintenant. Prenez l'adaptateur xUnit avec : il rend vos tirages rejouables, et la page y revient plus bas. La suite montre comment faire disparaître toute cette préparation.
Simplifions encore
Un outil lit votre type et écrit le générateur. Le fichier produit est le vôtre.
Ce qu'on aimerait écrire
[Fact, Reproducible]
public void A_pending_order_can_be_cancelled() {
Order order = CreateAnyPendingOrder(); // one line, and this is the one we want
order.Cancel();
Assert.Equal(OrderStatus.Cancelled, order.Status);
}CreateAnyPendingOrder() remplace les trois lignes d'arrange, et le test ne dit plus que l'essentiel : la commande est en attente. Ce helper, vous pouvez l'écrire vous-même : il contient la chaîne de contraintes que vous venez d'écrire, dans un fichier de votre projet de test. Le jour où Order gagne un paramètre, c'est vous qui rouvrez ce fichier. La suite montre un outil qui l'écrit, et qui le réécrit.
L'outil lit votre type et écrit le générateur
dum generate OrderAnalyzing JustDummies.SnippetValidation.Domain.Order
constructor Order(OrderReference, CustomerId, Money, OrderStatus)
reference OrderReference Any.String().WithLengthBetween(8, 20).As(OrderReference.Create) to verify, factory, guard, unread guards
customerId CustomerId Any.Guid().NonEmpty().As(CustomerId.Create) factory, guard
total Money Any.Decimal().Positive().As(Money.Create) factory, guard
status OrderStatus Any.Enum<OrderStatus>()
✓ AnyOrder.cs — 4 of 4 parameters inferred, 1 to verify.
The file will not compile until you resolve it. That is deliberate.dum est un outil .NET global. Vous le lancez une fois par type. Il lit vos propres sources et décide, paramètre par paramètre, comment tirer une valeur. La dernière colonne dit ce qu'il a trouvé seul, et où il s'est arrêté.
Généré pour vous aider, vous gardez la main
// Scaffolded by dum (JustDummies). This file is yours: read it, edit it, commit it.
// `dum generate Order --force` overwrites it. This type is partial, so members you add in a
// neighbouring file survive.
using JustDummies;
namespace JustDummies.SnippetValidation.Domain;
/// <summary>
/// A generator of arbitrary <see cref="Order" /> values. It draws from the ambient random
/// context, so a reproducibility scope pins it; to draw from an isolated
/// <c>Any.WithSeed(...)</c> context, pass that context's generators through the
/// <c>With…</c> overloads.
/// </summary>
public sealed partial class AnyOrder : IAny<Order> {
private readonly IAny<OrderReference> _reference;
private readonly IAny<CustomerId> _customerId;
private readonly IAny<Money> _total;
private readonly IAny<OrderStatus> _status;
/// <summary>Creates the generator with a default recipe for every constructor parameter.</summary>
public AnyOrder()
: this(reference: ReferenceFactory(),
customerId: CustomerIdFactory(),
total: TotalFactory(),
status: StatusFactory()) { }
private static IAny<OrderReference> ReferenceFactory() {
// TODO(dum): 'OrderReference reference' may be guarded by something dum could not read (§9).
// This is dum's best generator for the type; verify it honours the real invariant,
// or replace it, then delete the line below.
_ = TODO_verify_the_generator_for_reference;
return Any.String().WithLengthBetween(8, 20).As(OrderReference.Create);
}
private static IAny<CustomerId> CustomerIdFactory() {
return Any.Guid().NonEmpty().As(CustomerId.Create);
}
private static IAny<Money> TotalFactory() {
return Any.Decimal().Positive().As(Money.Create);
}
private static IAny<OrderStatus> StatusFactory() {
return Any.Enum<OrderStatus>();
}
private AnyOrder(IAny<OrderReference> reference,
IAny<CustomerId> customerId,
IAny<Money> total,
IAny<OrderStatus> status) {
_reference = reference;
_customerId = customerId;
_total = total;
_status = status;
}
/// <summary>Pins <c>reference</c> to a fixed value.</summary>
public AnyOrder WithReference(OrderReference value) {
return WithReference(new FixedValue<OrderReference>(value));
}
/// <summary>Draws <c>reference</c> from <paramref name="generator" />.</summary>
public AnyOrder WithReference(IAny<OrderReference> generator) {
return new AnyOrder(generator, _customerId, _total, _status);
}
/// <summary>Pins <c>customerId</c> to a fixed value.</summary>
public AnyOrder WithCustomerId(CustomerId value) {
return WithCustomerId(new FixedValue<CustomerId>(value));
}
/// <summary>Draws <c>customerId</c> from <paramref name="generator" />.</summary>
public AnyOrder WithCustomerId(IAny<CustomerId> generator) {
return new AnyOrder(_reference, generator, _total, _status);
}
/// <summary>Pins <c>total</c> to a fixed value.</summary>
public AnyOrder WithTotal(Money value) {
return WithTotal(new FixedValue<Money>(value));
}
/// <summary>Draws <c>total</c> from <paramref name="generator" />.</summary>
public AnyOrder WithTotal(IAny<Money> generator) {
return new AnyOrder(_reference, _customerId, generator, _status);
}
/// <summary>Pins <c>status</c> to a fixed value.</summary>
public AnyOrder WithStatus(OrderStatus value) {
return WithStatus(new FixedValue<OrderStatus>(value));
}
/// <summary>Draws <c>status</c> from <paramref name="generator" />.</summary>
public AnyOrder WithStatus(IAny<OrderStatus> generator) {
return new AnyOrder(_reference, _customerId, _total, generator);
}
/// <summary>Produces one arbitrary <see cref="Order" />.</summary>
public Order Generate() {
return new Order(_reference.Generate(),
_customerId.Generate(),
_total.Generate(),
_status.Generate());
}
private sealed class FixedValue<TValue> : IAny<TValue> {
private readonly TValue _value;
public FixedValue(TValue value) {
_value = value;
}
public TValue Generate() {
return _value;
}
}
}L'outil écrit tout le fichier : les champs, une factory par paramètre, le tirage. Il ne sait pas lire la règle du préfixe ORD-, alors plutôt que de l'inventer il écrit son meilleur générateur et plante à côté une ligne qui ne compile pas. Le fichier ne se construit pas tant que vous n'avez pas regardé. Vous supprimez cette ligne et ajoutez .AlphaNumeric(), .InUpperCase() et .StartingWith("ORD-") sur la factory de reference : c'est la chaîne que vous avez déjà écrite, inchangée. Le fichier vous appartient : vous le lisez, vous le modifiez, vous le commitez.
Un test enfin explicite, et qui ne ment pas
[Fact, Reproducible]
public void A_pending_order_can_be_cancelled() {
Order order = Any.Order().WithStatus(OrderStatus.Pending).Generate();
order.Cancel();
Assert.Equal(OrderStatus.Cancelled, order.Status);
}Le même test qu'avant, jusqu'à l'assertion comprise. La préparation tient sur une ligne, et cette ligne nomme la seule chose dont le test a besoin : la commande est en attente. Le reste est tiré à chaque exécution, et reste valide. Plus rien ici ne s'appelle « any » en rendant toujours la même valeur.
Installer tout ça
CLI .NET
La bibliothèque
Voir sur NuGet — La bibliothèqueouvre un nouvel ongletdotnet add package JustDummies --prereleaseL'adaptateur xUnit
Voir sur NuGet — L'adaptateur xUnitouvre un nouvel ongletdotnet add package JustDummies.Xunit --prereleaseL'outil de scaffolding
Voir sur NuGet — L'outil de scaffoldingouvre un nouvel ongletdotnet tool install --global JustDummies.Cli --prereleaseConsole du gestionnaire de packages
La bibliothèque
Voir sur NuGet — La bibliothèqueouvre un nouvel ongletInstall-Package JustDummies -IncludePrereleaseL'adaptateur xUnit
Voir sur NuGet — L'adaptateur xUnitouvre un nouvel ongletInstall-Package JustDummies.Xunit -IncludePrereleaseL'outil de scaffolding
Voir sur NuGet — L'outil de scaffoldingouvre un nouvel ongletUn outil .NET global s'installe en ligne de commande.
L'outil est facultatif. La bibliothèque seule rendait déjà tout cela possible. L'outil vous épargne seulement de l'écrire.
Une question se pose forcément ici : si les valeurs changent à chaque exécution, comment revenir sur celle qui a fait échouer un test ?
Un tirage qui se rejoue à l'identique
Les valeurs changent à chaque exécution. Le jour où l'une d'elles fait échouer un test, vous récupérez exactement celle-là.
Attraper un bug avant qu'il n'arrive en production
[Fact, Reproducible]
public void A_pending_order_can_be_cancelled() {
Order order = Any.Order().WithStatus(OrderStatus.Pending).Generate();
order.Cancel();
Assert.Equal(OrderStatus.Cancelled, order.Status);
}Votre build passe au rouge alors que rien n'a changé dans le code. La valeur tirée ce jour-là a trouvé un cas que votre code ne tient pas. C'est un bug qui serait parti en production. Ce tirage n'est pas perdu : les deux étapes qui suivent le récupèrent à l'identique, en une ligne.
Prenons un exemple
[Fact, Reproducible]
public void A_pending_order_can_be_cancelled() {
Order order = new AnyOrder().Generate();
order.Cancel();
Assert.Equal(OrderStatus.Cancelled, order.Status);
}Ce test est le même, mais le statut reste arbitraire. Deux des trois statuts ne s'annulent pas, donc il passe au rouge environ deux fois sur trois. Rien n'est cassé. Le test vient de découvrir qu'il ne disait pas ce dont il avait besoin.
Le test qui échoue vous dit comment le rejouer
Ordering.Tests.OrderCancellationReplayed.A_pending_order_can_be_cancelled [FAIL]
System.InvalidOperationException : Only a pending order can be cancelled.
Output:
[JustDummies] These arbitrary values were seeded with -1808250554. Reproduce this run with [Reproducible(Seed = -1808250554)].Le test qui a échoué écrit une ligne dans la sortie de votre build. Cette ligne porte un numéro : le seed. Ce numéro suffit à retirer exactement les mêmes valeurs.
Collez-le, et vous retrouvez le même tirage
[Fact, Reproducible(Seed = -1808250554)]
public void A_pending_order_can_be_cancelled() {
Order order = new AnyOrder().Generate();
order.Cancel();
Assert.Equal(OrderStatus.Cancelled, order.Status);
}Ordering.Tests.OrderCancellationReplayed.A_pending_order_can_be_cancelled [FAIL]
System.InvalidOperationException : Only a pending order can be cancelled.
Output:
[JustDummies] These arbitrary values were seeded with -1808250554. Reproduce this run with [Reproducible(Seed = -1808250554)].Vous collez dans le test le seed rapporté par votre build, et l'échec revient sur votre machine. Ce sont les valeurs qui ont échoué, pas des valeurs qui leur ressemblent. Chaque cas de test tire son propre seed, donc une suite qui tourne en parallèle vous rend le seed du cas qui a échoué.
Envie d'essayer ?
Trois packages. Aucun n'est gros, et vous avez vu ce que chacun fait.
CLI .NET
La bibliothèque
Voir sur NuGet — La bibliothèqueouvre un nouvel ongletdotnet add package JustDummies --prereleaseL'adaptateur xUnit
Voir sur NuGet — L'adaptateur xUnitouvre un nouvel ongletdotnet add package JustDummies.Xunit --prereleaseL'outil de scaffolding
Voir sur NuGet — L'outil de scaffoldingouvre un nouvel ongletdotnet tool install --global JustDummies.Cli --prereleaseConsole du gestionnaire de packages
La bibliothèque
Voir sur NuGet — La bibliothèqueouvre un nouvel ongletInstall-Package JustDummies -IncludePrereleaseL'adaptateur xUnit
Voir sur NuGet — L'adaptateur xUnitouvre un nouvel ongletInstall-Package JustDummies.Xunit -IncludePrereleaseL'outil de scaffolding
Voir sur NuGet — L'outil de scaffoldingouvre un nouvel ongletUn outil .NET global s'installe en ligne de commande.
L'adaptateur transforme un test rouge en un tirage que vous rejouez. C'est le plus petit des trois packages. Les trois sont ici.