Combien de fois est-il arrivé que lorsque vous écrivez un test unitaire de travail, vous regardez son code, et est-ce ... mauvais? Et vous pensez comme ça: "Ceci est un test, laissez-le comme ça ...". Non,% username%, alors ne le quittez pas. Les tests sont une partie importante du système qui fournit un support de code, et il est très important que cette partie soit également prise en charge. Malheureusement, nous n'avons pas beaucoup de moyens de garantir cela (nous n'écrirons pas de tests pour les tests), mais il en reste quelques-uns.

Dans notre école de développeurs Dodo DevSchool, nous soulignons, entre autres, les critères suivants pour un bon test:
- reproductibilité: l'exécution de tests sur le même code et la même entrée conduit toujours au même résultat;
- concentration: il ne devrait y avoir qu'une seule raison pour que le test tombe;
- compréhensibilité: eh bien, ici c'est clair. :)
Comment appréciez-vous un tel test au regard de ces critères?
[Fact] public void AcceptOrder_Successful() { var ingredient1 = new Ingredient("Ingredient1"); var ingredient2 = new Ingredient("Ingredient2"); var ingredient3 = new Ingredient("Ingredient3"); var order = new Order(DateTime.Now); var product1 = new Product("Pizza1"); product1.AddIngredient(ingredient1); product1.AddIngredient(ingredient2); var orderLine1 = new OrderLine(product1, 1, 500); order.AddLine(orderLine1); var product2 = new Product("Pizza2"); product2.AddIngredient(ingredient1); product2.AddIngredient(ingredient3); var orderLine2 = new OrderLine(product2, 1, 650); order.AddLine(orderLine2); var orderRepositoryMock = new Mock<IOrderRepository>(); var ingredientsRepositoryMock = new Mock<IIngredientRepository>(); var service = new PizzeriaService(orderRepositoryMock.Object, ingredientsRepositoryMock.Object); service.AcceptOrder(order); orderRepositoryMock.Verify(r => r.Add(order), Times.Once); ingredientsRepositoryMock.Verify(r => r.ReserveIngredients(order), Times.Once); }
Pour moi - très mauvais.
C'est incompréhensible: par exemple, je ne peux même pas allouer les blocs Arranger, Act et Assert.
Illisible: la propriété DateTime.Now est utilisée. Et enfin, il est flou, car a 2 raisons à la chute: les appels aux méthodes de deux référentiels sont vérifiés.
De plus, bien que la dénomination des tests dépasse le cadre de cet article, je reste attentif au nom: avec un tel ensemble de propriétés négatives, il est difficile de le formuler de telle manière qu'en regardant le nom du test, une personne extérieure comprenne immédiatement pourquoi ce test est généralement dans le projet.
Si vous ne pouvez pas nommer le test de manière concise, alors quelque chose ne va pas avec le test.
Puisque le test est incompréhensible, disons ce qui s'y passe:
- Les ingrédients sont créés.
- À partir des ingrédients, des produits (pizzas) sont créés.
- Une commande est créée à partir des produits.
- Un service est créé pour lequel les référentiels sont mouillés.
- La commande est passée à la méthode AcceptOrder du service.
- Il est vérifié que les méthodes Add et ReserveIngredients des référentiels respectifs ont été appelées.
Alors, comment pouvons-nous améliorer ce test? Vous devez essayer de ne laisser dans le corps du test que ce qui est vraiment important. Et pour cela, des gens intelligents comme Martin Fowler et Rebecca Parsons sont venus avec
DSL (Domain Specific Language) . Ici, je vais parler des modèles DSL que nous utilisons chez Dodo pour nous assurer que nos tests unitaires sont doux et soyeux, et les développeurs se sentent en confiance tous les jours.
Le plan est le suivant: nous allons d'abord rendre ce test compréhensible, puis nous travaillerons sur la reproductibilité et finirons par le rendre ciblé. Nous avons conduit ...
Élimination des ingrédients (objets de domaine prédéfinis)
Commençons par le bloc de création de commande. La commande est l'une des entités du domaine central. Ce serait cool si nous pouvions décrire l'ordre de telle manière que même les personnes qui ne savent pas écrire du code mais comprennent la logique du domaine peuvent comprendre quel type d'ordre nous créons. Pour ce faire, tout d'abord, nous devons abandonner l'utilisation de l'abrégé "Ingrédient1" et "Pizza1" en les remplaçant par de vrais ingrédients, pizzas et autres objets du domaine.
Le premier candidat à l'optimisation est les ingrédients. Tout est simple avec eux: ils n'ont besoin d'aucune personnalisation, juste un appel au constructeur. Il suffit de les mettre dans un conteneur séparé et de les nommer pour qu'il soit clair pour les experts du domaine:
public static class Ingredients { public static readonly Ingredient Dough = new Ingredient("Dough"); public static readonly Ingredient Pepperoni = new Ingredient("Pepperoni"); public static readonly Ingredient Mozzarella = new Ingredient("Mozzarella"); }
Au lieu de l'ingrédient1, l'ingrédient2 et l'ingrédient3 complètement insensés, nous avons obtenu la pâte, le pepperoni et la mozzarella.
Utilisez des objets de domaine prédéfinis pour les entités de domaine couramment utilisées.
Constructeur de produits
L'entité de domaine suivante est les produits. Tout est un peu plus compliqué avec eux: chaque produit est composé de plusieurs ingrédients et nous devrons les ajouter au produit avant utilisation.
Ici, le bon vieux modèle Builder est très pratique. Voici ma version de build pour le produit:
public class ProductBuilder { private Product _product; public ProductBuilder(string name) { _product = new Product(name); } public ProductBuilder Containing(Ingredient ingredient) { _product.AddIngredient(ingredient); return this; } public Product Please() { return _product; } }
Il se compose d'un constructeur paramétré, d'une méthode
Containing
personnalisée et d'une méthode
Please
terminal. Si vous n'aimez pas être gentil avec le code, vous pouvez remplacer
Please
par
Now
. Le générateur masque les constructeurs complexes et les appels de méthode qui configurent l'objet. Le code devient plus propre et plus compréhensible. Dans le bon sens, le constructeur devrait simplifier la création de l'objet afin que le code soit clair pour l'expert du domaine. Il est particulièrement utile d'utiliser un générateur pour les objets qui nécessitent une configuration avant de commencer le travail.
Le constructeur de produits vous permettra de créer des designs comme:
var pepperoni = new ProductBuilder("Pepperoni") .Containing(Ingredients.Dough) .Containing(Ingredients.Pepperoni) .Please();
Les builds vous aident à créer des objets qui nécessitent une personnalisation. Envisagez de créer un générateur même si la configuration se compose d'une seule ligne.
ObjectMother
Malgré le fait que la création du produit est devenue beaucoup plus décente, le concepteur du
new ProductBuilder
toujours l'air plutôt moche. Corrigez-le avec le modèle ObjectMother (Father).
Le modèle est simple comme 5 kopecks: nous créons une classe statique et rassemblons tous les constructeurs dedans.
public static class Create { public static ProductBuilder Product(string name) => new ProductBuilder(name); }
Vous pouvez maintenant écrire comme ceci:
var pepperoni = Create.Product("Pepperoni") .Containing(Ingredients.Dough) .Containing(Ingredients.Pepperoni) .Please();
ObjectMother a été inventé pour la création déclarative d'objets. De plus, cela aide à introduire de nouveaux développeurs dans le domaine, comme lors de l'écriture du mot
Create
IDE lui-même vous dira ce que vous pouvez créer dans ce domaine.
Dans notre code, ObjectMother est parfois appelé Non
Create
, mais
Given
. J'aime les deux options. Si vous avez d'autres idées, partagez-les dans les commentaires.
Pour créer des objets de manière déclarative, utilisez ObjectMother. Le code deviendra plus propre et il sera plus facile pour les nouveaux développeurs de se plonger dans le domaine.
Retrait du produit
C'est devenu beaucoup mieux, mais les produits ont encore de la place pour grandir. Nous avons un nombre limité de produits et, comme les ingrédients, ils peuvent être collectés dans une classe distincte et non initialisés pour chaque test:
public static class Pizza { public static Product Pepperoni => Create.Product("Pepperoni") .Containing(Ingredients.Dough) .Containing(Ingredients.Pepperoni) .Please(); public static Product Margarita => Create.Product("Margarita") .Containing(Ingredients.Dough) .Containing(Ingredients.Mozzarella) .Please(); }
Ici, j'ai appelé le conteneur non pas des
Products
, mais de la
Pizza
. Ce nom permet de lire le test. Par exemple, cela aide à supprimer les questions comme «Le Pepperoni est-il une pizza ou une saucisse?».
Essayez d'utiliser des objets de domaine réels, pas des substituts comme Product1.
Le constructeur de la commande (exemple au dos)
Maintenant, nous appliquons les modèles décrits pour créer un générateur de commandes, mais maintenant, ne partons pas du générateur, mais de ce que nous aimerions recevoir. Voici comment je souhaite créer une commande:
var order = Create.Order .Dated(DateTime.Now) .With(Pizza.Pepperoni.CountOf(1).For(500)) .With(Pizza.Margarita.CountOf(1).For(650)) .Please();
Comment pouvons-nous y parvenir? Nous avons évidemment besoin de constructeurs pour la commande et la ligne de commande. Avec le constructeur pour commander, tout est limpide. Le voici:
public class OrderBuilder { private DateTime _date; private readonly List<OrderLine> _lines = new List<OrderLine>(); public OrderBuilder Dated(DateTime date) { _date = date; return this; } public OrderBuilder With(OrderLine orderLine) { _lines.Add(orderLine); return this; } public Order Please() { var order = new Order(_date); foreach (var line in _lines) { order.AddLine(line); } return order; } }
Mais avec
OrderLine
situation est plus intéressante: premièrement, la méthode Please du terminal n'est pas appelée ici, et deuxièmement, l'accès au générateur n'est pas fourni par le
Create
statique et non par le constructeur du constructeur lui-même. Nous allons résoudre le premier problème en utilisant l'
implicit operator
et notre constructeur ressemblera à ceci:
public class OrderLineBuilder { private Product _product; private decimal _count; private decimal _price; public OrderLineBuilder Of(decimal count, Product product) { _product = product; _count = count; return this; } public OrderLineBuilder For(decimal price) { _price = price; return this; } public static implicit operator OrderLine(OrderLineBuilder b) { return new OrderLine(b._product, b._count, b._price); } }
La deuxième méthode nous aidera à comprendre la méthode d'extension pour la classe
Product
:
public static class ProductExtensions { public static OrderLineBuilder CountOf(this Product product, decimal count) { return Create.OrderLine.Of(count, product) } }
En général, les méthodes d'extension sont de grands amis de DSL. Ils peuvent faire une description déclarative et compréhensible à partir d'une logique complètement infernale.
Utilisez des méthodes d'extension. Utilisez-les. :)
Après avoir fait toutes ces actions, nous avons obtenu le code de test suivant:
[Fact] public void AcceptOrder_Successful() { var order = Create.Order .Dated(DateTime.Now) .With(Pizza.Pepperoni.CountOf(1).For(500)) .With(Pizza.Margarita.CountOf(1).For(650)) .Please(); var orderRepositoryMock = new Mock<IOrderRepository>(); var ingredientsRepositoryMock = new Mock<IIngredientRepository>(); var service = new PizzeriaService(orderRepositoryMock.Object, ingredientsRepositoryMock.Object); service.AcceptOrder(order); orderRepositoryMock.Verify(r => r.Add(order), Times.Once); ingredientsRepositoryMock.Verify(r => r.ReserveIngredients(order), Times.Once); }
Ici, nous avons adopté l'approche que nous appelons la «fée fée». C'est lorsque vous écrivez pour la première fois du code inactif comme vous voudriez le voir, puis essayez d'envelopper ce que vous avez écrit en DSL. Ceci est très utile pour agir - parfois vous-même ne pouvez pas imaginer ce dont C # est capable.
Imaginez qu'une fée magique est arrivée et vous a permis d'écrire du code comme vous le souhaitez, puis essayez d'envelopper tout ce qui est écrit en DSL.
Création d'un service (modèle testable)
Avec la commande maintenant tout va plus ou moins pas mal. Le moment est venu de s'occuper des mokas des référentiels. Il convient de dire ici que le test lui-même, que nous envisageons, est un test de comportement. Les tests de comportement sont fortement associés à la mise en œuvre de méthodes, et s'il est possible de ne pas écrire de tels tests, il vaut mieux ne pas le faire. Cependant, ils sont parfois utiles et parfois, vous ne pouvez pas vous en passer du tout. La technique suivante permet d'écrire exactement des tests de comportement, et si vous réalisez soudainement que vous voulez l'utiliser, pensez d'abord si vous pouvez réécrire les tests de telle manière qu'ils vérifient l'état, pas le comportement.
Donc, je veux m'assurer que dans ma méthode de test, il n'y a pas un seul mok. Pour ce faire, je vais créer un wrapper pour
PizzeriaService
dans lequel j'encapsule toute la logique qui vérifie les appels de méthode:
public class PizzeriaServiceTestable : PizzeriaService { private readonly Mock<IOrderRepository> _orderRepositoryMock; private readonly Mock<IIngredientRepository> _ingredientRepositoryMock; public PizzeriaServiceTestable(Mock<IOrderRepository> orderRepositoryMock, Mock<IIngredientRepository> ingredientRepositoryMock) : base(orderRepositoryMock.Object, ingredientRepositoryMock.Object) { _orderRepositoryMock = orderRepositoryMock; _ingredientRepositoryMock = ingredientRepositoryMock; } public void VerifyAddWasCalledWith(Order order) { _orderRepositoryMock.Verify(r => r.Add(order), Times.Once); } public void VerifyReserveIngredientsWasCalledWith(Order order) { _ingredientRepositoryMock.Verify(r => r.ReserveIngredients(order), Times.Once); } }
Cette classe nous permettra de vérifier les appels de méthode, mais nous devons toujours la créer d'une manière ou d'une autre. Pour ce faire, nous utiliserons le constructeur que nous connaissons déjà:
public class PizzeriaServiceBuilder { public PizzeriaServiceTestable Please() { var orderRepositoryMock = new Mock<IOrderRepository>(); var ingredientsRepositoryMock = new Mock<IIngredientRepository>(); return new PizzeriaServiceTestable(orderRepositoryMock, ingredientsRepositoryMock); } }
Pour le moment, notre méthode de test ressemble à ceci:
[Fact] public void AcceptOrder_Successful() { var order = Create.Order .Dated(DateTime.Now) .With(Pizza.Pepperoni.CountOf(1).For(500)) .With(Pizza.Margarita.CountOf(1).For(650)) .Please(); var service = Create.PizzeriaService.Please(); service.AcceptOrder(order); service.VerifyAddWasCalledWith(order); service.VerifyReserveIngredientsWasCalledWith(order); }
Le test des appels de méthode n'est pas la seule raison pour laquelle la classe Testable peut être utilisée. Ici, par exemple,
ici notre Dima Pavlov l'utilise pour une refactorisation complexe d'un code hérité.
Testable est capable de sauver la situation dans les cas les plus difficiles. Pour les tests de comportement, cela aide à encapsuler les contrôles d'appels laids dans de belles méthodes.
À ce moment capital, nous avons fini de comprendre la compréhensibilité du test. Il reste à le rendre reproductible et ciblé.
Reproductibilité (extension littérale)
Le modèle d'extension littérale n'est pas directement lié à la reproductibilité, mais il nous y aidera. Notre problème pour le moment est que nous utilisons la date
DateTime.Now
comme date de commande. Si soudainement à partir d'une certaine date, la logique d'acceptation des commandes change, alors dans notre logique métier nous devrons au moins pendant un certain temps supporter 2 logiques d'acceptation des commandes, les séparant en vérifiant comme
if (order.Date > edgeDate)
. Dans ce cas, notre test a une chance de tomber lorsque la date système passe à travers la frontière. Oui, nous allons résoudre ce problème rapidement et même effectuer deux tests: l'un vérifiera la logique avant la date limite et l'autre après. Néanmoins, il est préférable d'éviter de telles situations et de rendre immédiatement toutes les données d'entrée constantes.
"Et où est la DSL?" - demandez-vous. Le fait est qu'il est pratique d'entrer des dates dans les tests via des méthodes d'extension, par exemple
3.May(2019)
. Cette forme d'enregistrement sera compréhensible non seulement pour les développeurs, mais aussi pour les entreprises. Pour ce faire, il suffit de créer une telle classe statique
public static class DateConstructionExtensions { public static DateTime May(this int day, int year) => new DateTime(year, 5, day); }
Naturellement, les dates ne sont pas les seules à utiliser ce modèle. Par exemple, si nous introduisions la quantité d'ingrédients dans la composition des produits, nous pourrions écrire quelque chose comme
42.Grams("flour")
.
Les objets et les dates quantitatifs sont créés de manière pratique grâce aux méthodes d'extension familières.
Focus
Pourquoi est-il important de garder les tests ciblés? Le fait est que les tests ciblés sont plus faciles à maintenir, mais ils doivent toujours être pris en charge. Par exemple, ils doivent être modifiés lors du changement de code et supprimés lors du sciage d'anciennes fonctionnalités. Si les tests ne sont pas ciblés, lors de la modification de la logique, vous devrez comprendre les grands tests et en découper des éléments de la fonctionnalité testée. Si les tests sont ciblés et que leurs noms sont clairs, il vous suffit de supprimer les tests obsolètes et d'en écrire de nouveaux. Si les tests ont une bonne DSL, ce n'est pas du tout un problème.
Donc, après avoir fini d'écrire DSL, nous avons eu l'opportunité de concentrer ce test en le divisant en 2 tests:
[Fact] public void WhenAcceptOrder_AddIsCalled() { var order = Create.Order .Dated(3.May(2019)) .With(Pizza.Pepperoni.CountOf(1).For(500)) .With(Pizza.Margarita.CountOf(1).For(650)) .Please(); var service = Create.PizzeriaService.Please(); service.AcceptOrder(order); service.VerifyAddWasCalledWith(order); } [Fact] public void WhenAcceptOrder_ReserveIngredientsIsCalled() { var order = Create.Order .Dated(3.May(2019)) .With(Pizza.Pepperoni.CountOf(1).For(500)) .With(Pizza.Margarita.CountOf(1).For(650)) .Please(); var service = Create.PizzeriaService.Please(); service.AcceptOrder(order); service.VerifyReserveIngredientsWasCalledWith(order); }
Les deux tests se sont révélés courts, clairs, reproductibles et ciblés.
Veuillez noter que maintenant les noms des tests reflètent le but pour lequel ils ont été écrits et maintenant tout développeur qui est venu dans mon projet comprendra pourquoi chacun des tests a été écrit et ce qui se passe dans ce test.
L'objectif des tests les rend pris en charge. Un bon test doit être ciblé.
Et maintenant, je peux déjà vous entendre me crier: «Yura, qu'est-ce que tu fous? Nous avons écrit un million de code juste pour rendre jolis deux tests? » Oui, exactement. Bien que nous n'ayons que quelques tests, il est logique d'investir dans la DSL et de rendre ces tests compréhensibles. Une fois que vous écrivez DSL, vous obtenez un tas de goodies:
- Il devient facile d'écrire de nouveaux tests. Pas besoin de vous installer pendant 2 heures pour les tests unitaires, il suffit de prendre et d'écrire.
- Les tests deviennent compréhensibles et lisibles. Tout développeur qui regarde le test comprend pourquoi il a été écrit et ce qu'il vérifie.
- Le seuil de participation aux tests (et peut-être dans le domaine) pour les nouveaux développeurs est réduit. Par exemple, via ObjectMother, vous pouvez facilement déterminer quels objets peuvent être créés dans le domaine.
- Enfin, il est tout simplement agréable de travailler avec des tests et, par conséquent, le code est davantage pris en charge.
Des exemples de code source et de tests sont disponibles
ici .