Refactorizando métodos no-deterministas para poder hacer Test Unitarios

0
5023

En este tutorial vamos a ver cómo podemos rafactorizar métodos no-deterministas para poder hacer test unitarios, ya que este tipo de métodos devuelve un resultado diferente cada vez que son ejecutados.

0. Índice de contenidos

1. Introducción

Vamos a hacer esta introducción al estilo de algunos post de Bob Martin, como un diálogo…

– ¿Qué es un método no-determinista?

Pues el que si se ejecuta dos o más veces no tiene por qué dar el mismo resultado

– Nunca he usado ninguno, ¿podrías indicarme dónde se usan?

El uso de métodos no-deterministas quizá no sea muy común para las aplicaciones clásicas de gestión, pero sí que son necesarios en otro tipo de aplicaciones como algunas de los campos de la simulación, las estadística, inteligencia artificial, etcétera. Y es que la vida en general es no-determinista: 2+2 a veces son 3,7 y otras 4,2…

– Bien… ¿Y qué tiene de especial para testing respecto a un método normal, osea, determinista?

Pues que como la salida del método cada vez es un valor diferente, es complicado decir: «cuando el parámetro es 2, la salida debe ser 4».

– mmm no lo veo claro… ¿Puedes poner un ejemplo con código

Sí, mira este método que dada una lista con cadenas de texto devuelve una de ellas, al azar

public static String returnRandomStringFromStringList(List listWithStrings) {
    int rndIndex = (int) (Math.random() * listWithStrings.size());
    return listWithStrings.get(rndIndex);
}
	

¿Cómo harías para testearla?

– mmm tendría que pensarlo…

Pues entonces déjame que te muestre algunas cosas a ver si te puedo aportar alguna idea. Sigue leyendo…

2. Entorno

Este tutorial está hecho con el siguiente entorno:

  • Hardware: Portátil MacBook Pro 15′ (2.3 GHz Intel Core i7, 16GB DDR3).
  • Sistema Operativo: Mac OS Mavericks 10.9.4
  • Netbeans 8.0.2
  • Java 1.8.0_45

3. Testeando métodos estáticos no-deterministas que llaman a métodos estáticos

Después de la introducción vamos a ponernos más técnicos y vamos a ver dónde reside el problema.

Tenemos una típica clase de utilidades que está llena de métodos estáticos. ¿Por qué métodos estáticos? No necesitan estado y simplemente toman unos parámetros de entrada y devuelven un resultado en la salida. Vamos a usar esta clase como ejemplo:

public final class MyRandomUtilsOriginal {

	private MyRandomUtilsOriginal() throws InstantiationException{
		throw new InstantiationException();
	}

	public static String returnRandomStringFromStringList(List listWithStrings) {
		int rndIndex = (int) (Math.random() * listWithStrings.size());
		return listWithStrings.get(rndIndex);
	}
}

Al tratarse de una clase de utilidades, evitamos que sea instanciable con un constructor privado y declarándola final. Además todos sus métodos (en este caso uno solo), es de tipo static.

Testear un método estático de forma unitaria no tiene mucha dificultad, simplemente se le invoca en un test y se comprueba la salida o las llamadas a los elementos externos usando mocks.

El problema de este método es que su resultado es no-determinista, es decir, cada vez devolverá una cadena de texto diferente de la lista de cadenas de texto que se le pasa.

Por tanto no podemos hacer el clásico assertEquals(esperado,resultado) con el resultado de la llamada: el resultado cada vez es uno diferente. De hecho, algunas veces pasará el test y otras veces no. Esto es un intento de test:

public class MyRandomUtils1Test {

	List listWithStrings = Arrays.asList("Uno", "Dos", "Tres", "Cuatro");

	@Test
	public void shouldReturnRndElementOfGivenListOfStrings_WhenUsingOriginal() {
		String result = MyRandomUtilsOriginal.returnRandomStringFromStringList(listWithStrings);
		assertEquals("Dos", result);
	}
}

	

Podemos ver como la entrada que usamos es la lista que contiene «Uno», «Dos», «Tres» y «Cuatro» y estamos comparando la salida con el «Dos». En 1/4 de las ocasiones, el test pasará, y fallará en 3/4 partes de las veces.

Si tienes experiencia haciendo test unitarios sabrás que se trata de aislar el problema y sacarlo fuera. Ahí está la gracia del TDD (¡aunque primero hemos partido del código ya hecho, así que no es TDD como tal!). Si algo no es testeable fácilmente es que tienes un problema de diseño.

¿Cual es nuestro problema? Pues la llamada a Math.random(), que es la que introduce el no-determinismo o aleatoriedad en nuestro procedimiento. Para más problemas, se trata de una llamada a un método estático, con lo que no podremos mockearlo a menos que usemos Powermock como bien explica José Manuel Sánchez Suárez, y esto no suele ser una buena idea para hacer test unitarios, así que no lo vamos a usar.

En definitiva, vamos a refactorizar el código del método estático de nuestra clase de utilidades que a su vez llama a otro método estático para hacerlo más testeable:

3.1. Alternativa 1: la aleatoriedad como parámetro numérico

Al fin y al cabo el número aleatorio es un número de tipo double entre 0,0 y 1,0. Lo podemos incluir en los parámetros de entrada, y así poder especificar el que queramos en el test.

¡No olvidéis darle un buen nombre, tal y como nos recomienda Uncle Bob en su libro Clean Code:

public static String returnRandomStringFromStringList(List listWithStrings, double aRandomNumberBetween0and1) {
	int rndIndex = (int) (aRandomNumberBetween0and1 * listWithStrings.size());
	return listWithStrings.get(rndIndex);
}

Y el test ya sería determinista y siempre se obtendría el mismo resultado:

@Test
public void shouldReturnRndElementOfGivenListOfString_WhenUsingAlternative1() {
	double aRandomDoubleNumber = 0.4d;
	String result = MyRandomUtilsAlternative1.returnRandomStringFromStringList(listWithStrings, aRandomDoubleNumber);
	assertEquals("Dos", result);
}

Podemos ver cómo le estamos pasando el valor 0.4d por parámetro, que para el conjunto de prueba, siempre debe darnos «Dos» como resultado. Sólo queda confiar en que los que usen esta función se acuerden de meter el valor aleatorio de verdad por parámetro.

3.2. Alternativa 2: Interfaz como parámetro

La solución del número puede tener el problema de que estamos confiando en que el programador que use nuestra función no se va a olvidar de interpretar correctamente el parámetro y no a a poner un número de tipo double cualquiera que no varíe. Aunque hemos elegido un nombre significativo, «aRandomNumberBetween0and1», puede que lo olvide

En programación orientada a objetos, aunque estemos con métodos estáticos, mejor dejarlo explícito en un mecanismo claro, y lo podemos hacer a través de una interfaz:

public interface RndNumberGenerator {
	double getNextRndNumber();
}

La interfaz define que debe existir un cierto mecanismo que a la hora de implementarse (y es obligada la implementación) que provea un número de tipo double, que por el nombre parece aleatorio. Veamos cómo cambia nuestro método:

public static String returnRandomStringFromStringList(List listWithStrings, RndNumberGenerator aRandomNumberGeneratorInstance) {
	int rndIndex = (int) (aRandomNumberGeneratorInstance.getNextRndNumber() * listWithStrings.size());
	return listWithStrings.get(rndIndex);
}

Ahora recibe por parámetro una instancia de la interfaz RndNumberGenerator que hemos definido.

¿Qué ganamos con esto?

Pues que al tratarse de una instancia, podemos hacer un simple Test Double para imitar una implementación, que en vez de devolver un número aleatorio cualquiera, devuelva uno que nosotros queramos para el test. Así queda el test, haciendo uso de las lambdas de Java8:

@Test
public void shouldReturnRndElementOfGivenListOfString_WhenUsingAlternative2()
{
	RndNumberGenerator rndNumberGenerator = () -> 0.4d;

	String result = MyRandomUtilsAlternative2.returnRandomStringFromStringList(listWithStrings, rndNumberGenerator);
	assertEquals("Dos", result);
}

Podemos ver cómo hemos creado un objeto de la interfaz RndNumberGenerator que devuelve también 0.4d cada vez que se le invoca. Así hemos hecho determinista nuestro test y a la vez hemos mejorado el diseño, dejando explícito que debe existir un mecanismo que devuelva un número aleatorio, y se hace implementando la interfaz. Seguramente en producción, en vez de usar ese Test Double, se usará uno que devuelva una llamada a Math.random() directamente.

Además estamos desacoplando la implementación del generador de números aleatorios, con lo cual, para el desarrollador será muy fácil cambiar el generador de números aleatorios sencillo por uno diferente, con mayor aleatoriedad o que simplemente tome el valor de otro tipo de distribución y no sólo de una uniforme, sino de una normal o Gausiana por ejemplo.

3.3. Alternativa 3: Inyectar dependencia a la clase de utilidades

Esta opción me parece menos adecuada porque hemos dicho que las clases de utilidades no tienen que tener estados ya que son estáticas. Esto no quita para que puedan tener dependencias, estáticas claro.

Idealmente, las dependencias deben darse en el constructor, para obligar a que cuando se llame a algún método, las dependencias ya estén construídas y no haya problemas de NullPointer.

Pero como estamos hablando de una clase final de utilidades con un constructor privado que nadie puede invocar, entonces es imposible cumplir con la regla del párrafo anterior. No obstante vamos a ello, pero quede aquí la advertencia.

Esta alternativa consiste en sacar del parámetro la llamada a la implementación de la interfaz del generador del número aleatorio, y meterla como dependencia estática en nuestra clase de utilidades, creando un campo estático y un setter:

public final class MyRandomUtilsAlternative3 {

	private static RndNumberGenerator aRandomNumberGenerator;

	private MyRandomUtilsAlternative3() throws InstantiationException{
		throw new InstantiationException();
	}

	public static String returnRandomStringFromStringList(List listWithStrings) {
		int rndIndex = (int) (aRandomNumberGenerator.getNextRndNumber() * listWithStrings.size());
		return listWithStrings.get(rndIndex);
	}

	public static void setaRandomNumberGenerator(RndNumberGenerator aRandomNumberGenerator) {
		MyRandomUtilsAlternative3.aRandomNumberGenerator = aRandomNumberGenerator;
	}
}

El test unitario de esta alternativa sería:

@Test
public void shouldReturnRndElementOfGivenListOfString_WhenUsingAlternative3(){
	RndNumberGenerator rndNumberGenerator = () -> 0.4d;
	MyRandomUtilsAlternative3.setaRandomNumberGenerator(rndNumberGenerator);

	String result = MyRandomUtilsAlternative3.returnRandomStringFromStringList(listWithStrings);
	assertEquals("Dos", result);
}

Es decir, se crea la instacia de RndNumberGenerator y se llama al setter para que la establezca para la clase completa. Ya puedes imaginar que si en algún momento se quita esa referencia (estableciéndola a null o poniendo otro objeto), se puede liar una buena. Y no, no se puede poner la referencia como final ni eliminar el setter y cambiarlo por un bloque static porque no sería testeable, que es lo que estamos buscando.

Quizá después de todo podría trasladar los métodos no-deterministas a una clase a modo de servicio donde no fueran estáticas y se pudiera especificar esta dependencia en un constructor… Pero para eso haría falta tener más información sobre la estructura de la aplicación.

Estas son las tres alternativas que se me han ocurrido. Si conoces alguna más, me encantaría me ayudarías mucho indicándola en los comentarios ^_^

4. Conclusiones

La testeabilidad de un código es muy importante, más que nuestro código sea conciso o muy encapsulado, y por tanto, merece la pena hacer modificaciones en el mismo para facilitar su testeo.

Los métodos no deterministas están formados por un código determinista más una generación aleatoria de números desde una distribución de probabilidad.

Para testear los métodos no-deterministas, lo que hay que hacer es separar la parte determinista de la generación del número aleatorio, que se podrá ver como una dependencia.

En nuestro ejemplo estamos usando método estáticos que hacen llamadas a otros métodos estáticos. Hemos visto cómo mejorar el código para hacerlo más legible e indirectamente comunicar al programador que lo use cómo se tiene que hacer, a través de la especificación de las interfaces de las dependencias. Y además lo hemos hecho más flexible, dando la oportunidad de cambiar el tipo de generador de número aleatorio.

5. Referencias

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad