A veces, estamos testeando un componente que tiene dependencias con apis de terceros o incluso se conecta a una base de datos para recuperar cierta información. Pero, lo que realmente queremos testear es el comportamiento del componente. Imaginemos que siempre que existen dependencias, realizamos una conexión con la base de datos, en un proyecto pequeño el rendimiento de los tests podría ser inapreciable, pero en un proyecto grande con cientos, incluso miles de tests, la duración de todos ellos podría ser inmensa. Aquí es cuando entran en juego los dobles de tests para simular dicho comportamiento que nos permita centrarnos y testar solo lo que realmente necesitamos. Permiten “engañar” al código para que se crea que colabora correctamente con otras clases, es como si fueran los dobles de las películas para las escenas peligrosas.
Existen los siguientes tipos de dobles ordenados de menor a mayor complejidad:
dummy: se usa cuando no nos importa cómo se colabora con este objeto. Por
ejemplo, cuando sabemos que no se va a usar en absoluto. Lo necesitamos porque nos interesa su interfaz pero no su implementación. La implementación de los métodos de estos dobles no hacen nada y devuelven null. Normalmente, se usa para rellenar una lista de parámetros.
class DummyRepositoryClass implements RepositoryClass {
@Override
public String getHelloWorld() {
throw new RuntimeException("Not expected to be called"); }
}
class ServiceTest{
@Test
public void example_dummy_test() {
DummyRepositoryClass dummy = new DummyRepositoryClass();
ServiceClass myService = new ServiceClass(dummy);
Se debe tener en cuenta que el uso de un framework de mocks también es una alternativa al ejemplo anterior y suele ser más común. Si usamos, por ejemplo, mockito, se haría de la siguiente forma:
stub: es como un dummy pero que devuelve valores fijos distintos de null. Por
ejemplo, un método de autenticación devolvería siempre true y así podríamos usar este doble para probar todos los escenarios donde la autenticación ha sido correcta, sin necesidad de hacer la llamada real. En el ejemplo anterior, el método
getHelloWorld(), podría devolver siempre la misma string, esto se consideraría
un stub. Como vimos antes, esto también se podría hacer con un mock, donde podremos especificar el valor que queremos que devuelva siempre.
spy: es como un stub pero que espía a quien lo llama. Esto permite luego,
comprobar el número de veces que se ha llamado al método, el número de argumentos que se le pasan, etc. Estos dobles son peligrosos porque acoplan el test con la implementación concreta, lo que provocará que si se cambia la implementación, aunque no cambie el comportamiento, el test fallará. Son tests frágiles, por lo que debemos evitarlos.
Mockito nos ofrece el método verify(), que comprueba que se llama al método e incluso el número de veces que ha debido ser invocado. Por ejemplo:
}
DummyRepositoryClass dummy = mock(DummyRepositoryClass.class);
private final AuthenticationService spyAs = mock(AuthenticationService.class); ...
when(spyAs.isAuthenticated()).thenReturn(true); ...
En el código anterior se comprueba que efectivamente se ha llamado al método isAuthenticated() una sola vez (el valor por defecto). Si quisiéramos comprobar que se ha llamado tres veces:
verify(spyAs, times(3)).isAuthenticated();
mock: es como un spy que sabe lo que está probando exactamente. Así, al propio
mock, en la sección de aserciones, se le preguntará si ha ido bien o mal el test. El mock sabe el comportamiento de cómo se debe llamar al doble, cuántas veces se le ha llamado, con qué parámetros, etc. Es una de las formas más conocidas y usadas hoy en día por los desarrolladores ya que ofrece múltiples opciones para probar nuestro código.
Vamos a ver con Mockito dos ejemplos sobre cómo especificar el resultado que queremos que nos devuelva nuestro mock. Imaginemos que tenemos un servicio que devuelve una lista de productos de una tienda. Esa lista es del tipo
List<Product>. La primera forma que veremos a continuación es type safe, esto quiere decir que tiene en cuenta el tipo devuelto y por tanto, nos saldría un error en tiempo de compilación indicandonos que se espera una lista de productos y se está devolviendo una string:
La segunda forma no es type safe, esto quiere decir que no tiene en cuenta el tipo devuelto y por tanto, no nos saldría ningún error en tiempo de compilación pero sí al ejecutar el test, provocando su fallo:
fake: es un tipo totalmente distinto a los anteriores. Un fake implementa los
métodos con lógica de negocio, es como un simulador que puede ser muy sencillo o extremadamente complicado. Por ejemplo, si usamos una base de datos en memoria para simular una base de datos real, esta base de datos en memoria se
when(productService.getProducts()).thenReturn("This should be a list of products, not a string") //Shows an error
doReturn("This should be a list of products, not a
considera un fake.
De forma coloquial, también es muy común denominar a todos estos dobles como “mocks”.
Recomendaciones
El principal objetivo de los tests es comprobar que todas las partes implicadas de una aplicación queden libres de errores de forma unitaria e integrada para
prevenir problemas en sucesivas fases del ciclo de vida del proyecto.
FIRST
Si bien los propios tests deben perseguir también un buen diseño, para evitar que la propia infraestructura de tests se convierta en un problema, debería cumplir con el principio FIRST:
F
ast: los tests deben ser de rápida ejecución, por eso debemos poner especialénfasis en implementar tests unitarios y, solo test de integración en aquellos casos en los que realmente necesitemos el contexto de un sistema externo para ser ejecutados. Si nombramos correctamente los tests de integración, podemos definir una fase concreta para la ejecución de los mismos dentro del ciclo de vida de Maven, pudiendo ahorrar la ejecución de tal fase en una build normal y recopilar estadísticas de cobertura independientes distinguiendo entre tests unitarios y de integración.
I
ndependent: para facilitarnos la tarea de detección de errores es muy importanteque los tests sean independientes los unos de los otros. Para lograrlo debemos evitar que las salidas de unos tests sean utilizadas como entradas de otros y no debería importar el orden en el cual se vayan a ejecutar los tests, ya que cada ejecución debe ser independiente de la otra. Si tenemos una batería de tests de integración contra base de datos, debemos mantener la transaccionalidad en las operaciones, de modo que el entorno siempre quede consistente tras su
ejecución.
R
epeatable: deben soportar su ejecución más de una vez sin cambiar el resultadoni el estado del sistema independientemente de su entorno o contexto.
S
elf-validating: deben ser autoevaluables, es decir, que el propio test identifiquesi el test ha funcionado correctamente o no. Esta autoevaluación se realiza mediante aserciones (asserts).
T
imely: deben escribirse en el momento oportuno, es decir antes del código deproducción, y el motivo es muy simple: es más fácil hacer tests para un código que todavía no está escrito que para uno que ya ha sido creado, del mismo modo que es más fácil hacer crecer recto un árbol que todavía no ha brotado con una guía, que enderezar uno que tiene varios metros de altura.