Contenido

Unit tests en Java

El otro día me di cuenta de que no había escrito ningún artículo sobre tests unitarios en Java. ¡Eso hay que arreglarlo!

Tengo artículos más complejos pero, a veces, volver a la base ayuda a tener una mejor visión de conjunto. Además, nunca se sabe lo que se puede aprender de lo básico.

Aprovecharé para hacer incapié en las características de los buenos tests.

Actualización 2012-07-11: Añado árbol de directorios y cabeceras indicando a qué archivo pertenecen. Actualización 2012-07-13: Gracias a un comentario de David Marín, descubro que la parte de los proveedores de datos es completamente errónea y la arreglo.

Java

Qué vamos a probar

Hay dos aproximaciones posibles. Por una parte, podría poner una función y probarla. Esto es lo que se denominaría “probar legacy code”. Otra aproximación sería Test Driven Development o TDD, que consiste en escribir la prueba primero y después producir código que hace pasar la prueba.

Como soy amante de TDD, he decidido seguir esta aproximación. Pero me vais a permitir que analice ambas.

Legacy code

Sólo se puede probar el código que es testeable. Parece algo trivial, pero no es tan sencillo verlo.

Las funciones legacy son aquéllas que nos encontramos de código antiguo y que no tienen pruebas. A menudo tendrán muchos parámetros de entrada, realizarán varias tareas y tendrán código duplicado. En muchas ocasiones, también hay mucho “código tricky”, es decir: trucos que ha usado el programador para demostrar que es mejor que nadie. Todo eso hace el código dificil de seguir, comprobar, depurar y comprender.

A menudo, en el código antiguo, es fácil encontrar funciones que admiten, literalmente, decenas de parámetros. Una función así, directamente, no se puede probar. La cantidad de casos posibles y de caminos existentes es tan grande y la probabilidad de tener que modificar algo en el futuro es tan alta que no merece la pena. En estos casos, los test unitarios no sirven.

Es mejor atacar con tests de sistema, que nos permitan comprobar que no rompemos nada mientras rehacemos este tipo de funciones.

Test Driven Development

Cuando se realiza la prueba primero, es más dificil encontrar funciones tan grandes. Normalmente, las pruebas se dirigen en exclusiva a lo que necesitas, por lo que tiendes a eliminar todo lo que sobra.

Además, preparar una prueba demasiado compleja es un trabajo agotador, y suele evitarse por pruebas más simples. Así, rellenar una estructura enorme es más dificil que pasar una pequeña. Por eso es más sencillo cumplir el principio de unica responsabilidad cuando se utilizan técnicas como TDD.

Hay que pensar que los mayores usuarios de la mayor parte de nuestras funciones serán otros programadores (o nosotros mismos). Gracias a TDD se consigue un código más legible para los desarrolladores, más fiable y totalmente documentado, ya que los tests no dejan de ser ejemplos de uso.

Además, cuando se encuentra un nuevo defecto, lo suyo es crear primero el test para evitar que vuelva a suceder en el futuro. Así el fallo queda documentado de la forma más fiable: probando que el código se comporta exactamente como queremos.

El problema

Como suelo hacer, voy a exponer un problema y a solucionarlo. En este caso, vamos a realizar una clase de estadística, que permita obtener la mediana de una lista de números.

Comencemos:

Estructura

Como suelo hacer, aquí tenéis el archivo maven que vamos a usar, para que no haya problemas de dependencias ni ejecución. Usaremos JUnit 4.10:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<project xmlns="https://maven.apache.org/POM/4.0.0" xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="https://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>org.magmax</groupId>
  <artifactId>stats</artifactId>
  <version>1.0-SNAPSHOT</version>
  <packaging>jar</packaging>

  <name>stats</name>
  <url>https://maven.apache.org</url>

  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>

  <dependencies>
    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>4.10</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
</project>

La estructura de ficheros que generaremos será la siguiente:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
.
├── pom.xml
└── src
    ├── main
    │   └── java
    │       └── org
    │           └── magmax
    │               └── stats
    │                   └── Stats.java
    └── test
        └── java
            └── org
                └── magmax
                    └── stats
                        └── StatsTest.java

Escribiendo código

Vamos con los tests:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// file src/test/java/org/magmax/stats/StatsTest.java
package org.magmax.stats;

import org.junit.Test;
import static org.junit.Assert.*;
import org.junit.Before;

public class StatsTest {
	private Stats sut;

	@Before
	public void setUp() {
		sut = new Stats();
	}

	@Test
	public void MedianaVectorVacio() {
		assertEquals(0, sut.mediana(new int[0]));
	}
}

Como veis, lo único que hay que hacer es utilizar la notación “@Test” y ya está.

Me parece muy interesante mostrar que aquí se han realizado más operaciones de las que parecen. Por ejemplo, sin saberlo hemos decidido que calcularemos la media de un array de enteros. Este requisito nunca estuvo en el enunciado. ¿Por qué se ha hecho así? Porque era más sencillo para el test.

También hemos decidido cómo se construye la clase de la que vamos a crear objetos, mediante un constructor sin parámetros. Y hemos decidido que no sea un método estático, sino que es necesario instanciar un objeto de la clase.

Aquí hay parte de mi experiencia desarrollando: no me gustan los métodos estáticos porque siempre terminan dando problemas y me gusta llamar al objeto a probar “sut”, que significa “Subject Under Test” (objeto en pruebas). Esto me permite identificar rápidamente qué objeto estoy probando.

Para ser TDD extrictos, lanzaremos el tests y veremos que falla. Decimos que estamos en rojo o que el test está rojo porque está fallando. Así que lo arreglamos:

1
2
3
4
5
6
7
8
9
// file src/main/java/org/magmax/stats/Stats.java
package org.magmax.stats;

public class Stats
{
	public int mediana (int[] v) {
		return 0;
	}
}

Ahora ya tenemos luz verde (o el test está verde). Es importante ver el verde, igual que lo es ver el rojo.

Como veis, eso es el código mínimo que necesito para que pase mis tests. A menudo esto se denomina “diseño evolutivo”, y es más importante de lo que parece conocerlo. Sin saberlo, me está dando un caso base que, en la mayor parte de las ocasiones, se mantendrá incluso al final. La experiencia me ha enseñado que este primer test suele ser siempre parte de las condiciones iniciales de la función.

Añadiendo tests

Como no quiero aburriros, voy a saltarme unos cuantos ciclos de test-rojo-codificación-verde-refactorización-verde:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// file src/test/java/org/magmax/stats/StatsTest.java
package org.magmax.stats;

import org.junit.Test;
import static org.junit.Assert.*;
import org.junit.Before;

public class StatsTest {
	private Stats sut;

	@Before
	public void setUp() {
		sut = new Stats();
	}

	@Test
	public void MedianaVectorVacio() {
		assertEquals(0, sut.mediana(new int[0]));
	}

	@Test
	public void MedianaVectorUnElemento() {
		assertEquals(5, sut.mediana(new int[]{5}));
	}

	@Test
	public void MedianaTresElementos() {
		assertEquals(3, sut.mediana(new int[]{1, 3, 5}));
	}

	@Test
	public void MedianaCuatroElementos() {
		assertEquals(4, sut.mediana(new int[]{2, 4, 6, 8}));
	}
}

Fijaos que, durante la elaboración del último test, MedianaCuatroElementos, he tomado otra decisión de diseño que no se encontraba en el enunciado, y que ésta queda documentada gracias al test: si el número de elementos es par, tendré dos elementos en el centro; quiero el primero de ellos.

Aquí también se aprecia la experiencia: trato de utilizar siempre diferentes datos y, si son iguales, trato de evitar obtener un resultado ya obtenido previamente.

Vamos con el programa:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// file src/main/java/org/magmax/stats/Stats.java
package org.magmax.stats;

public class Stats {

	public int mediana(int[] v) {
		if (v.length == 0) {
			return 0;
		}
		return v[obtenerPosicionMedio(v.length)];
	}

	private int obtenerPosicionMedio(int length) {
		return (int) (length / 2.00001);
	}
}

Cuando ya tenía verde decidí refactorizar. Y refactorizando me di cuenta de que tenía un algoritmo horrible en el método, así que lo saqué a otro método a parte, obtenerPosicionMedio.

Paso siguiente

En este momento todo está terminado,… ¿no? Pues no. Aún queda refactorizar las pruebas. Ese paso es tan importante como refactorizar el programa principal. Miradlo desde este punto de vista: si las pruebas son complejas de realizar, nadie las hará. Si son complejas de mantener, nadie las mantendrá.

Tal y como expliqué en el artículo Pruebas Unitarias: Proveedores de datos, podemos utilizar proveedores de datos:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// file src/test/java/org/magmax/stats/StatsTest.java
package org.magmax.stats;

import java.util.Arrays;
import java.util.Collection;
import org.junit.Test;
import static org.junit.Assert.*;
import org.junit.Before;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;

@RunWith(Parameterized.class)
public class StatsTest {

	private Stats sut;
	private int expected;
	private int[] input;

	@Before
	public void setUp() {
		sut = new Stats();
	}

	@Parameterized.Parameters
	public static Collection numbers() {
		return Arrays.asList(new Object[][]{
					{0, new int[]{}},
					{5, new int[]{5}},
					{3, new int[]{1, 3, 5}},
					{4, new int[]{2, 4, 6, 8}},
				});
	}

	public StatsTest(int expected, int[] input) {
		this.expected = expected;
		this.input = input;
	}

	@Test
	public void testMediana() {
		assertEquals(expected, sut.mediana(input));
	}
}

Con lo que se reduce la complejidad y se facilita poder insertar nuevos casos de uso.