📜 ⬆️ ⬇️

Magic Fairy for unit tests: DSL in C #

How often did it happen that when you write a working unit test, you look at its code, and is it ... bad? And you think this: "This is a test, I will leave it like this ...". No,% username%, so do not leave. Tests are a significant part of the system that ensures maintainability of the code, and it is very important that this part is also supported. Unfortunately, there are not so many ways to ensure this (we will not write tests for tests), but we still have a couple.


In our “Dodo DevSchool” development school, we highlight, among other things, the following good test criteria:


How do you like such a test in terms of these criteria?
')
[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); } 

For me - very bad.

It is incomprehensible: for example, I cannot even single out the Arrange, Act and Assert blocks.

Unplayable: using DateTime.Now property. And finally, he is unfocused, because has 2 reasons for crashing: calls to methods of two repositories are checked.

In addition, although the naming of tests is beyond the scope of this article, I still draw attention to the name: with such a set of negative properties, it is difficult to formulate it so that when looking at the name of the test, the outsider will immediately understand why this test is in the project.
If it is not possible to concisely name the test, then something is wrong with the test.
Since the test is incomprehensible, let's tell what is happening in it:

  1. Ingredients are created.
  2. From the ingredients are the products (pizza).
  3. An order is created from the products.
  4. A service is created for which repositories are cleaned.
  5. The order is passed to the service AcceptOrder method.
  6. It is verified that the Add and ReserveIngredients methods of the corresponding repositories were called.

So how do we make this test better? You need to try to leave in the body of the test only what is really important. And for this, smart people like Martin Fowler and Rebecca Parsons came up with DSL (Domain Specific Language) . Here I will talk about the DSL patterns that we use in Dodo so that our unit tests are soft and silky, and the developers feel confident every day.

The plan is this: first, we will make this test understandable, then we will work on reproducibility and end up by making it focused. They drove ...

Extending ingredients (predefined domain objects)


Let's start with the order creation block. An order is one of the central domain entities. It would be cool if we could describe the order so that even people who can’t write code but understand the domain logic can understand what order we are creating. For this, first of all, we need to abandon the use of abstract Ingredient1 and Pizza1, replacing them with real ingredients, pizzas and other domain objects.

The first candidate for optimization is the ingredients. Everything is simple with them: they do not need any customization, just a call to the constructor. It is enough to put them in a separate container and call them so that the domain experts can understand:

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

Instead of completely insane Ingredient1, Ingredient2 and Ingredient3 we got Dough, Pepperoni and Mozzarella.
Use domain object predefined for frequently used domain entities.

Builder for products


The next domain entity is products. Everything is a little more complicated with them: each product consists of several ingredients and we have to add them to the product before use.

Here we can use the good old Pattern Builder. Here’s what my product version builder looks like:

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

It consists of a parameterized constructor, a customizing method of Containing and a terminal method Please . If you do not like to be kind to the code, then you can replace Please with Now . The builder hides complex constructors and method calls that customize the object. The code becomes cleaner and clearer. In a good way, the builder should simplify the creation of the object so that the code is understandable to the domain expert. Especially it is worth using a builder for objects that require configuration before starting work.

The product builder will allow you to create structures like:

 var pepperoni = new ProductBuilder("Pepperoni") .Containing(Ingredients.Dough) .Containing(Ingredients.Pepperoni) .Please(); 

Builders help create objects that need customization. Consider creating a builder even if the setting consists of a single line.

ObjectMother


Despite the fact that product creation has become much more decent, the designer of new ProductBuilder still looks pretty ugly. Fix it with the ObjectMother (Father) pattern.

The pattern is as simple as 5 kopecks: we create a static class and collect all the builders in it.

 public static class Create { public static ProductBuilder Product(string name) => new ProductBuilder(name); } 

Now you can write like this:

 var pepperoni = Create.Product("Pepperoni") .Containing(Ingredients.Dough) .Containing(Ingredients.Pepperoni) .Please(); 

ObjectMother is coined for the declarative creation of objects. In addition, it helps to introduce new developers to the domain, since when writing the word Create IDE itself will prompt what can be created in this domain.

In our code, ObjectMother is sometimes called not Create , but Given . I like both options. If you have any other ideas - share in the comments.
For declarative creation of objects use ObjectMother. The code will be cleaner, and it will be easier for new developers to delve into the domain.

Delivering Products


It has become much better, but products still have room to grow. We have a limited number of products and we can, like ingredients, assemble them in a separate class and not initialize for each 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(); } 

Here I called the container not Products , but Pizza . This name helps to read the test. For example, it helps to remove questions like “Is Pepperoni a pizza or sausage?”.
Try to use real domain objects, not substitutes like Product1.

Order Builder (back example)


Now apply the described patterns to create an order builder, but now let's go not from the builder, but from what we would like to receive. That's how I want to create an order:

 var order = Create.Order .Dated(DateTime.Now) .With(Pizza.Pepperoni.CountOf(1).For(500)) .With(Pizza.Margarita.CountOf(1).For(650)) .Please(); 

How can we achieve this? We obviously need build builders for the order and the order line. With the order builder, everything is crystal clear. Here he is:

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

But with OrderLine situation is more interesting: firstly, the terminal method Please is not called here, and secondly, the access to the builder is not provided by the static Create or the constructor of the builder itself. We will solve the first problem with the help of an implicit operator and our builder will look like this:

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

With the second, the Extension method for the Product class will help us to understand:

 public static class ProductExtensions { public static OrderLineBuilder CountOf(this Product product, decimal count) { return Create.OrderLine.Of(count, product) } } 

In general, Extension methods are great friends of DSL. They can make a declarative, clear description from completely hellish logic.
Use extension methods. Just use them. :)
Having done all these actions, we received the following test code:

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

Here we applied the approach that we call the "Magic Fairy." This is when you first write inoperative code as you would like to see it, and then you try to wrap what you wrote in DSL. It is very useful to act this way - sometimes you yourself have no idea what C # is capable of.
Imagine that a magic fairy flew in and allowed you to write the code as you like, and then try to wrap the writing in DSL.

Creating a service (Testable pattern)


With the order, now everything is more or less good. It is time to deal with the mocks of the repositories. It is worth saying here that the test we are examining in itself is a test of behavior. Behavior tests are strongly associated with the implementation of methods, and if there is an opportunity not to write such tests, then it is better not to do this. However, sometimes they are useful, and at times, they can not do without them. The following technique helps to write tests for behavior, and if you suddenly realize that you want to use it, then first think about whether it is impossible to rewrite the tests in such a way that they check the state, and not the behavior.

So, I want to make sure that in my test method there is not a single mock. To do this, I will create a wrapper for the PizzeriaService , in which I encapsulate all the logic that checks the method calls:

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

This class will allow us to check method calls, but we still need to create it somehow. To do this, use the builder we already know:

 public class PizzeriaServiceBuilder { public PizzeriaServiceTestable Please() { var orderRepositoryMock = new Mock<IOrderRepository>(); var ingredientsRepositoryMock = new Mock<IIngredientRepository>(); return new PizzeriaServiceTestable(orderRepositoryMock, ingredientsRepositoryMock); } } 

At the moment, our test method looks like this:

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

Checking method calls is not the only thing for which the Testable class can be used. For example, here our Dima Pavlov uses it for complex refactoring of legacy code.
Testable is able to save the situation in the most difficult cases. For behavioral tests, it helps to wrap ugly call checks into beautiful methods.
At this momentous moment we finished to understand the clarity of the test. It remains to make it reproducible and focused.

Reproducibility (Literal Extension)


The Literal Extension pattern is not directly related to reproducibility, but it will help us with it. Our problem at the moment is that we use DateTime.Now as the date of the order. If suddenly, starting from a certain date, the logic of receiving an order changes, then in our business logic we will have at least some time to maintain 2 order acceptance logic, sharing them with a check like if (order.Date > edgeDate) . In this case, our test has a chance to fall when passing the system date across the boundary. Yes, we will fix it quickly, and even make two tests out of one: one will check the logic before the boundary date, and the other after. Nevertheless, it is better to avoid such situations and immediately make all input data constant.

"And here DSL?" - you ask. The fact is that dates in tests are conveniently entered through Extension-methods, for example 3.May(2019) . This form of recording will be clear not only to developers, but also to business. To do this, just create such a static class.

 public static class DateConstructionExtensions { public static DateTime May(this int day, int year) => new DateTime(year, 5, day); } 

Naturally, dates are not the only thing for which this pattern can be used. For example, if we entered the number of ingredients in the composition of the products, we could write something like 42.Grams("flour") .
It is convenient to create quantitative objects and dates through the already familiar extension-methods.

Focus


Why is it important to keep tests focused? The fact is that focused tests are easier to maintain, and it’s still necessary to support them. For example, they have to be changed when changing the code and deleted when cutting out old features. If the tests are not focused, then when the logic changes, it will be necessary to sort out large tests and cut pieces of the checked functionality from them. If the tests are focused and their names are clear, then you just need to delete the obsolete tests and write new ones. If there is a good DSL in the tests, then this is not a problem at all.

So, after we finished writing DSL, we had the opportunity to make this test focused by dividing it into 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); } 

Both tests were short, clear, reproducible and focused.

Please note that now the names of the tests reflect the purpose for which they were written and now any developer who came to my project will understand why each of the tests was written and what happens in this test.
The focus of the tests makes them supported. A good test must be focused.
And now, I already hear you shouting to me, “Yura, have you been fucking? We wrote a million code just to make a couple of tests pretty? ”. Yes exactly. While we have only a couple of tests, it makes sense to invest in DSL and make these tests understandable. Once you write a DSL, you get a bunch of buns:


Sample source code and tests are available here .

Source: https://habr.com/ru/post/451598/


All Articles