Aujourd'hui, je vais parler du modèle de constructeur dans le développement piloté par les tests. Si vous travaillez déjà avec des tests, vous avez probablement remarqué à quel point la création de toutes les données d'entrée peut prendre du temps. Souvent, le même ensemble de données, ou des données présentant de légères différences, sont utilisées dans de nombreux tests de la suite de tests d'un système. Le modèle de constructeur est utile ici. Il sert à deux fins :
À titre d’exemple, je prendrai la classe Invoice
, une version très simplifiée pourrait ressembler à ceci :
public class Invoice { public Invoice( string invoiceNo, string customer, string countryCode, DateTime invoiceDate, IReadOnlyList<InvoiceLine> lines) { InvoiceNo = invoiceNo; InvoiceDate = invoiceDate; Customer = customer; CountryCode = countryCode; Lines = lines; } public string InvoiceNo { get; } public string Customer { get; } public string CountryCode { get; } public DateTime InvoiceDate { get; } public decimal TotalAmount => Lines.Sum(x => x.TotalPrice); public IReadOnlyList<InvoiceLine> Lines { get; } } public class InvoiceLine { public InvoiceLine( string itemCode, decimal unitCount, decimal unitPrice, decimal vat) { ItemCode = itemCode; UnitCount = unitCount; UnitPrice = unitPrice; Vat= vat; } public string ItemCode { get; } public decimal UnitCount { get; } public decimal UnitPrice { get; } public decimal Vat { get; } public decimal TotalPrice => UnitCount * UnitPrice * (1 + Vat / 100); }
Pour créer un objet Invoice
, je dois fournir de nombreuses valeurs aux constructeurs de Invoice
et InvoiceLine
. Dans de nombreux cas, seule une partie des propriétés est pertinente pour des tests spécifiques. Ici, les constructeurs viennent en aide.
Le générateur pour InvoiceLine
pourrait ressembler à ceci :
public partial class InvoiceLineBuilder { private string _itemCode; private decimal _unitCount; private decimal _unitPrice; private decimal _vat; public static implicit operator InvoiceLine(InvoiceLineBuilder builder) => builder.Build(); public static InvoiceLineBuilder Default() { return new InvoiceLineBuilder( "001", 1, 100, 21 ); } public InvoiceLineBuilder( string itemCode, decimal unitCount, decimal unitPrice, decimal vat) { _itemCode = itemCode; _unitCount = unitCount; _unitPrice = unitPrice; _vat = vat; } public InvoiceLine Build() { return new InvoiceLine( _itemCode, _unitCount, _unitPrice, _vat ); } public InvoiceLineBuilder WithItemCode(string value) { _itemCode = value; return this; } public InvoiceLineBuilder WithUnitCount(decimal value) { _unitCount = value; return this; } public InvoiceLineBuilder WithUnitPrice(decimal value) { _unitPrice = value; return this; } public InvoiceLineBuilder WithVat(decimal vat) { _vat = value; return this; } }
Builder for Invoice
pourrait ressembler à ceci :
public partial class InvoiceBuilder { private string _invoiceNo; private string _customer; private string _countryCode; private DateTime _invoiceDate; private IReadOnlyList<InvoiceLine> _lines; public static implicit operator Invoice(InvoiceBuilder builder) => builder.Build(); public static InvoiceBuilder Default() { return new InvoiceBuilder( "S001", "AB VeryImportantCustomer", "SV", DateTime.Parse("2024-01-01"), new [] { InvoiceLineBuilder .Default() .Build() } ); } public InvoiceBuilder( string invoiceNo, string customer, string countryCode, DateTime invoiceDate, IReadOnlyList<InvoiceLine> lines) { _invoiceNo = invoiceNo; _customer = customer; _countryCode = countryCode; _invoiceDate = invoiceDate; _lines = lines; } public Invoice Build() { return new Invoice( _invoiceNo, _invoiceDate, _lines ); } public InvoiceBuilder WithInvoiceNo(string value) { _invoiceNo = value; return this; } public InvoiceBuilder WithCustomer(string value) { _customer = value; return this; } public InvoiceBuilder WithCountryCode(string value) { _countryCode = value; return this; } public InvoiceBuilder WithInvoiceDate(DateTime value) { _invoiceDate = value; return this; } public InvoiceBuilder WithLines(IReadOnlyList<InvoiceLine> value) { _lines = value; return this; } public InvoiceBuilder WithLines(params InvoiceLine[] value) { _lines = value; return this; } }
Dans le cas où un test nécessite un objet Invoice
uniquement pour sa propriété de prix total, alors Invoice
peut être créé comme ceci :
var invoice = InvoiceBuilder .Default() .WithLines( InvoiceLineBuilder .Default .WithUnitPrice(158) );
Comme le prix total est calculé en additionnant les lignes de facture et que le nombre d'unités par défaut pour la ligne de facture est de 1, il suffit de définir le prix unitaire de la ligne de facture. Si une fonctionnalité similaire est nécessaire dans plusieurs tests, nous pourrions aller plus loin et ajouter la méthode suivante à InvoiceBuilder
:
public static InvoiceBuilder DefaultWithTotalPrice(decimal totalPrice) { return new InvoiceBuilder( "S001", DateTime.Parse("2023-01-01"), new[] { InvoiceLineBuilder .Default() .WithUnitPrice(totalPrice) .Build() } ); }
Comme mentionné ci-dessus, la classe builder est un excellent endroit pour collecter tous les cas courants et extrêmes de la classe. Ici, je vais fournir quelques-uns de ces cas possibles :
De mon point de vue, c'est un excellent endroit pour recueillir des connaissances sur les différents cas que notre système gère. Il sert de base de connaissances utile aux nouveaux développeurs pour comprendre ce que le système doit gérer. Si je suis nouveau dans un domaine, je ne pense peut-être même pas aux cas extrêmes possibles. Voici un exemple de code de certains des cas mentionnés ci-dessus :
public static InvoiceBuilder ForEUCountry() { return Default() .WithCountryCode("SV"); } public static InvoiceBuilder ForUSA() { return Default() .WithCountryCode("USA"); } public static InvoiceBuilder ForChina() { return Default() .WithCountryCode("CN"); } public InvoiceBuilder WithRegularVat() { return this .WithLines( InvoiceLineBuilder .Default .WithItemCode("S001") .WithVat(21), InvoiceLineBuilder .Default .WithItemCode("S002") .WithVat(21) ); } public InvoiceBuilder WithReducedVat() { return this .WithLines( InvoiceLineBuilder .Default .WithItemCode("S001") .WithVat(9), InvoiceLineBuilder .Default .WithItemCode("S002") .WithVat(9) ); } public InvoiceBuilder WithMixedVat() { return this .WithLines( InvoiceLineBuilder .Default .WithItemCode("S001") .WithVat(21), InvoiceLineBuilder .Default .WithItemCode("S002") .WithVat(9) ); }
Nous pouvons maintenant créer un mélange des éléments ci-dessus. Par exemple, si un cas de test nécessite une facture pour un client de l'UE avec des lignes de facture avec TVA mixte, je peux procéder comme suit :
[Test] public void SomeTest() { //arrange var invoice = InvoiceBuilder .ForEU() .WithMixedVat(); //act ... //assert ... }
Ce n’est qu’un exemple simple, mais j’espère que vous comprenez le concept.
Un constructeur est utile lorsque nous avons un objet volumineux et complexe, mais que seuls quelques champs sont pertinents pour le test.
Un autre cas utile est lorsque je souhaite tester plusieurs scénarios en fonction de valeurs spécifiques. Toutes les propriétés sauf une restent les mêmes et je n'en modifie qu'une seule. Cela permet de mettre plus facilement en évidence la différence qui entraîne un comportement différent du service ou de l'objet.
Tout d'abord, vous pouvez créer vous-même une classe de construction. Cela ne nécessite aucun investissement initial en temps ou en argent, et vous avez beaucoup de liberté dans la manière dont vous la construisez. Copier, coller et remplacer peuvent être utiles, mais cela prend quand même un peu de temps, surtout pour les classes plus grandes.
Lorsque j'ai commencé à générer du code, j'ai commencé par configurer un seul test pour celui-ci. Ce test ne testait rien en réalité ; il acceptait simplement un type, récupérait toutes les propriétés à l'aide de la réflexion, créait une classe de création à partir d'un modèle codé en dur et l'écrivait dans la fenêtre de sortie du testeur. Tout ce que j'avais à faire était de créer un fichier de classe et de copier/coller le contenu à partir de la fenêtre de sortie du testeur.
Vous trouverez ici toutes les informations sur BuilderGenerator. Il explique le générateur de source incrémental .NET. Cela signifie que le code du générateur est régénéré en direct lorsque la classe cible change. Il n'y a donc aucun problème ni travail manuel par rapport aux méthodes ci-dessus. Créez simplement une classe de générateur, ajoutez l'attribut BuilderFor
avec le type de classe cible et toutes les méthodes With
sont générées automatiquement et prêtes à l'emploi.
[BuilderFor(typeof(InvoiceLine))] public partial class InvoiceLineBuilder { public static InvoiceLineBuilder Default() { return new InvoiceLineBuilder() .WithItemCode("S001") .WithUnitCount(1); } }
Je n'ai pas beaucoup travaillé avec, mais il semble avoir une large base d'utilisateurs avec 82,7 000 téléchargements au moment de la rédaction. J'ai remarqué quelques problèmes qui m'ont fait choisir d'autres options :
La solution ne parvient pas à se construire si la classe de construction se trouve dans un projet différent de la classe cible. Elle peut se trouver dans un autre projet, mais l'espace de noms doit rester le même. Sinon, vous verrez les erreurs suivantes :
Il ne prend pas en charge les paramètres de constructeur et échoue avec des erreurs si la classe cible n'a pas de constructeur sans paramètre :
Explorons les autres options qui s’offrent à nous.
Il s'agit d'une bibliothèque très populaire avec plus de 82,2 millions de téléchargements au total (et 186,1 000 pour la version actuelle) au moment de la rédaction. Comme l'indique l'auteur de la bibliothèque, il s'agit d'un faux générateur de données capable de produire de nombreux objets basés sur des règles prédéfinies. Ce n'est pas exactement ce qu'est le modèle de construction dans TDD, mais il peut être adapté.
Il existe plusieurs façons d'utiliser Bogus.Faker, mais je vais me concentrer ici sur la façon d'imiter le modèle de générateur.
La manière la plus simple de créer un objet est d'utiliser Bogus.Faker :
[Test] public void BogusTest() { var faker = new Faker<InvoiceLine2>(); var invoiceLine = faker.Generate(); Assert.IsNotNull(invoiceLine); }
Il crée une instance de InvoiceLine2
avec des valeurs par défaut, c'est-à-dire des valeurs nulles et des zéros. Pour définir certaines valeurs, j'utiliserai la configuration suivante :
[Test] public void BogusTest() { var faker = new Faker<InvoiceLine2>() .RuleFor(x => x.ItemCode, f => f.Random.AlphaNumeric(5)) .RuleFor(x => x.UnitPrice, f => f.Random.Decimal(10, 1000)) .RuleFor(x => x.UnitCount, f => f.Random.Number(1, 5)) .RuleFor(x => x.Vat, f => f.PickRandom(21, 9, 0)); var invoiceLine = faker.Generate(); Assert.IsNotNull(invoiceLine); ToJson(invoiceLine); }
Le code ci-dessus crée un objet de ligne de facture avec des valeurs aléatoires. Un exemple pourrait ressembler à ceci :
{ "ItemCode": "gwg7y", "UnitCount": 3.0, "UnitPrice": 597.035612417891230, "Vat": 0.0, "TotalPrice": 1791.106837253673690 }
C'est utile, mais chaque test nécessite sa propre configuration. À la place, nous pouvons créer une classe de construction :
public class InvoiceLineBuilder: Faker<InvoiceLine2> { public static InvoiceLineBuilder Default() { var faker = new InvoiceLineBuilder(); faker .RuleFor(x => x.ItemCode, f => f.Random.AlphaNumeric(5)) .RuleFor(x => x.UnitPrice, f => f.Random.Decimal(10, 1000)) .RuleFor(x => x.UnitCount, f => f.Random.Number(1, 5)) .RuleFor(x => x.Vat, f => f.PickRandom(21, 9, 0)); return faker; } }
L'utilisation ressemblerait à ceci :
[Test] public void BogusTest() { var faker = TestDoubles.Bogus.InvoiceLineBuilder .Default() .RuleFor(x => x.ItemCode, f => "S001") .RuleFor(x => x.UnitPrice, f => 100); var invoiceLine = faker.Generate(); Assert.IsNotNull(invoiceLine); ToJson(invoiceLine); }
Et le résultat :
{ "ItemCode": "S001", "UnitCount": 2.0, "UnitPrice": 100.0, "Vat": 9.0, "TotalPrice": 218.00 }
De mon point de vue, c'est un peu plus verbeux que le modèle de construction standard. De plus, je ne suis pas fan de l'utilisation de valeurs aléatoires. Ce n'est pas un gros problème, mais des problèmes surviennent lorsque les propriétés d'une classe sont initialisées à l'aide d'un constructeur et qu'elle n'a pas de setters. Cela ne fonctionne alors pas comme un constructeur et chaque configuration devient statique.
var faker = new InvoiceLineBuilder(); faker .CustomInstantiator(f => new InvoiceLine( f.Random.AlphaNumeric(5), f.Random.Decimal(10, 1000), f.Random.Number(1, 5), f.PickRandom(21, 9, 0) ) );
Il s'agit également d'une bibliothèque très populaire avec plus de 13,2 millions de téléchargements au total (et 7,2 millions pour la version actuelle). Bien qu'elle n'ait pas été activement développée récemment, la dernière version est sortie en 2019. Essentiellement, elle est très similaire à Bogus.Faker. Il devrait même être possible de réutiliser Bogus pour fournir des valeurs aléatoires en implémentant un IPropertyNamer spécifique.
Essayons de l’utiliser sans définir aucune propriété :
[Test] public void NBuilderTest() { var invoiceLine = Builder<InvoiceLine2> .CreateNew() .Build(); Assert.IsNotNull(invoiceLine); ToJson(invoiceLine); }
Il produit la sortie suivante ::
{ "ItemCode": "ItemCode1", "UnitCount": 1.0, "UnitPrice": 1.0, "Vat": 1.0, "TotalPrice": 1.01 }
L'objectif de cet article est de montrer comment créer une classe de constructeur réutilisable. Commençons :
public class InvoiceLineBuilder { public static ISingleObjectBuilder<InvoiceLine2> Default() { return Builder<InvoiceLine2> .CreateNew() .With(x => x.ItemCode, "S001") .With(x => x.UnitCount, 1) .With(x => x.UnitPrice, 100) .With(x => x.Vat, 21); } }
Et voici l'utilisation :
[Test] public void NBuilderTest() { var invoiceLine = TestDoubles.NBuilder.InvoiceLineBuilder .Default() .With(x => x.ItemCode, "S002") .With(x => x.Vat, 9) .Build(); Assert.IsNotNull(invoiceLine); ToJson(invoiceLine); }
Et le résultat :
{ "ItemCode": "S002", "UnitCount": 1.0, "UnitPrice": 100.0, "Vat": 9.0, "TotalPrice": 109.00 }
Similairement à Bogus.Faker, vous ne pouvez pas remplacer les valeurs si une propriété de classe est définie à l'aide d'un constructeur et n'a pas de setter . Si vous essayez d'utiliser la méthode With pour une telle propriété, elle échouera avec l'exception suivante :
System.ArgumentException : Property set method not found.
EasyTdd.Generators.Builder est un package Nuget qui fonctionne en tandem avec EasyTdd, l'extension Visual Studio . Ce package exploite un générateur de source incrémentiel .NET pour créer des générateurs à partir de modèles utilisés par l'extension EasyTdd. Le générateur de générateur gère les setters de propriétés, les paramètres de constructeur et une combinaison des deux. Il prend également en charge les paramètres génériques.
C'est ma méthode préférée pour créer un générateur. Voici les avantages par rapport aux autres options :
Lorsque EasyTdd est installé dans Visual Studio, ouvrez le menu d'action rapide sur la classe cible et sélectionnez « Générer le générateur incrémentiel » :
Cette action crée une classe de générateur partielle avec l'attribut BuilderFor défini :
[EasyTdd.Generators.BuilderFor(typeof(InvoiceLine))] public partial class InvoiceLineBuilder { public static InvoiceLineBuilder Default() { return new InvoiceLineBuilder( () => default, // Set default itemCode value () => default, // Set default unitCount value () => default, // Set default unitPrice value () => default // Set default vat value ); } }
Le code du générateur lui-même est généré en arrière-plan, et cette classe partielle est destinée aux configurations de cas courants/limites. N'hésitez pas à définir des valeurs par défaut au lieu de default
.
Vous trouverez plus d'informations sur sa configuration et son fonctionnement ici .
L'avantage est que si j'ai besoin de valeurs aléatoires, je peux utiliser Bogus ici :
public static InvoiceLineBuilder Random() { var f = new Faker(); return new InvoiceLineBuilder( () => f.Random.AlphaNumeric(5), () => f.Random.Decimal(10, 1000), () => f.Random.Number(1, 5), () => f.PickRandom(21, 9, 0) ); }
Usage:
[Test] public void EasyTddBuilder() { var invoiceLine = TestDoubles.Builders.InvoiceLineBuilder .Random() .WithUnitPrice(100) .WithUnitCount(1) .Build(); Assert.IsNotNull(invoiceLine); ToJson(invoiceLine); }
Et le résultat :
{ "ItemCode": "ana0i", "UnitCount": 1.0, "UnitPrice": 100.0, "Vat": 9.0, "TotalPrice": 109.00 }
EasyTdd propose également une génération de code de builder complète sans dépendance au package Nuget EasyTdd.Generators. Ceci est utile si vous ne souhaitez pas ou n'êtes pas autorisé à dépendre de bibliothèques tierces. L'extension génère le code et tout est dans votre projet, sans dépendances, sans aucune condition. N'hésitez pas à le modifier, tout est à vous. Cette approche offre tous les avantages du cas EasyTdd.Generators, à l'exception de la régénération automatique lors des changements de classe cible.
Dans ce cas, le générateur doit être régénéré manuellement (également en quelques clics). Deux fichiers sont générés pour éviter de perdre les configurations lors de la régénération. Un fichier contient la déclaration de classe du générateur, avec toutes les méthodes nécessaires, l'autre est destiné uniquement aux configurations et aux méthodes supplémentaires, qui ne sont pas destinées à la régénération. La classe peut être générée de la même manière que ci-dessus, en ouvrant le menu d'action rapide et en cliquant sur « Générer le générateur » :
Lorsque le builder est déjà généré l'outil propose d'ouvrir la classe builder ou de la régénérer :
Dans cet article de blog, j'ai présenté le modèle de création et son utilisation dans le développement piloté par les tests. J'ai également montré plusieurs façons de l'implémenter, en commençant par l'implémentation manuelle, en utilisant des bibliothèques tierces comme Bogus.Faker et NBuilder, des générateurs de code incrémentiels comme BuilderGenerator et EasyTdd.Generators.Builder, et enfin, en faisant générer tout le code par l'extension EasyTdd de Visual Studio. Chaque méthode a ses points forts et ses points faibles et fonctionne bien dans les cas simples.
Cependant, lorsqu'il s'agit de classes immuables, EasyTdd se distingue par la gestion des modifications de propriétés de manière égale, qu'une valeur de propriété soit initialisée par un setter ou via un paramètre de constructeur. EasyTdd prend en charge les modèles et vous permet de personnaliser la sortie en fonction de vos préférences. EasyTdd surpasse également les autres méthodes d'implémentation d'un générateur en raison de sa rapidité d'implémentation. Il fournit des outils dans Visual Studio pour générer automatiquement des fichiers en quelques clics, ce qui permet d'économiser du temps et des efforts.
Photo de Markus Spiske sur Unsplash