Escapando del matorral de pruebas: construyendo un atajo de un accesorio a una aserción


En este artículo, me gustaría proponer una alternativa al estilo de diseño de prueba tradicional utilizando conceptos de programación funcional en Scala. Este enfoque se inspiró en muchos meses de dolor por mantener docenas de exámenes fallidos y un deseo ardiente de hacerlos más directos y más comprensibles.


Aunque el código está en Scala, las ideas propuestas son apropiadas para desarrolladores e ingenieros de control de calidad que usan lenguajes que admiten programación funcional. Puede encontrar un enlace de Github con la solución completa y un ejemplo al final del artículo.


El problema


Si alguna vez tuvo que lidiar con pruebas (no importa cuáles: pruebas unitarias, integrales o funcionales), probablemente se escribieron como un conjunto de instrucciones secuenciales. Por ejemplo:


// The following tests describe a simple internet store. // Depending on their role, bonus amount and the order's // subtotal, users may receive a discount of some size. "If user's role is 'customer'" - { import TestHelper._ "And if subtotal < 250 after bonuses - no discount" in { val db: Database = Database.forURL(TestConfig.generateNewUrl()) migrateDb(db) insertUser(db, id = 1, name = "test", role = "customer") insertPackage(db, id = 1, name = "test", userId = 1, status = "new") insertPackageItems(db, id = 1, packageId = 1, name = "test", price = 30) insertPackageItems(db, id = 2, packageId = 1, name = "test", price = 20) insertPackageItems(db, id = 3, packageId = 1, name = "test", price = 40) val svc = new SomeProductionLogic(db) val result = svc.calculatePrice(packageId = 1) result shouldBe 90 } "And if subtotal >= 250 after bonuses - 10% off" in { val db: Database = Database.forURL(TestConfig.generateNewUrl()) migrateDb(db) insertUser(db, id = 1, name = "test", role = "customer") insertPackage(db, id = 1, name = "test", userId = 1, status = "new") insertPackageItems(db, id = 1, packageId = 1, name = "test", price = 100) insertPackageItems(db, id = 2, packageId = 1, name = "test", price = 120) insertPackageItems(db, id = 3, packageId = 1, name = "test", price = 130) insertBonus(db, id = 1, packageId = 1, bonusAmount = 40) val svc = new SomeProductionLogic(db) val result = svc.calculatePrice(packageId = 1) result shouldBe 279 } } "If user's role is 'vip'" - {/*...*/} 

En mi experiencia, esta forma de escribir pruebas es preferida por la mayoría de los desarrolladores. Nuestro proyecto tiene alrededor de mil pruebas en diferentes niveles de aislamiento, y todas fueron escritas con este estilo hasta hace poco. A medida que el proyecto creció, comenzamos a notar problemas graves y retrasos en el mantenimiento de tales pruebas: solucionarlos tomaría al menos la misma cantidad de tiempo que escribir el código de producción.


Al escribir nuevas pruebas, siempre teníamos que encontrar formas de preparar los datos desde cero, generalmente copiando y pegando los pasos de las pruebas vecinas. Como resultado, cuando el modelo de datos de la aplicación cambiara, el castillo de naipes se derrumbaría y tendríamos que reparar cada prueba fallida: en el peor de los casos, profundizando en cada prueba y reescribiéndola.


Cuando una prueba fallaba "honestamente", es decir, debido a un error real en la lógica empresarial, era imposible comprender qué salió mal sin depurar. Debido a que las pruebas eran tan difíciles de entender, nadie tenía el conocimiento completo sobre cómo se supone que debe comportarse el sistema.


Todo este dolor, en mi opinión, es un síntoma de los dos problemas más profundos de este diseño de prueba:


  1. No existe una estructura clara y práctica para las pruebas. Cada prueba es un copo de nieve único. La falta de estructura conduce a la verbosidad, que consume mucho tiempo y desmotiva. Detalles insignificantes distraen de lo que es más importante: el requisito que afirma la prueba. Copiar y pegar se convierte en el enfoque principal para escribir nuevos casos de prueba.
  2. Las pruebas no ayudan a los desarrolladores a localizar defectos; solo indican que hay un problema de algún tipo. Para comprender el estado en el que se ejecuta la prueba, debe trazarla en su cabeza o usar un depurador.

Modelado


¿Podemos hacerlo mejor? (Alerta de spoiler: podemos). Consideremos qué tipo de estructura puede tener esta prueba.


 val db: Database = Database.forURL(TestConfig.generateNewUrl()) migrateDb(db) insertUser(db, id = 1, name = "test", role = "customer") insertPackage(db, id = 1, name = "test", userId = 1, status = "new") insertPackageItems(db, id = 1, packageId = 1, name = "test", price = 30) insertPackageItems(db, id = 2, packageId = 1, name = "test", price = 20) insertPackageItems(db, id = 3, packageId = 1, name = "test", price = 40) 

Como regla general, el código bajo prueba espera algunos parámetros explícitos (identificadores, tamaños, cantidades, filtros, por nombrar algunos), así como algunos datos externos (de una base de datos, cola o algún otro servicio del mundo real). Para que nuestra prueba se ejecute de manera confiable, necesita un elemento fijo : un estado para colocar el sistema, los proveedores de datos o ambos.


Con este accesorio, preparamos una dependencia para inicializar el código bajo prueba: llenar una base de datos, crear una cola de un tipo particular, etc.


 val svc = new SomeProductionLogic(db) val result = svc.calculatePrice(packageId = 1) 

Después de ejecutar el código bajo prueba en algunos parámetros de entrada, recibimos una salida , tanto explícita (devuelta por el código bajo prueba) como implícita (los cambios en el estado).


 result shouldBe 90 

Finalmente, verificamos que el resultado es el esperado, terminando la prueba con una o más aserciones .



Se puede concluir que las pruebas generalmente consisten en las mismas etapas: preparación de entrada, ejecución de código y afirmación de resultados. Podemos utilizar este hecho para deshacernos del primer problema de nuestras pruebas , es decir, una forma demasiado liberal, dividiendo explícitamente el cuerpo de una prueba en etapas. Tal idea no es nueva, como se puede ver en las pruebas de estilo BDD ( desarrollo basado en el comportamiento ).


¿Qué pasa con la extensibilidad? Cualquier paso del proceso de prueba puede, a su vez, contener cualquier cantidad de intermedios. Por ejemplo, podríamos dar un paso grande y complicado, como construir un accesorio y dividirlo en varios, encadenados uno tras otro. De esta manera, el proceso de prueba puede ser infinitamente extensible, pero en última instancia siempre consta de los mismos pocos pasos generales.



Ejecutando pruebas


Intentemos implementar la idea de dividir la prueba en etapas, pero primero, debemos determinar qué tipo de resultado nos gustaría ver.


En general, nos gustaría escribir y mantener pruebas para que sea menos laborioso y más agradable. Cuantas menos instrucciones explícitas y no únicas tenga una prueba, menos cambios tendrían que realizarse después de cambiar los contratos o la refactorización, y menos tiempo tomaría leer la prueba. El diseño de la prueba debe promover la reutilización de fragmentos de código comunes y desalentar la copia y el pegado sin sentido. También sería bueno que las pruebas tuvieran una forma unificada. La previsibilidad mejora la legibilidad y ahorra tiempo. Por ejemplo, imagine cuánto tiempo más le tomaría a los aspirantes a científicos aprender todas las fórmulas si los libros de texto los escribieran libremente en un lenguaje común en lugar de las matemáticas.


Por lo tanto, nuestro objetivo es ocultar cualquier cosa que distraiga e innecesaria, dejando solo lo que es de importancia crítica para la comprensión: lo que se está probando, cuáles son las entradas y salidas esperadas.


Volvamos a nuestro modelo de la estructura de la prueba.



Técnicamente, cada paso puede representarse mediante un tipo de datos y cada transición, mediante una función. Es posible pasar del tipo de datos inicial al último aplicando cada función al resultado del anterior. En otras palabras, mediante el uso de la composición de funciones de preparación de datos (llamémosla prepare ), ejecución de código ( execute ) y verificación del resultado esperado ( check ). La entrada para esta composición sería el primer paso: el accesorio. Llamemos a la función de orden superior resultante la función de ciclo de vida de prueba .


Prueba de la función del ciclo de vida
 def runTestCycle[FX, DEP, OUT, F[_]]( fixture: FX, prepare: FX => DEP, execute: DEP => OUT, check: OUT => F[Assertion] ): F[Assertion] = // In Scala instead of writing check(execute(prepare(fixture))) // one can use a more readable version using the andThen function: (prepare andThen execute andThen check) (fixture) 

Surge una pregunta, ¿de dónde vienen estas ciertas funciones? Bueno, en cuanto a la preparación de datos, solo hay una cantidad limitada de formas de hacerlo: llenar una base de datos, burlarse, etc. Por lo tanto, es útil escribir variantes especializadas de la función de prepare compartida en todas las pruebas. Como resultado, sería más fácil realizar funciones de ciclo de vida de prueba especializadas para cada caso, lo que ocultaría implementaciones concretas de preparación de datos. Dado que la ejecución de código y las aserciones son más o menos únicas para cada prueba (o grupo de pruebas), la execute y la check deben escribirse explícitamente cada vez.


Función de ciclo de vida de prueba adaptada para pruebas de integración en una base de datos
 // Sets up the fixture — implemented separately def prepareDatabase[DB](db: Database): DbFixture => DB def testInDb[DB, OUT]( fixture: DbFixture, execute: DB => OUT, check: OUT => Future[Assertion], db: Database = getDatabaseHandleFromSomewhere(), ): Future[Assertion] = runTestCycle(fixture, prepareDatabase(db), execute, check) 

Al delegar todos los matices administrativos a la función del ciclo de vida de la prueba, tenemos la capacidad de extender el proceso de prueba sin tocar ninguna prueba dada. Al utilizar la composición de funciones, podemos interferir en cualquier paso del proceso y extraer o agregar datos.


Para ilustrar mejor las capacidades de este enfoque, resuelvamos el segundo problema de nuestra prueba inicial : la falta de información complementaria para detectar problemas. Agreguemos el registro de cualquier ejecución de código que haya devuelto. Nuestro registro no cambiará el tipo de datos; solo produce un efecto secundario : enviar un mensaje a la consola. Después del efecto secundario, lo devolvemos tal como está.


Pruebe la función del ciclo de vida con el registro
 def logged[T](implicit loggedT: Logged[T]): T => T = (that: T) => { // By passing an instance of the Logged typeclass for T as an argument, // we get an ability to “add” behavior log() to the abstract “that” member. // More on typeclasses later on. loggedT.log(that) // We could even do: that.log() that // The object gets returned unaltered } def runTestCycle[FX, DEP, OUT, F[_]]( fixture: FX, prepare: FX => DEP, execute: DEP => OUT, check: OUT => F[Assertion] )(implicit loggedOut: Logged[OUT]): F[Assertion] = // Insert logged right after receiving the result - after execute() (prepare andThen execute andThen logged andThen check) (fixture) 

Con este simple cambio, hemos agregado el registro de la salida del código ejecutado en cada prueba . La ventaja de funciones tan pequeñas es que son fáciles de entender, componer y eliminar cuando sea necesario.



Como resultado, nuestra prueba ahora se ve así:


 val fixture: SomeMagicalFixture = ??? // Comes from somewhere else def runProductionCode(id: Int): Database => Double = (db: Database) => new SomeProductionLogic(db).calculatePrice(id) def checkResult(expected: Double): Double => Future[Assertion] = (result: Double) => result shouldBe expected // The creation and filling of Database is hidden in testInDb "If user's role is 'customer'" in testInDb( state = fixture, execute = runProductionCode(id = 1), check = checkResult(90) ) 

El cuerpo de la prueba se volvió conciso, el dispositivo y los controles se pueden reutilizar en otras pruebas, y ya no preparamos la base de datos manualmente en ninguna parte. Solo queda un pequeño problema ...


Preparación de accesorios


En el código anterior estábamos trabajando bajo la suposición de que el dispositivo nos sería dado desde algún lugar. Dado que los datos son el ingrediente crítico de las pruebas sencillas y fáciles de mantener, tenemos que tocar cómo hacerlos fácilmente.


Supongamos que nuestra tienda bajo prueba tiene una base de datos relacional típica de tamaño mediano (por simplicidad, en este ejemplo tiene solo 4 tablas, pero en realidad, puede tener cientos). Algunas tablas tienen datos referenciales, algunos datos comerciales, y todo eso puede agruparse lógicamente en una o más entidades complejas. Las relaciones se vinculan con claves externas , para crear un Bonus , se requiere un Package , que a su vez necesita un User , y así sucesivamente.



Las soluciones y los hacks solo conducen a la inconsistencia de los datos y, como resultado, a horas y horas de depuración. Por esta razón, no estamos haciendo cambios en el esquema de ninguna manera.


Podríamos usar algunos métodos de producción para llenarlo, pero incluso bajo un escrutinio superficial, esto plantea muchas preguntas difíciles. ¿Qué preparará los datos en las pruebas para ese código de producción? ¿Tendríamos que reescribir las pruebas si cambia el contrato de ese código? ¿Qué sucede si los datos provienen de otro lugar y no hay métodos para usar? ¿Cuántas solicitudes se necesitarían para crear una entidad que depende de muchas otras?


Base de datos completando la prueba inicial
 insertUser(db, id = 1, name = "test", role = "customer") insertPackage(db, id = 1, name = "test", userId = 1, status = "new") insertPackageItems(db, id = 1, packageId = 1, name = "test", price = 30) insertPackageItems(db, id = 2, packageId = 1, name = "test", price = 20) insertPackageItems(db, id = 3, packageId = 1, name = "test", price = 40) 

Los métodos de ayuda dispersos, como los de nuestro primer ejemplo, son el mismo problema bajo un disfraz diferente. Asumen la responsabilidad de gestionar las dependencias sobre nosotros mismos, lo que estamos tratando de evitar.


Idealmente, nos gustaría tener una estructura de datos que presente todo el estado del sistema de un vistazo. Un candidato adecuado sería una tabla (o un conjunto de datos , como en PHP o Python) que no tendría nada más que campos críticos para la lógica empresarial. Si cambia, mantener las pruebas sería fácil: simplemente cambiamos los campos en el conjunto de datos. Ejemplo:


 val dataTable: Seq[DataRow] = Table( ("Package ID", "Customer's role", "Item prices", "Bonus value", "Expected final price") , (1, "customer", Vector(40, 20, 30) , Vector.empty , 90.0) , (2, "customer", Vector(250) , Vector.empty , 225.0) , (3, "customer", Vector(100, 120, 30) , Vector(40) , 210.0) , (4, "customer", Vector(100, 120, 30, 100) , Vector(20, 20) , 279.0) , (5, "vip" , Vector(100, 120, 30, 100, 50), Vector(10, 20, 10), 252.0) ) 


Desde nuestra tabla, creamos claves - enlaces de entidad por ID. Si una entidad depende de otra, también se crea una clave para esa otra entidad. Puede suceder que dos entidades diferentes creen una dependencia con la misma ID, lo que puede conducir a una violación de la clave principal . Sin embargo, en esta etapa es increíblemente barato deduplicar claves, ya que todo lo que contienen son ID, podemos ponerlas en una colección que nos deduplica, por ejemplo, un Set . Si eso resulta insuficiente, siempre podemos implementar una deduplicación más inteligente como una función separada y componerla en la función de prueba del ciclo de vida.


Claves (ejemplo)
 sealed trait Key case class PackageKey(id: Int, userId: Int) extends Key case class PackageItemKey(id: Int, packageId: Int) extends Key case class UserKey(id: Int) extends Key case class BonusKey(id: Int, packageId: Int) extends Key 

La generación de datos falsos para campos (por ejemplo, nombres) se delega a una clase separada. Luego, al usar esa clase y las reglas de conversión para las claves, obtenemos los objetos Row destinados a la inserción en la base de datos.


Filas (ejemplo)
 object SampleData { def name: String = "test name" def role: String = "customer" def price: Int = 1000 def bonusAmount: Int = 0 def status: String = "new" } sealed trait Row case class PackageRow(id: Int, name: String, userId: Int, status: String) extends Row case class PackageItemRow(id: Int, packageId: Int, name: String, price: Int) extends Row case class UserRow(id: Int, name: String, role: String) extends Row case class BonusRow(id: Int, packageId: Int, bonusAmount: Int) extends Row 

Los datos falsos generalmente no son suficientes, por lo que necesitamos una forma de anular campos específicos. Afortunadamente, las lentes son justo lo que necesitamos: podemos usarlas para iterar sobre todas las filas creadas y cambiar solo los campos que necesitamos. Dado que las lentes son funciones disfrazadas, podemos componerlas como de costumbre, que es su punto más fuerte.


Lense (ejemplo)
 def changeUserRole(userId: Int, newRole: String): Set[Row] => Set[Row] = (rows: Set[Row]) => rows.modifyAll(_.each.when[UserRow]) .using(r => if (r.id == userId) r.modify(_.role).setTo(newRole) else r) 

Gracias a la composición, podemos aplicar diferentes optimizaciones y mejoras dentro del proceso: por ejemplo, podríamos agrupar filas por tabla para insertarlas con un solo INSERT para reducir el tiempo de ejecución de la prueba o registrar todo el estado de la base de datos.


Función de preparación de accesorios
 def makeFixture[STATE, FX, ROW, F[_]]( state: STATE, applyOverrides: F[ROW] => F[ROW] = x => x ): FX = (extractKeys andThen deduplicateKeys andThen enrichWithSampleData andThen applyOverrides andThen logged andThen buildFixture) (state) 

Finalmente, todo nos proporciona un accesorio. En la prueba en sí, no se muestra nada adicional, excepto el conjunto de datos inicial: todos los detalles están ocultos por la composición de la función.



Nuestro conjunto de pruebas ahora se ve así:


 val dataTable: Seq[DataRow] = Table( ("Package ID", "Customer's role", "Item prices", "Bonus value", "Expected final price") , (1, "customer", Vector(40, 20, 30) , Vector.empty , 90.0) , (2, "customer", Vector(250) , Vector.empty , 225.0) , (3, "customer", Vector(100, 120, 30) , Vector(40) , 210.0) , (4, "customer", Vector(100, 120, 30, 100) , Vector(20, 20) , 279.0) , (5, "vip" , Vector(100, 120, 30, 100, 50), Vector(10, 20, 10), 252.0) ) "If the buyer's role is" - { "a customer" - { "And the total price of items" - { "< 250 after applying bonuses - no discount" - { "(case: no bonuses)" in calculatePriceFor(dataTable, 1) "(case: has bonuses)" in calculatePriceFor(dataTable, 3) } ">= 250 after applying bonuses" - { "If there are no bonuses - 10% off on the subtotal" in calculatePriceFor(dataTable, 2) "If there are bonuses - 10% off on the subtotal after applying bonuses" in calculatePriceFor(dataTable, 4) } } } "a vip - then they get a 20% off before applying bonuses and then all the other rules apply" in calculatePriceFor(dataTable, 5) } 

Y el código de ayuda:


Código de ayuda
 // Reusable test's body def calculatePriceFor(table: Seq[DataRow], idx: Int) = testInDb( state = makeState(table.row(idx)), execute = runProductionCode(table.row(idx)._1), check = checkResult(table.row(idx)._5) ) def makeState(row: DataRow): Logger => DbFixture = { val items: Map[Int, Int] = ((1 to row._3.length) zip row._3).toMap val bonuses: Map[Int, Int] = ((1 to row._4.length) zip row._4).toMap MyFixtures.makeFixture( state = PackageRelationships .minimal(id = row._1, userId = 1) .withItems(items.keys) .withBonuses(bonuses.keys), overrides = changeRole(userId = 1, newRole = row._2) andThen items.map { case (id, newPrice) => changePrice(id, newPrice) }.foldPls andThen bonuses.map { case (id, newBonus) => changeBonus(id, newBonus) }.foldPls ) } def runProductionCode(id: Int): Database => Double = (db: Database) => new SomeProductionLogic(db).calculatePrice(id) def checkResult(expected: Double): Double => Future[Assertion] = (result: Double) => result shouldBe expected 

Agregar nuevos casos de prueba a la tabla es una tarea trivial que nos permite concentrarnos en cubrir más casos marginales y no en escribir código repetitivo.


Reutilizando la preparación de accesorios en diferentes proyectos


Bien, escribimos una gran cantidad de código para preparar accesorios en un proyecto específico, pasando bastante tiempo en el proceso. ¿Qué pasa si tenemos varios proyectos? ¿Estamos condenados a reinventar todo desde cero todo el tiempo?


Podemos abstraer la preparación del aparato sobre un modelo de dominio concreto. En el mundo de la programación funcional, existe un concepto de clases de tipos . Sin profundizar en los detalles, no son como clases en OOP, sino más bien como interfaces en el sentido de que definen un cierto comportamiento de algún grupo de tipos. La diferencia fundamental es que no se heredan sino que se instancian como variables. Sin embargo, de manera similar a la herencia, la resolución de instancias de tipo de clase ocurre en tiempo de compilación . En este sentido, las clases de tipos se pueden entender como métodos de extensión de Kotlin y C # .


Para registrar un objeto, no necesitamos saber qué hay dentro, qué campos y métodos tiene. Lo único que nos importa es tener un log() comportamiento log() con una firma particular. Extender cada clase individual con una interfaz Logged sería extremadamente tedioso e incluso, en muchos casos, no sería posible, por ejemplo, para bibliotecas o clases estándar. Con las clases de tipos, esto es mucho más fácil. Podemos crear una instancia de una clase de tipo llamada Logged , por ejemplo, para que un dispositivo lo registre en un formato legible para humanos. Para todo lo demás que no tiene una instancia de Logged , podemos proporcionar una alternativa: una instancia para el tipo Any que utiliza un método estándar toString() para registrar de forma gratuita todos los objetos en su representación interna.


Un ejemplo de la clase de tipo Logged y sus instancias
 trait Logged[A] { def log(a: A)(implicit logger: Logger): A } // For all Futures implicit def futureLogged[T]: Logged[Future[T]] = new Logged[Future[T]] { override def log(futureT: Future[T])(implicit logger: Logger): Future[T] = { futureT.map { t => // map on a Future lets us modify its result after it finishes logger.info(t.toString()) t } } } // Fallback in case there are no suitable implicits in scope implicit def anyNoLogged[T]: Logged[T] = new Logged[T] { override def log(t: T)(implicit logger: Logger): T = { logger.info(t.toString()) t } } 

Además del registro, podemos usar este enfoque durante todo el proceso de hacer accesorios. Nuestra solución propone una forma abstracta de hacer accesorios de base de datos y un conjunto de clases de tipos para acompañarlo. Es el proyecto que usa la responsabilidad de la solución implementar las instancias de estas clases de tipos para que todo funcione.


 // Fixture preparation function def makeFixture[STATE, FX, ROW, F[_]]( state: STATE, applyOverrides: F[ROW] => F[ROW] = x => x ): FX = (extractKeys andThen deduplicateKeys andThen enrichWithSampleData andThen applyOverrides andThen logged andThen buildFixture) (state) override def extractKeys(implicit toKeys: ToKeys[DbState]): DbState => Set[Key] = (db: DbState) => db.toKeys() override def enrichWithSampleData(implicit enrich: Enrich[Key]): Key => Set[Row] = (key: Key) => key.enrich() override def buildFixture(implicit insert: Insertable[Set[Row]]): Set[Row] => DbFixture = (rows: Set[Row]) => rows.insert() // Behavior of splitting something (eg a dataset) into keys trait ToKeys[A] { def toKeys(a: A): Set[Key] // Something => Set[Key] } // ...converting keys into rows trait Enrich[A] { def enrich(a: A): Set[Row] // Set[Key] => Set[Row] } // ...and inserting rows into the database trait Insertable[A] { def insert(a: A): DbFixture // Set[Row] => DbFixture } // To be implemented in our project (see the example at the end of the article) implicit val toKeys: ToKeys[DbState] = ??? implicit val enrich: Enrich[Key] = ??? implicit val insert: Insertable[Set[Row]] = ??? 

Al diseñar esta herramienta de preparación de accesorios, utilicé los principios SÓLIDOS como una brújula para asegurarme de que se pueda mantener y ampliar:


  • El Principio de Responsabilidad Única : cada clase de tipo describe uno y solo un comportamiento de un tipo.
  • El principio abierto / cerrado : no modificamos ninguna de las clases de producción; en su lugar, los extendemos con instancias de typeclasses.
  • El Principio de sustitución de Liskov no se aplica aquí ya que no usamos la herencia.
  • El principio de segregación de interfaz : utilizamos muchas clases de tipos especializadas en lugar de una global.
  • El principio de inversión de dependencia : la función de preparación de accesorios no depende de tipos concretos, sino de clases de tipos abstractos.

Después de asegurarnos de que se cumplan todos los principios, podemos asumir con seguridad que nuestra solución es mantenible y lo suficientemente extensible como para ser utilizada en diferentes proyectos.


Después de escribir la función del ciclo de vida de la prueba y la solución para la preparación del accesorio, que también es independiente de un modelo de dominio concreto en cualquier aplicación, estamos listos para mejorar todas las pruebas restantes.


Línea inferior


Hemos cambiado del estilo de diseño de prueba tradicional (paso a paso) a funcional. El estilo paso a paso es útil desde el principio y en proyectos de menor tamaño, ya que no restringe a los desarrolladores y no requiere ningún conocimiento especializado. Sin embargo, cuando la cantidad de pruebas se vuelve demasiado grande, ese estilo tiende a caerse. Escribir pruebas en el estilo funcional probablemente no resolverá todos sus problemas de prueba, pero podría mejorar significativamente la escala y el mantenimiento de las pruebas en proyectos, donde hay cientos o miles de ellas. Las pruebas escritas en el estilo funcional resultan ser más concisas y centradas en las cosas esenciales (como los datos, el código bajo prueba y el resultado esperado), no en los pasos intermedios.


Además, hemos explorado cuán poderosas pueden ser la composición de funciones y las clases de tipos en la programación funcional. Con su ayuda, es bastante sencillo diseñar soluciones teniendo en cuenta la capacidad de ampliación y la reutilización.


Desde que adoptamos el estilo hace varios meses, nuestro equipo tuvo que dedicar un poco de esfuerzo para adaptarse, pero al final, disfrutamos del resultado. Las nuevas pruebas se escriben más rápido, los registros hacen la vida mucho más cómoda y los conjuntos de datos son útiles para verificar cada vez que haya preguntas sobre las complejidades de algunas lógicas. Nuestro equipo tiene como objetivo cambiar gradualmente todas las pruebas a este nuevo estilo.




Enlace a la solución y un ejemplo completo se puede encontrar aquí: Github . ¡Diviértete con tus pruebas!

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


All Articles