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();
produitORD-4KMVJ2EIUA

environ 1,2 Mo, téléchargés seulement si vous le demandez

CLI .NET

dotnet add package JustDummies --prerelease

Console du gestionnaire de packages

Install-Package JustDummies -IncludePrerelease
En savoir plus

La 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 thing

C'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());
    }

}
refuséAn order reference must start with ORD-. (Parameter 'value')

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);
    }

}
produit, exécution après exécutionORD-J2HLSL6DIORD-HNT3A027CEVXORD-BR5R5ABYORD-Z5VPW646GIV

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

dotnet add package JustDummies --prerelease
dotnet add package JustDummies.Xunit --prerelease

Console du gestionnaire de packages

Install-Package JustDummies -IncludePrerelease
Install-Package JustDummies.Xunit -IncludePrerelease

Tout 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 Order
Analyzing 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é.

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

dotnet add package JustDummies --prerelease
dotnet add package JustDummies.Xunit --prerelease
dotnet tool install --global JustDummies.Cli --prerelease

Console du gestionnaire de packages

Install-Package JustDummies -IncludePrerelease
Install-Package JustDummies.Xunit -IncludePrerelease

Un 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);
}
produit, exécution après exécutionPendingCancelledCancelledShipped

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);
}
ce que le build a obtenu en l'exécutant
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

dotnet add package JustDummies --prerelease
dotnet add package JustDummies.Xunit --prerelease
dotnet tool install --global JustDummies.Cli --prerelease

Console du gestionnaire de packages

Install-Package JustDummies -IncludePrerelease
Install-Package JustDummies.Xunit -IncludePrerelease

Un 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.