El artículo “Responsabilidad deslizante del patrón de repositorio” planteó varias preguntas, que son muy difíciles de responder. ¿Necesitamos un repositorio si es imposible ignorar por completo los detalles técnicos? ¿Qué tan complejo debe ser el depósito para que su adición pueda considerarse valiosa? La respuesta a estas preguntas varía según el énfasis puesto en el desarrollo de los sistemas. Probablemente la pregunta más difícil es la siguiente:¿necesita siquiera un repositorio? El problema de la “abstracción fluida” y la creciente complejidad de la codificación con un aumento en el nivel de abstracción no permiten encontrar una solución que satisfaga a ambos lados de la valla. Por ejemplo, en la generación de informes, el diseño de intenciones conduce a la creación de una gran cantidad de métodos para cada filtro y clasificación, y una solución genérica genera una gran sobrecarga de codificación.
Para tener una imagen completa, analicé el problema de las abstracciones en términos de su aplicación en un código heredado. Un repositorio, en este caso, nos interesa únicamente como herramienta para obtener código de calidad y sin errores. Por supuesto, este patrón no es lo único necesario para la aplicación de las prácticas de TDD. Habiendo comido un montón de sal durante el desarrollo de varios proyectos grandes y observando lo que funciona y lo que no, desarrollé algunas reglas para mí que me ayudan a seguir las prácticas de TDD. Estoy abierto a críticas constructivas y otros métodos de implementación de TDD.
Prólogo
Algunos pueden notar que no es posible aplicar TDD en un proyecto antiguo. Existe la opinión de que diferentes tipos de pruebas de integración (pruebas de interfaz de usuario, de extremo a extremo) son más adecuadas para ellos porque es demasiado difícil de entender el código antiguo. Además, puede escuchar que escribir pruebas antes de la codificación real solo genera una pérdida de tiempo, porque es posible que no sepamos cómo funcionará el código. Tuve que trabajar en varios proyectos, donde estaba limitado solo a pruebas de integración, creyendo que las pruebas unitarias no son indicativas. Al mismo tiempo, se escribieron muchas pruebas, ejecutaron muchos servicios, etc. Como resultado, solo una persona pudo entenderlas, quien, de hecho, las escribió.
Durante mi práctica, logré trabajar en varios proyectos muy grandes, donde había mucho código heredado. Algunos de ellos presentaban pruebas y otros no (solo había una intención de implementarlos). Participé en dos grandes proyectos, en los que de alguna manera intenté aplicar el enfoque TDD. En la etapa inicial, TDD se percibió como un desarrollo Test First. Eventualmente, las diferencias entre esta comprensión simplificada y la percepción actual, denominada brevemente BDD, se hicieron más claras. Cualquiera que sea el idioma que se utilice, los puntos principales, a los que yo llamo reglas, siguen siendo similares. Alguien puede encontrar paralelismos entre las reglas y otros principios para escribir un buen código.
Regla 1:Uso de abajo hacia arriba (de adentro hacia afuera)
Esta regla se refiere más bien al método de análisis y diseño de software cuando se incorporan nuevas piezas de código en un proyecto de trabajo.
Cuando está diseñando un nuevo proyecto, es absolutamente natural imaginar un sistema completo. En esta etapa, usted controla tanto el conjunto de componentes como la futura flexibilidad de la arquitectura. Por lo tanto, puede escribir módulos que se pueden integrar entre sí de manera fácil e intuitiva. Este enfoque de arriba hacia abajo le permite realizar un buen diseño inicial de la arquitectura futura, describir las líneas guía necesarias y tener una imagen completa de lo que, al final, desea. Después de un tiempo, el proyecto se convierte en lo que se llama el código heredado. Y luego comienza la diversión.
En la etapa en la que es necesario integrar una nueva funcionalidad en un proyecto existente con un montón de módulos y dependencias entre ellos, puede ser muy difícil ponerlos todos en tu cabeza para hacer el diseño adecuado. El otro lado de este problema es la cantidad de trabajo requerido para realizar esta tarea. Por lo tanto, el enfoque de abajo hacia arriba será más efectivo en este caso. En otras palabras, primero crea un módulo completo que resuelve la tarea necesaria y luego lo construye en el sistema existente, haciendo solo los cambios necesarios. En este caso, puede garantizar la calidad de este módulo, ya que es una unidad completa del funcional.
Cabe señalar que no es tan simple con los enfoques. Por ejemplo, al diseñar una nueva funcionalidad en un sistema antiguo, le guste o no, utilizará ambos enfoques. Durante el análisis inicial, aún necesita evaluar el sistema, luego bajarlo al nivel del módulo, implementarlo y luego volver al nivel de todo el sistema. En mi opinión, lo principal aquí es no olvidar que el nuevo módulo debe ser una funcionalidad completa y ser independiente, como una herramienta separada. Cuanto más estrictamente se adhiera a este enfoque, menos cambios se realizarán en el código anterior.
Regla 2:Probar solo el código modificado
Cuando se trabaja con un proyecto antiguo, no hay absolutamente ninguna necesidad de escribir pruebas para todos los escenarios posibles del método/clase. Además, es posible que no esté al tanto de algunos escenarios, ya que puede haber muchos de ellos. El proyecto ya está en producción, el cliente está satisfecho, así que puedes estar tranquilo. En general, solo sus cambios causan problemas en este sistema. Por lo tanto, solo ellos deben ser probados.
Ejemplo
Hay un módulo de tienda en línea, que crea un carrito de artículos seleccionados y lo almacena en una base de datos. No nos importa la implementación específica. Hecho como hecho:este es el código heredado. Ahora necesitamos introducir un nuevo comportamiento aquí:enviar una notificación al departamento de contabilidad en caso de que el costo del carrito exceda los $1000. Aquí está el código que vemos. ¿Cómo introducir el cambio?
public class EuropeShop : Shop { public override void CreateSale() { var items = LoadSelectedItemsFromDb(); var taxes = new EuropeTaxes(); var saleItems = items.Select(item => taxes.ApplyTaxes(item)).ToList(); var cart = new Cart(); cart.Add(saleItems); taxes.ApplyTaxes(cart); SaveToDb(cart); } }
Según la primera regla, los cambios deben ser mínimos y atómicos. No estamos interesados en la carga de datos, no nos importa el cálculo de impuestos y guardarlos en la base de datos. Pero nos interesa el carro calculado. Si hubiera un módulo que hiciera lo que se requiere, entonces realizaría la tarea necesaria. Por eso hacemos esto.
public class EuropeShop : Shop { public override void CreateSale() { var items = LoadSelectedItemsFromDb(); var taxes = new EuropeTaxes(); var saleItems = items.Select(item => taxes.ApplyTaxes(item)).ToList(); var cart = new Cart(); cart.Add(saleItems); taxes.ApplyTaxes(cart); // NEW FEATURE new EuropeShopNotifier().Send(cart); SaveToDb(cart); } }
Dicho notificador funciona por sí solo, se puede probar y los cambios realizados en el código antiguo son mínimos. Esto es exactamente lo que dice la segunda regla.
Regla 3:solo probamos los requisitos
Para liberarse de la cantidad de escenarios que requieren pruebas con pruebas unitarias, piense en lo que realmente necesita de un módulo. Escriba primero el conjunto mínimo de condiciones que puede imaginar como requisitos para el módulo. El conjunto mínimo es el conjunto, que cuando se complementa con uno nuevo, el comportamiento del módulo no cambia mucho, y cuando se elimina, el módulo no funciona. El enfoque BDD ayuda mucho en este caso.
Además, imagina cómo interactuarán con él otras clases que son clientes de tu módulo. ¿Necesita escribir 10 líneas de código para configurar su módulo? Cuanto más simple sea la comunicación entre las partes del sistema, mejor. Por lo tanto, es mejor seleccionar módulos responsables de algo específico del código antiguo. SOLID acudirá en ayuda en este caso.
Ejemplo
Ahora veamos cómo todo lo descrito anteriormente nos ayudará con el código. Primero, seleccione todos los módulos que solo están asociados indirectamente con la creación del carrito. Así se distribuye la responsabilidad de los módulos.
public class EuropeShop : Shop { public override void CreateSale() { // 1) load from DB var items = LoadSelectedItemsFromDb(); // 2) Tax-object creates SaleItem and // 4) goes through items and apply taxes var taxes = new EuropeTaxes(); var saleItems = items.Select(item => taxes.ApplyTaxes(item)).ToList(); // 3) creates a cart and 4) applies taxes var cart = new Cart(); cart.Add(saleItems); taxes.ApplyTaxes(cart); new EuropeShopNotifier().Send(cart); // 4) store to DB SaveToDb(cart); } }
De esta manera se pueden distinguir. Por supuesto, estos cambios no se pueden hacer de inmediato en un sistema grande, pero se pueden hacer gradualmente. Por ejemplo, cuando los cambios se relacionan con un módulo de impuestos, puede simplificar cómo otras partes del sistema dependen de él. Esto puede ayudar a deshacerse de las dependencias altas y usarlo en el futuro como una herramienta independiente.
public class EuropeShop : Shop { public override void CreateSale() { // 1) extracted to a repository var itemsRepository = new ItemsRepository(); var items = itemsRepository.LoadSelectedItems(); // 2) extracted to a mapper var saleItems = items.ConvertToSaleItems(); // 3) still creates a cart var cart = new Cart(); cart.Add(saleItems); // 4) all routines to apply taxes are extracted to the Tax-object new EuropeTaxes().ApplyTaxes(cart); new EuropeShopNotifier().Send(cart); // 5) extracted to a repository itemsRepository.Save(cart); } }
En cuanto a las pruebas, estos escenarios serán suficientes. Hasta el momento, su implementación no nos interesa.
public class EuropeTaxesTests { public void Should_not_fail_for_null() { } public void Should_apply_taxes_to_items() { } public void Should_apply_taxes_to_whole_cart() { } public void Should_apply_taxes_to_whole_cart_and_change_items() { } } public class EuropeShopNotifierTests { public void Should_not_send_when_less_or_equals_to_1000() { } public void Should_send_when_greater_than_1000() { } public void Should_raise_exception_when_cannot_send() { } }
Regla 4:Agregue solo código probado
Como escribí anteriormente, debe minimizar los cambios en el código anterior. Para hacer esto, el código antiguo y el nuevo/modificado se pueden dividir. El nuevo código se puede colocar en métodos que se pueden verificar mediante pruebas unitarias. Este enfoque ayudará a reducir los riesgos asociados. Hay dos técnicas que se han descrito en el libro "Trabajar de manera efectiva con el código heredado" (enlace al libro a continuación).
Método/clase Sprout:esta técnica le permite incrustar un nuevo código muy seguro en uno anterior. La forma en que agregué el notificador es un ejemplo de este enfoque.
Método Wrap:un poco más complicado, pero la esencia es la misma. No siempre funciona, pero solo en los casos en que se llama a un nuevo código antes o después de uno antiguo. Al asignar responsabilidades, dos llamadas del método ApplyTaxes fueron reemplazadas por una llamada. Para esto fue necesario cambiar el segundo método para que la lógica no se rompa mucho y se pudiera comprobar. Así se veía la clase antes de los cambios.
public class EuropeTaxes : Taxes { internal override SaleItem ApplyTaxes(Item item) { var saleItem = new SaleItem(item) { SalePrice = item.Price*1.2m }; return saleItem; } internal override void ApplyTaxes(Cart cart) { if (cart.TotalSalePrice <= 300m) return; var exclusion = 30m/cart.SaleItems.Count; foreach (var item in cart.SaleItems) if (item.SalePrice - exclusion > 100m) item.SalePrice -= exclusion; } }
Y aquí cómo se ve después. La lógica de trabajar con los elementos del carro cambió un poco, pero en general, todo siguió igual. En este caso, el método anterior llama primero a un nuevo ApplyToItems y luego a su versión anterior. Esta es la esencia de esta técnica.
public class EuropeTaxes : Taxes { internal override void ApplyTaxes(Cart cart) { ApplyToItems(cart); ApplyToCart(cart); } private void ApplyToItems(Cart cart) { foreach (var item in cart.SaleItems) item.SalePrice = item.Price*1.2m; } private void ApplyToCart(Cart cart) { if (cart.TotalSalePrice <= 300m) return; var exclusion = 30m / cart.SaleItems.Count; foreach (var item in cart.SaleItems) if (item.SalePrice - exclusion > 100m) item.SalePrice -= exclusion; } }
Regla 5:"Romper" dependencias ocultas
Esta es la regla sobre el mayor mal en un código antiguo:el uso del nuevo operador dentro del método de un objeto para crear otros objetos, repositorios u otros objetos complejos. ¿Por qué es eso malo? La explicación más simple es que esto hace que las partes del sistema estén altamente conectadas y ayuda a reducir su coherencia. Aún más corto:conduce a la violación del principio de "bajo acoplamiento, alta cohesión". Si observa el otro lado, entonces este código es demasiado difícil de extraer en una herramienta separada e independiente. Deshacerse de tales dependencias ocultas a la vez es muy laborioso. Pero esto se puede hacer gradualmente.
Primero, debe transferir la inicialización de todas las dependencias al constructor. En particular, esto se aplica a la nueva operadores y la creación de clases. Si tiene ServiceLocator para obtener instancias de clases, también debe eliminarlo del constructor, donde puede extraer todas las interfaces necesarias.
En segundo lugar, las variables que almacenan la instancia de un objeto/repositorio externo deben tener un tipo abstracto y, mejor aún, una interfaz. La interfaz es mejor porque proporciona más capacidades a un desarrollador. Como resultado, esto permitirá hacer una herramienta atómica a partir de un módulo.
En tercer lugar, no deje grandes hojas de métodos. Esto muestra claramente que el método hace más de lo que se especifica en su nombre. También es indicativo de una posible violación de SOLID, la Ley de Deméter.
Ejemplo
Ahora veamos cómo se ha cambiado el código que crea el carrito. Solo el bloque de código que crea el carrito permaneció sin cambios. El resto se colocó en clases externas y puede ser sustituido por cualquier implementación. Ahora la clase EuropeShop toma la forma de una herramienta atómica que necesita ciertas cosas que están explícitamente representadas en el constructor. El código se vuelve más fácil de percibir.
public class EuropeShop : Shop { private readonly IItemsRepository _itemsRepository; private readonly Taxes.Taxes _europeTaxes; private readonly INotifier _europeShopNotifier; public EuropeShop() { _itemsRepository = new ItemsRepository(); _europeTaxes = new EuropeTaxes(); _europeShopNotifier = new EuropeShopNotifier(); } public override void CreateSale() { var items = _itemsRepository.LoadSelectedItems(); var saleItems = items.ConvertToSaleItems(); var cart = new Cart(); cart.Add(saleItems); _europeTaxes.ApplyTaxes(cart); _europeShopNotifier.Send(cart); _itemsRepository.Save(cart); } }SCRIPT
Regla 6:Cuantas menos pruebas grandes, mejor
Las pruebas grandes son diferentes pruebas de integración que intentan probar los scripts de los usuarios. Sin duda, son importantes, pero comprobar la lógica de algún SI en la profundidad del código es muy caro. Escribir esta prueba lleva la misma cantidad de tiempo, si no más, que escribir la funcionalidad en sí. Apoyarlos es como otro código heredado, que es difícil de cambiar. ¡Pero estas son solo pruebas!
Es necesario comprender qué pruebas se necesitan y adherirse claramente a este entendimiento. Si necesita una verificación de integración, escriba un conjunto mínimo de pruebas, incluidos escenarios de interacción positivos y negativos. Si necesita probar el algoritmo, escriba un conjunto mínimo de pruebas unitarias.
Regla 7:No pruebes métodos privados
Un método privado puede ser demasiado complejo o contener código que no se llama desde métodos públicos. Estoy seguro de que cualquier otra razón que se te ocurra resultará ser una característica de un código o diseño “malo”. Lo más probable es que una parte del código del método privado se convierta en un método/clase independiente. Compruebe si se viola el primer principio de SOLID. Esta es la primera razón por la que no vale la pena hacerlo. La segunda es que de esta forma no compruebas el comportamiento de todo el módulo, sino cómo lo implementa el módulo. La implementación interna puede cambiar independientemente del comportamiento del módulo. Por lo tanto, en este caso, obtiene pruebas frágiles y lleva más tiempo del necesario respaldarlas.
Para evitar la necesidad de probar métodos privados, presente sus clases como un conjunto de herramientas atómicas y no sepa cómo se implementan. Esperas algún comportamiento que estás probando. Esta actitud también se aplica a las clases en el contexto de la asamblea. Las clases que están disponibles para los clientes (de otras asambleas) serán públicas, y aquellas que realizan trabajo interno, privadas. Aunque, hay una diferencia de métodos. Las clases internas pueden ser complejas, por lo que pueden transformarse en internas y también probarse.
Ejemplo
Por ejemplo, para probar una condición en el método privado de la clase EuropeTaxes, no escribiré una prueba para este método. Espero que los impuestos se apliquen de cierta manera, por lo que la prueba reflejará este mismo comportamiento. En la prueba, conté manualmente cuál debería ser el resultado, lo tomé como estándar y esperé el mismo resultado de la clase.
public class EuropeTaxes : Taxes { // code skipped private void ApplyToCart(Cart cart) { if (cart.TotalSalePrice <= 300m) return; // <<< I WANT TO TEST THIS CONDIFTION var exclusion = 30m / cart.SaleItems.Count; foreach (var item in cart.SaleItems) if (item.SalePrice - exclusion > 100m) item.SalePrice -= exclusion; } } // test suite public class EuropeTaxesTests { // code skipped [Fact] public void Should_apply_taxes_to_cart_greater_300() { #region arrange // list of items which will create a cart greater 300 var saleItems = new List<Item>(new[]{new Item {Price = 83.34m}, new Item {Price = 83.34m},new Item {Price = 83.34m}}) .ConvertToSaleItems(); var cart = new Cart(); cart.Add(saleItems); const decimal expected = 83.34m*3*1.2m; #endregion // act new EuropeTaxes().ApplyTaxes(cart); // assert Assert.Equal(expected, cart.TotalSalePrice); } }
Regla 8:No pruebe el algoritmo de métodos
Algunas personas verifican el número de llamadas de ciertos métodos, verifican la llamada en sí, etc., en otras palabras, verifican el trabajo interno de los métodos. Es tan malo como probar los privados. La diferencia está solo en la capa de aplicación de dicho control. Nuevamente, este enfoque brinda muchas pruebas frágiles, por lo que algunas personas no toman TDD correctamente.
Leer más…
Regla 9:No modifique el código heredado sin pruebas
Esta es la regla más importante porque refleja el deseo del equipo de seguir este camino. Sin el deseo de avanzar en esta dirección, todo lo que se ha dicho anteriormente no tiene un significado especial. Porque si un desarrollador no quiere usar TDD (no entiende su significado, no ve los beneficios, etc.), entonces su beneficio real se verá desdibujado por la discusión constante sobre lo difícil e ineficiente que es.
Si va a utilizar TDD, discútalo con su equipo, agréguelo a la Definición de Listo y aplíquelo. Al principio será duro, como todo lo nuevo. Como cualquier arte, TDD requiere práctica constante y el placer viene a medida que aprendes. Gradualmente, habrá más pruebas unitarias escritas, comenzará a sentir la "salud" de su sistema y comenzará a apreciar la simplicidad de escribir código, describiendo los requisitos en la primera etapa. Hay estudios de TDD realizados en grandes proyectos reales en Microsoft e IBM, que muestran una reducción de errores en los sistemas de producción del 40 % al 80 % (consulte los enlaces a continuación).
Lecturas adicionales
- Libro "Trabajar de manera eficaz con el código heredado" de Michael Feathers
- TDD cuando estás hasta el cuello en Legacy Code
- Romper dependencias ocultas
- El ciclo de vida del código heredado
- ¿Debería realizar pruebas unitarias de métodos privados en una clase?
- Interiores de pruebas unitarias
- 5 conceptos erróneos comunes sobre TDD y pruebas unitarias
- Ley de Deméter