Testeo unitario avanzado

Este artículo presenta algunas guías para desarrollar los casos de prueba, considerando que ya tienen una base de testeo unitario automatizado. Si estás buscando un apunte, te recomendamos el siguiente apunte de Testing.

Por otra parte, aquí explicamos la mecánica utilizando JUnit 5 como framework de testeo, si estás buscando una variante al estilo XSpec, como Kotest, podés ver esta página.

Ejemplo

Un sistema de seguros de automotor define en qué casos se puede pagar un siniestro:

Definiendo los escenarios

En base al ejemplo anterior, podemos considerar los siguientes escenarios:

Elegimos cuántos autos en base al valor límite: como a partir de los seis autos se considera mucho y menos de 6 son “pocos” autos, 6 es el valor de una flota con muchos autos, 5 es el valor de una flota con pocos autos.

Estructura de los tests

La estructura que tienen los tests en base a los escenarios propuestos podría ser:

      dado un cliente normal
  ├── que es moroso: no puede cobrar un siniestro
  └── que no es moroso: puede cobrar un siniestro
dado un cliente de flota con muchos autos (6 autos)
  ├── si el cliente debe más de $ 10.000 no puede cobrar un siniestro
  └── si el cliente debe $ 10.000 o menos, puede cobrar un siniestro
dado un cliente de flota con pocos autos (5 autos)
  ├── si el cliente debe más de $ 5.000 no puede cobrar un siniestro
  └── si el cliente debe $ 5.000 o menos puede cobrar un siniestro

    

Definiendo las clases y las variables de los tests

Necesitamos

a los que podemos configurar diferentes grados de deuda. Podemos seguir algunas recomendaciones adicionales:

Agrupar los escenarios en clases

¿Cuántas clases necesitamos para implementar los casos de prueba? Podríamos considerar una clase sola para todos los tests, o bien tener dos clases: una para clientes normales y otra para clientes de flota, o bien podríamos tener una clase para cada uno de los escenarios que planteamos más arriba (cliente normal moroso, cliente que no debe nada, flota de 6 autos, etc.)


Tener en una sola clase todos los tests no resulta ser una buena práctica, porque


Volviendo al ejemplo, hay varias opciones posibles:


Crearemos entonces estas clases de test:

Es importante que no haya demasiados detalles de implementación en los nombres: FlotaCon5AutosTest o FlotaCon6AutosTest está sujeto a que cualquier cambio del negocio respecto a lo que son “muchos” o “pocos” autos necesite modificar el nombre de la clase.

Intention revealing - parte 1

Queremos expresar lo más claramente posible la intención de la clase: qué clase de equivalencia está testeando. El nombre ayuda, e incluso JUnit 5 nos permite incorporar la anotación @DisplayName para describir el escenario en lenguaje castellano:

      @DisplayName("Dado un cliente de flota con muchos autos")
class FlotaMuchosAutosTest {

    

recordando que las clases agrupan los tests, más adelante veremos cómo juega a favor este encabezado escrito en lenguaje natural. Una vez más recordamos: “muchos autos” es mejor que decir “6 autos”. En otras palabras, explicitar el caso de prueba y no el dato de prueba: 6 autos es un dato concreto, pero lo que representa es el caso de prueba de una flota con muchos autos.

Expresividad en los tests

Un primer approach

Para crear nuestro fixture de una flota con muchos autos, los enunciados suelen traer ejemplos como: “Lidia Pereyra tiene una flota con 6 autos”. Es tentador escribir un test como el siguiente:

      	Flota pereyra
	
	@BeforeEach
	def void init() {
		pereyra = new Flota => [
			agregarAuto(new Auto("ab028122", 2008))
      // ... se agregan más autos ... //
		]
	}

	@Test
	def void pereyraNoPuedeCobrarSiniestro() {
		pereyra.generarDeuda(15000)
		assertFalse(pereyra.puedeCobrarSiniestro)
	}

    

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

      class Flota extends Cliente {
	List<Auto> autos = newArrayList
	
	override puedeCobrarSiniestro() {
		this.deuda < this.montoMaximoDeuda
	}
	
	def montoMaximoDeuda() {
		if (autos.size > 5) 20000 else 5000 // debería ser 10.000 y no 20.000
	}

    

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

mal nombre de variable

Al fallar la condición 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.

Una segunda oportunidad

Vamos a mejorar la semántica del test, renombrando la variable pereyra por un nombre más representativo de la clase de equivalencia que estamos modelando, agregando la anotación @DisplayName para el test y definiendo un mensaje de error adicional en el assert:

      class FlotaMuchosAutosTest {
	
	Flota flotaConMuchosAutos
	
	@BeforeEach
	def void init() {
		flotaConMuchosAutos = new Flota => [
			agregarAuto(new Auto("ab028122", 2008))
      // ... agregamos más autos ... //
		]
	}

	@Test
	@DisplayName("si tiene una deuda grande no puede cobrar un siniestro")
	def void conDeudaGrandeNoPuedeCobrarSiniestro() {
		flotaConMuchosAutos.generarDeuda(15000)
		assertFalse(flotaConMuchosAutos.puedeCobrarSiniestro, 
      "una flota que tiene una deuda abultada no puede cobrar un siniestro")
	}

    

Ahora al fallar el test sabemos más cosas:

mas expresividad en los tests


AAA Pattern

Los tests suelen estructurarse según el patrón AAA: Arrange, Act y Assert.

      	@Test
	def void conDeudaGrandeNoPuedeCobrarSiniestro() {
    // Arrange
		val flotaConMuchosAutos = this.crearFlotaDeAutos(6)

    // Act
    flotaConMuchosAutos.generarDeuda(15000)
		
    // Assert
    assertFalse(flotaConMuchosAutos.puedeCobrarSiniestro)
	}

    

En el ejemplo tenemos un método helper del test que permite crear un objeto Flota pasándole la cantidad de autos a crear. De esa manera la configuración de una flota ocurre en una sola línea y se puede incluir dentro del test mismo.

Una heurística posible sobre el setup del test es tratar de mantenerlo simple y de alto nivel, más cercano al lenguaje del dominio que con detalles de implementación. En el ejemplo de arriba se logra con mensajes que se encargan de instanciar objetos de dominio y que esconden la complejidad de conocer la colaboración entre la flota y sus autos). Una alternativa a tener métodos en el test puede ser crear un objeto específico que construya otro objeto, algo que dejaremos para más adelante.


“One assert per test”

Hay ciertas controversias respecto a si podemos tener varios asserts en el mismo test, ya que cuando el primer assert falla los siguientes no se siguen evaluando: esto en realidad depende del runner de xUnit, podríamos eventualmente trabajar con un framework que continue buscando asserts y discrimine cuáles anduvieron y cuáles no (RSpec, framework de testeo para Ruby, hace ésto).

En verdad, la heurística que nos interesa recomendar es: los tests deben fallar por exactamente un solo motivo, esto relaja esa restricción. Lo importante no es tener un solo assert, sino que todos los asserts estén relacionados con la misma funcionalidad. Dejamos un ejemplo concreto:

      @Test
@DisplayName("el parser obtiene correctamente la parte numérica de la patente del auto vieja")
def parsearNumerosPatenteVieja() {
	val lista = new PatenteParser("ABC257").parsearNumeros()
	assertEquals(3, lista.size)
	assertEquals(2, lista.get(0))
	assertEquals(5, lista.get(1))
	assertEquals(7, lista.get(2))
}

    

El lector puede profundizar con estos artículos:

TL;DR

Este es el resumen de buenas prácticas a la hora de definir tus tests:

Links relacionados