Saltar al contenido principal

¿Cómo elaborar casos de prueba?

Este artículo es una adaptación de Testeo unitario avanzado, elaborado por Fernando Dodino para la Fundación Uqbar.

Este artículo presenta algunas guías para desarrollar los casos de prueba, asumiendo que ya conocés el concepto de pruebas automatizadas y algún framework para implementarlas.

Descripción del dominio#

Todo el apunte estará elaborado en base al siguiente dominio:

requerimiento

Una ferretería tiene en cuenta ciertos aspectos para decidir si le puede fiar a un cliente o no.

  • Al cliente particular le fia solo si no adeuda nada, es decir si su deuda es de $0.
  • A las constructoras les fia dependiendo de cuántos albañiles trabajen en la obra:
    • si son 5 o más albañiles, le fía hasta $10.000,
    • de lo contrario sólo hasta $5.000.

Definiendo los escenarios#

Para armar los escenarios de prueba, vamos a tomar prestado un concepto de la matemática llamado clase de equivalencia. Clasificar a los distintos escenarios según las características que comparten nos va a permitir contemplar todas las opciones posibles sin tener que escribir infinitas pruebas.

Para un cliente particular tenemos dos casos: que deba algo o que no deba nada. Si debe $1 ó $50.000 no nos importa, porque está en la misma clase de equivalencia (la deuda es mayor a $0).

Para una empresa constructora, tenemos más variantes:

  • si tiene menos de 5 albañiles, podemos decir que son "pocos" albañiles,
  • si tiene 5 o más albañiles, podemos decir que son "muchos" albañiles.

Combinando eso con las posibles deudas, podríamos definir los siguientes escenarios:

  • Dado un cliente particular:
    • Si debe algo: no se le puede fiar.
    • Si no debe nada: se le puede fiar.
  • Dada una constructora con muchos (5) albañiles:
    • Si la obra debe más de $10.000: no se le puede fiar.
    • Si la obra debe menos de $10.000: se le puede fiar.
  • Dada una constructora con pocos (4) albañiles:
    • Si la obra debe más de $5.000: no se le puede fiar.
    • Si la obra debe menos de $5.000: se le puede fiar.

En el caso de la constructora, elegimos la cantidad de obreros en base al valor límite: como 5 es el número que distingue entre pocos y muchos, elegimos 4 para representar al caso pocos y 5 para el caso muchos.

Sin dudas podríamos haber elegido otros números, pero es importante que los tests sean simples y fáciles de comprender. Por esta razón, vamos a preferir siempre utilizar valores que nos resulten fáciles de razonar para representar a cada escenario.

Escribiendo los tests#

Necesitamos:

  • Un cliente particular.
  • Una empresa constructora con 5 albañiles.
  • Otra empresa constructora con 4 albañiles.

A los que podemos configurar diferentes grados de deuda.

Atención

Los ejemplos de código que vienen a continuación asumen que se utiliza Kotest (en el caso de Kotlin) o Jest (en el caso de Typescript). La agrupación que proponemos no puede imitarse fácilmente en frameworks como JUnit.

¿Cómo agrupar los escenarios?#

En frameworks como Jest o Kotest tenemos la posibilidad de jerarquizar nuestras pruebas, utilizando las funciones describe e it para crear estas agrupaciones.

Podríamos también tener todo junto, sin ningún tipo de agrupamiento. Pero esto no resulta ser una buena práctica, porque

  • dificulta diferenciar los escenarios: estarán todas las variables de los tests mezcladas,
  • si construimos un fixture con cada uno de los tipos de cliente, estamos penalizando a cada uno de los tests por lo que necesitan los demás: ¿tiene sentido crear una constructora con 5 albañiles si estoy testeando una que tiene 4?,
  • se pierde cohesión: un solo método (o función, en este caso) está cubriendo todos los casos de prueba.

Volviendo al ejemplo, hay varias opciones posibles:

  • hacer un describe para clientes particulares y otro para constructoras,
  • hacer un describe para clientes particulares, otro para constructoras con pocos albañiles y otro para constructoras con muchos albañiles.

Elegiremos la ultima opción y agruparemos las pruebas de la siguiente forma:

describe("Un cliente particular") {  // ...}
describe("Una constructora con pocos albañiles") {  // ...}
describe("Una constructora con muchos albañiles") {  // ...}

Es importante que no haya demasiados detalles de implementación en los nombres: Una constructora con 5 albañiles o Una constructora con 10 albañiles están sujetos a que cualquier cambio del negocio respecto a lo que son “muchos” o “pocos” albañiles necesite modificar el nombre del test. Además, un nombre así nos obliga a ir a revisar el código (o el requerimiento, si es que está escrito en algún lado) para comprender que se trata de dos escenarios diferentes.

Expresividad: nombres acorde a lo que representan#

Al empezar a diseñar los casos de prueba es usual imaginar algún ejemplo más o menos realista, como La Constructora Hurlingham tiene 5 albañiles y una deuda de 7000 pesos.

Intentando traducir esto a código, tal vez nos saldría un tests como el siguiente:

class FerreteriaTest : DescribeSpec({  describe("Una ferretería") {    it("puede fiarle a la Constructora Hurlingham") {      val constructoraHurlingham = EmpresaConstructora(albaniles = 5, deuda = 7000)      constructoraHurlingham.puedePedirFiado().shouldBeTrue()    }  }})

Pero ¿qué pasa si hay un error en el código y el test falla? Supongamos esta implementación, donde la clase EmpresaConstructora tiene la definición de la deuda como un entero:

class EmpresaConstructora(val cantidadAlbaniles: Int, deuda: Int): Cliente(deuda) {    // Debería ser >= 5    fun montoMaximoDeuda() = if (cantidadAlbaniles > 5) 10000 else 5000
    override fun puedePedirFiado() = deuda <= this.montoMaximoDeuda()}

Caso triste

Cuando ejecutamos el test tenemos muy poca información relevante:

  • la constante constructorHurlingham no está revelando que es una constructora con muchos albañiles,
  • y tampoco está claro por qué no puede pedir fiado.

Al fallar, tenemos que bucear en el código y extraer este dato para determinar si el error está en el test o en el código de negocio.

Otra oportunidad#

Vamos a mejorar la semántica del test, renombrando la constante constructoraHurlingham por un nombre más representativo de la clase de equivalencia que estamos modelando y cambiando el nombre del test:

class FerreteriaTest : DescribeSpec({  describe("Una ferretería") {    it("puede fiarle a una constructora con muchos albañiles") {      val constructoraMuchosAlbaniles = EmpresaConstructora(albaniles = 5, deuda = 7000)      constructoraMuchosAlbaniles.puedePedirFiado().shouldBeTrue()    }  }})

Caso feliz

Ahora al fallar el test sabemos más cosas:

  • qué es lo que estamos testeando, tratando de no entrar en detalles para no duplicar lo que dice el código,
  • qué se esperaba que pasara y no pasó, en un formato más o menos legible para un usuario: “Una ferretería puede fiarle a una constructora con muchos albañiles”.

Buenas prácticas#

Más allá de lo explicado hasta aquí, compartimos también algunas buenas prácticas que pueden ser útiles a la hora de armar nuestras pruebas.

El patrón AAA: Arrange-Act-Assert#

Una de las formas más comunes de estructurar las pruebas es utilizando el patrón AAA: Arrange, Act y Assert.

No es necesario cumplirlo a rajatabla - y de hecho el ejemplo de este apunte no lo cumple 😅 -, pero tenerlo en mente puede ayudarnos a entender si nuestro test tiene todo lo que debería tener.

Según este patrón, cada test se puede dividir en tres "momentos":

  • Arrange: que podría traducirse como arreglar o gestionar, en donde se instancian los objetos a testear, en este ejemplo la ferreteria y sus clientes. Cuando estos objetos son compartidos entre varios tests, los frameworks nos permiten ubicarlos en algún lugar común, por ejemplo dentro de un mismo describe. La desventaja de esta técnica es que para tener una idea general de los elementos que participan en el test debemos mirar el test y además el código de inicialización en el que está enmarcado.
  • Act: que podría traducirse como actuar. Son operaciones que tienen algún efecto sobre los objetos creados, y que posteriormente vamos a querer comprobar que hicieron lo que debían. Hay tests, como los de este apunte, que no necesitan disparar acciones, y está bien que eso ocurra.
  • Assert: que podría traducirse como afirmar. Es la parte donde escribimos lo que esperamos que pase, generalmente asociado a las respuestas que da el envío de un mensaje al objeto testeado.

Veamos un ejemplo sencillo que puede desglosarse fácilmente en esos tres componentes:

describe("Un ave") {  it("pierde energía al volar") {    // ARRANGE: se crea el objeto    val pepita = Ave(1000)
    // ACT: se realizan las acciones    pepita.volar()
    // ASSERT: se verifica el efecto    pepita.energia.shouldBe(900)  }}