¿Con qué frecuencia ha sucedido que cuando escribes una prueba de unidad de trabajo, miras su código y es ... malo? Y piensas así: "Esta es una prueba, dejémoslo así ...". No,% username%, así que no lo dejes. Las pruebas son una parte importante del sistema que proporciona soporte de código, y es muy importante que esta parte también sea compatible. Desafortunadamente, no tenemos muchas maneras de asegurar esto (no escribiremos pruebas para pruebas), pero todavía hay algunas.

En nuestra escuela de desarrolladores Dodo DevSchool, destacamos, entre otros, los siguientes criterios para una buena prueba:
- reproducibilidad: ejecutar pruebas en el mismo código y entrada siempre conduce al mismo resultado;
- enfoque: debe haber una sola razón para que la prueba caiga;
- comprensibilidad: bueno, aquí está claro. :)
¿Qué le parece esta prueba en términos de estos criterios?
[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); }
Para mí, muy mal.
Es incomprensible: por ejemplo, ni siquiera puedo asignar bloques Organizar, Actuar y Afirmar.
No se puede reproducir: se utiliza la propiedad DateTime.Now. Y finalmente, está desenfocado, porque tiene 2 razones para la caída: se verifican las llamadas a los métodos de dos repositorios.
Además, aunque el nombre de las pruebas está más allá del alcance de este artículo, sigo prestando atención al nombre: con un conjunto de propiedades negativas, es difícil formularlo de tal manera que, al mirar el nombre de la prueba, una persona externa entienda de inmediato por qué esta prueba está generalmente en el proyecto.
Si no puede nombrar de manera concisa la prueba, entonces algo está mal con la prueba.
Como la prueba es incomprensible, le diremos lo que está sucediendo en ella:
- Los ingredientes son creados.
- A partir de los ingredientes, se crean productos (pizzas).
- Se crea un pedido a partir de los productos.
- Se crea un servicio para el que los repositorios están húmedos.
- El pedido se pasa al método AcceptOrder del servicio.
- Se verifica que se han llamado los métodos Add y ReserveIngredients de los respectivos repositorios.
Entonces, ¿cómo podemos mejorar esta prueba? Debe tratar de dejar en el cuerpo de la prueba solo lo que es realmente importante. Y para eso, a las personas inteligentes como Martin Fowler y Rebecca Parsons se les ocurrió
DSL (lenguaje específico de dominio) . Aquí hablaré sobre los patrones DSL que usamos en Dodo para asegurar que nuestras pruebas unitarias sean suaves y sedosas, y los desarrolladores se sientan seguros todos los días.
El plan es este: primero haremos que esta prueba sea comprensible, luego trabajaremos en la reproducibilidad y terminaremos enfocándola. Manejamos ...
Eliminación de ingredientes (objetos de dominio predefinidos)
Comencemos con el bloque de creación de pedidos. Ordenar es una de las entidades de dominio central. Sería genial si pudiéramos describir el orden de tal manera que incluso las personas que no saben cómo escribir código pero entienden la lógica del dominio puedan entender qué tipo de orden estamos creando. Para hacer esto, en primer lugar, debemos abandonar el uso del resumen "Ingrediente1" y "Pizza1" reemplazándolos con ingredientes reales, pizzas y otros objetos de dominio.
El primer candidato para la optimización son los ingredientes. Todo es simple con ellos: no necesitan ninguna personalización, solo una llamada al constructor. Es suficiente llevarlos a un contenedor separado y nombrarlos para que sea claro para los expertos del dominio:
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"); }
En lugar del completamente loco Ingrediente1, Ingrediente2 e Ingrediente3, obtuvimos Masa, Pepperoni y Mozzarella.
Use objetos de dominio predefinidos para entidades de dominio de uso común.
Constructor de productos
La siguiente entidad de dominio son los productos. Todo es un poco más complicado con ellos: cada producto consta de varios ingredientes y tendremos que agregarlos al producto antes de su uso.
Aquí, el buen viejo patrón Builder es útil. Aquí está mi versión de compilación para el producto:
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; } }
Consiste en un constructor parametrizado, un método de
Containing
personalizado y un método de terminal
Please
. Si no le gusta ser amable con el código, puede reemplazar
Please
con
Now
. El constructor oculta constructores complejos y llamadas a métodos que configuran el objeto. El código se vuelve más limpio y más comprensible. En el buen sentido, el constructor debería simplificar la creación del objeto para que el código sea claro para el experto del dominio. Merece especialmente la pena usar un generador para objetos que requieren configuración antes de comenzar a trabajar.
El creador de productos le permitirá crear diseños como:
var pepperoni = new ProductBuilder("Pepperoni") .Containing(Ingredients.Dough) .Containing(Ingredients.Pepperoni) .Please();
Las compilaciones lo ayudan a crear objetos que necesitan personalización. Considere crear un generador incluso si la configuración consta de una línea.
ObjectMother
A pesar de que la creación del producto se ha vuelto mucho más decente, el diseñador del
new ProductBuilder
todavía se ve bastante feo. Arreglalo con el patrón ObjectMother (Padre).
El patrón es simple como 5 kopecks: creamos una clase estática y reunimos a todos los constructores.
public static class Create { public static ProductBuilder Product(string name) => new ProductBuilder(name); }
Ahora puedes escribir así:
var pepperoni = Create.Product("Pepperoni") .Containing(Ingredients.Dough) .Containing(Ingredients.Pepperoni) .Please();
ObjectMother fue inventado para la creación declarativa de objetos. Además, ayuda a introducir nuevos desarrolladores en el dominio, como al escribir la palabra
Create
IDE en sí le dirá lo que puede crear en este dominio.
En nuestro código, ObjectMother a veces se llama No
Create
, pero
Given
. Me gustan las dos opciones. Si tiene otras ideas, comparta en los comentarios.
Para crear objetos declarativamente, use ObjectMother. El código se volverá más limpio y será más fácil para los nuevos desarrolladores profundizar en el dominio.
Retiro del producto
Se ha vuelto mucho mejor, pero los productos aún tienen espacio para crecer. Tenemos un número limitado de productos y, al igual que los ingredientes, se pueden recolectar en una clase separada y no inicializarse para cada prueba:
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(); }
Aquí llamé al contenedor no
Products
, sino
Pizza
. Este nombre ayuda a leer la prueba. Por ejemplo, ayuda a eliminar preguntas como "¿El pepperoni es una pizza o una salchicha?".
Intente usar objetos de dominio reales, no sustitutos como Product1.
El constructor de la orden (ejemplo de la parte posterior)
Ahora aplicamos los patrones descritos para crear un generador de pedidos, pero ahora no vayamos del generador, sino de lo que nos gustaría recibir. Así es como quiero crear un pedido:
var order = Create.Order .Dated(DateTime.Now) .With(Pizza.Pepperoni.CountOf(1).For(500)) .With(Pizza.Margarita.CountOf(1).For(650)) .Please();
¿Cómo podemos lograr esto? Obviamente necesitamos constructores para el pedido y la línea de pedido. Con el constructor para ordenar todo es claro como el cristal. Aquí esta:
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; } }
Pero con
OrderLine
situación es más interesante: en primer lugar, el método Terminal Please no se llama aquí, y en segundo lugar, el acceso al generador no lo proporciona la
Create
estática y no el constructor del generador en sí. Resolveremos el primer problema usando el
implicit operator
y nuestro constructor se verá así:
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); } }
El segundo método nos ayudará a comprender el método de Extensión para la clase de
Product
:
public static class ProductExtensions { public static OrderLineBuilder CountOf(this Product product, decimal count) { return Create.OrderLine.Of(count, product) } }
En general, los métodos de extensión son grandes amigos de DSL. Pueden hacer una descripción declarativa y comprensible de una lógica completamente infernal.
Utiliza métodos de extensión. Solo úsalos. :)
Después de hacer todas estas acciones, obtuvimos el siguiente código de prueba:
[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); }
Aquí hemos tomado el enfoque que llamamos "Hada Hada". Esto es cuando primero escribe el código inactivo como le gustaría verlo, y luego intenta ajustar lo que escribió en DSL. Es muy útil actuar: a veces usted mismo no puede imaginar de qué es capaz C #.
Imagine que ha llegado un hada mágica y le permitió escribir el código que desea, y luego tratar de envolver todo lo escrito en DSL.
Crear un servicio (patrón comprobable)
Con el pedido ahora todo está más o menos mal. Ha llegado el momento de lidiar con los mokas de los repositorios. Vale la pena decir aquí que la prueba en sí, que estamos considerando, es una prueba de comportamiento. Las pruebas de comportamiento están fuertemente asociadas con la implementación de métodos, y si es posible no escribir tales pruebas, entonces es mejor no hacerlo. Sin embargo, a veces son útiles y, a veces, no puedes prescindir de ellos. La siguiente técnica ayuda a escribir exactamente las pruebas de comportamiento, y si de repente te das cuenta de que quieres usarla, primero piensa si puedes reescribir las pruebas de tal manera que verifiquen el estado, no el comportamiento.
Por lo tanto, quiero asegurarme de que en mi método de prueba no haya un solo mok. Para hacer esto, crearé un contenedor para
PizzeriaService
en el que encapsularé toda la lógica que verifica las llamadas a métodos:
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); } }
Esta clase nos permitirá verificar las llamadas a métodos, pero aún necesitamos crearla de alguna manera. Para hacer esto, utilizaremos el generador que ya conocemos:
public class PizzeriaServiceBuilder { public PizzeriaServiceTestable Please() { var orderRepositoryMock = new Mock<IOrderRepository>(); var ingredientsRepositoryMock = new Mock<IIngredientRepository>(); return new PizzeriaServiceTestable(orderRepositoryMock, ingredientsRepositoryMock); } }
Por el momento, nuestro método de prueba se ve así:
[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); }
Las llamadas a métodos de prueba no son la única razón por la que se puede usar la clase Testable. Aquí, por ejemplo,
aquí nuestro Dima Pavlov lo usa para la refactorización compleja de un código heredado.
Testable es capaz de salvar la situación en los casos más difíciles. Para las pruebas de comportamiento, es útil incluir comprobaciones de llamadas feas en métodos hermosos.
En este momento trascendental, terminamos de comprender la comprensibilidad de la prueba. Queda por hacer que sea reproducible y enfocado.
Reproducibilidad (extensión literal)
El patrón de extensión literal no está directamente relacionado con la reproducibilidad, pero nos ayudará con él. Nuestro problema en este momento es que usamos
DateTime.Now
date como la fecha de pedido. Si de repente comienza a partir de alguna fecha, la lógica de aceptación del pedido cambia, entonces en nuestra lógica de negocios tendremos que admitir al menos por un tiempo 2 lógicas de aceptación del pedido, separándolas marcando como
if (order.Date > edgeDate)
. En este caso, nuestra prueba tiene la posibilidad de caer cuando la fecha del sistema pasa a través del límite. Sí, solucionaremos esto rápidamente, e incluso haremos dos de una prueba: una verificará la lógica antes de la fecha límite y la otra después. Sin embargo, es mejor evitar tales situaciones e inmediatamente hacer que todos los datos de entrada sean constantes.
"¿Y dónde está el DSL?" - usted pregunta El hecho es que es conveniente ingresar fechas en las pruebas a través de métodos de Extensión, por ejemplo, 3 de
3.May(2019)
. Esta forma de grabación será comprensible no solo para los desarrolladores, sino también para las empresas. Para hacer esto, solo cree una clase estática
public static class DateConstructionExtensions { public static DateTime May(this int day, int year) => new DateTime(year, 5, day); }
Naturalmente, las fechas no son las únicas cosas para usar este patrón. Por ejemplo, si introdujimos la cantidad de ingredientes en la composición de los productos, podríamos escribir algo así como
42.Grams("flour")
.
Los objetos cuantitativos y las fechas se crean convenientemente a través de los métodos de extensión familiares.
Enfoque
¿Por qué es importante mantener enfocadas las pruebas? El hecho es que las pruebas enfocadas son más fáciles de mantener, pero aún deben ser compatibles. Por ejemplo, deben cambiarse al cambiar el código y eliminarse al cortar características antiguas. Si las pruebas no están enfocadas, entonces cuando cambie la lógica, necesitará comprender las pruebas grandes y eliminar partes de la funcionalidad probada de ellas. Si las pruebas están enfocadas y sus nombres son claros, entonces solo necesita eliminar las pruebas obsoletas y escribir otras nuevas. Si las pruebas tienen un buen DSL, entonces esto no es un problema en absoluto.
Entonces, después de terminar de escribir DSL, tuvimos la oportunidad de enfocar esta prueba dividiéndola en 2 pruebas:
[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); }
Ambas pruebas resultaron ser cortas, claras, reproducibles y enfocadas.
Tenga en cuenta que ahora los nombres de las pruebas reflejan el propósito para el que fueron escritos y ahora cualquier desarrollador que participó en mi proyecto comprenderá por qué cada una de las pruebas fue escrita y qué sucede en esta prueba.
El enfoque de las pruebas los hace compatibles. Una buena prueba debe centrarse.
Y ahora, ya puedo oírte gritarme: “Yura, ¿qué mierda eres? ¿Escribimos un millón de códigos solo para hacer un par de pruebas bonitas? Si exactamente. Si bien solo tenemos un par de pruebas, tiene sentido invertir en DSL y hacer que estas pruebas sean comprensibles. Una vez que escribes DSL, obtienes un montón de cosas:
- Se vuelve fácil escribir nuevas pruebas. No es necesario que se configure durante 2 horas para la prueba de la unidad, solo tome y escriba.
- Las pruebas se vuelven comprensibles y legibles. Cualquier desarrollador que vea la prueba comprende por qué fue escrita y qué verifica.
- Se reduce el umbral para unir pruebas (y quizás en el dominio) para nuevos desarrolladores. Por ejemplo, a través de ObjectMother, puede averiguar fácilmente qué objetos se pueden crear en el dominio.
- Y finalmente, es agradable trabajar con pruebas y, como resultado, el código se vuelve más compatible.
El código fuente de muestra y las pruebas están disponibles
aquí .