El Arte de la Guerra... del Testing: Dobles de tests (Codemotion 2024)
El 21 de Mayo de 2024 tuve la oportunidad de dar una charla en Codemotion sobre los dobles de test, y cómo estos pueden ayudarnos a mejorar la calidad de nuestro código. Fue mi primera charla en un evento, y la verdad es que me lo pasé genial. Esta charla es una breve introducción a una charla más completa, que daré en algún otro evento en algún futuro.
A continuación comparto con vosotros el contenido que comente en la charla.
Introducción
Buenas tardes a todos, mi nombre es Aitor Santana y vengo desde la soleada Gran Canaria. Soy un apasionado de los videojuegos, un hobby que me ha acompañado desde mi infancia. Sin embargo, mi carrera profesional ha tomado un camino diferente pero igualmente emocionante: el desarrollo web. Actualmente, trabajo del lado del backend con Java en Lean Mind. Aunque no desarrollo videojuegos, la resolución de problemas y creatividad que he desarrollado jugando, me han sido muy útiles en mi carrera. Estoy aquí para compartir con ustedes mi experiencia en el fascinante mundo del testing de software.
Contexto
Para empezar me gustaría daros un poco de contexto.
Cuando aprendí a manejar con más o menos soltura el desarrollo de test, y empecé a profundizar me tope con una piedra en el camino: los dobles de prueba. Entender este tipo de estructuras puede ser un gran desafío, ya que existen varios tipos que se adaptan a diferentes casos de uso.
Para mí fue un concepto complicado de entender, y mucha gente que conozco paso por este mismo problema, así que se me ocurrió preguntarle a nuestro querido chat-gpt.
Esta fue la estadística que saco, seguramente no podamos fiarnos de estos datos al 100%, pero sí que nos muestra que para la gran mayoría son conceptos algo complejos, es por ello que me gustaría ayudar a todo aquel que esté empezando, a entender un poco mejor este tipo de herramientas. Creo que esto es como el arte de la guerra, conoce bien a tu enemigo para saber como combatirlo.
Tipos de Pruebas
Para apoyarme voy a utilizar este pequeño esquema que saqué del libro “La artesanía del código limpio de Robert C. Martin”, que representa los tipos de dobles como unan estructura jerárquica, como si estuviéramos hablando de herencia entre comillas.
Para mí fue lo que termino de hacerme clic en la cabeza, lo que termino por hacer encajar las piezas del puzzle. Esto que vemos es la terminología de Meszaros, apareció por primera vez en el libro “xUnit Test Patterns: Refactoring Test Code” de Gerard Meszaros. Creo que es importante conocer un poco cada tipo de doble, porque en las librerías de test generalmente están un poco difusos, y muchas veces es eso lo que nos lleva a confusión.
LoginDialog
Para apoyarme y explicar los diferentes tipos de doble, me apoyaré en un pequeño ejercicio.
public interface Authenticator {
boolean authenticate(String username, String password);
}
public class LoginDialog {
private final Authenticator authenticator;
private boolean isOpen = false;
public LoginDialog(Authenticator authenticator) {
this.authenticator = authenticator;
}
public boolean submit(String username, String password) {
if(isOpen) {
close();
return authenticator.authenticate(username, password);
}
return false;
}
// More code...
}
Tenemos esta interfaz Authenticator y la clase LoginDialog, esa interfaz representa el contrato que debe seguir el colaborador que pasemos por constructor a la clase. El objetivo es testear el comportamiento del diálogo de Login y utilizar los distintos dobles para hacer nuestras pruebas. Al final de la sesión tendrán un repo con el boilerplate, como los diferentes ejemplos de código que veremos en la sesión.
Dummy
Empecemos con el tipo de doble más sencillo, el dummy. Un dummy es un tipo de doble de prueba que se utiliza cuando necesitas pasar un objeto a un componente bajo prueba, pero el comportamiento del doble no es relevante para la prueba en cuestión.
public class AuthenticatorDummy implements Authenticator {
@Override
public boolean authenticate(String username, String password) {
return false;
}
}
@Test
void when_closed_login_is_canceled() {
Authenticator authenticator = new AuthenticatorDummy();
LoginDialog dialog = new LoginDialog(authenticator);
dialog.show();
dialog.close();
assertFalse(dialog.isOpen());
}
Nosotros queremos testear que se cierre el modal cuando le damos a cancelar, por lo que un dummy nos viene como anillo al dedo. Creamos un dummy que implemente la interfaz, para que podamos inyectarlo en nuestro Login, que devuelva cualquier cosa, nos da igual.
No es relevante que el usuario esté autenticado para testear el comportamiento del modal.
Stub
El siguiente paso es el stub. Un stub es un tipo de doble de prueba que, a diferencia de un dummy, proporciona respuestas predefinidas a las llamadas que se le hacen durante la prueba, tienen estado o memoria.
Esto nos permite especificar el resultado deseado sin necesidad de interactuar con el sistema real de autenticación.
Por ejemplo si quisiéramos probar que una autenticación falle, teniendo en cuenta la interfaz Authenticator
,
podríamos hacer lo siguiente:
public class AuthenticatorStub implements Authenticator {
private final boolean allowLogin;
public AuthenticatorStub(boolean allowLogin) {
this.allowLogin = allowLogin;
}
@Override
public boolean authenticate(String username, String password) {
return allowLogin;
}
}
@Test
public void when_authorizer_deny_login_work_well() {
Authenticator authenticator = new AuthenticatorStub(false);
LoginDialog dialog = new LoginDialog(authenticator);
dialog.show();
boolean success = dialog.submit("username", "password");
assertFalse(success);
}
Nos creamos un stub al que le pasemos por constructor el resultado que nosotros queramos que tenga nuestro authenticator, y lo devolvemos en el método authenticate. De esta forma en los test podemos crear un Stub que falle la autenticación de manera programática, y sin depender de la implementación real de nuestro artefacto.
Spy
Continuemos con el siguiente tipo de doble, el Spy. Un Spy se utiliza para verificar si ciertas acciones han sido realizadas en el objeto, como la invocación de métodos con parámetros específicos, sin interrumpir el flujo natural de la prueba. Además de proporcionar respuestas predefinidas como un Stub, también registra información sobre cómo se utiliza durante las pruebas, por lo que podemos hacerle preguntas (Datos con los que ha sido llamado, cuántas veces ha sido llamado, etc). Este tipo de artefactos vienen muy bien cuando tenemos métodos que no devuelven nada, pero a los que queremos hacer seguimiento.
Volvamos de nuevo al código:
public class AuthenticatorSpy implements Authenticator {
private final boolean allowLogin;
private int calls = 0;
private String registeredUserName;
private String registeredPassword;
public AuthenticatorSpy(boolean allowLogin) {
this.allowLogin = allowLogin;
}
@Override
public boolean authenticate(String username, String password) {
calls ++;
registeredUserName = username;
registeredPassword = password;
return allowLogin;
}
public int calls() {
return calls;
}
public String registeredUserName() {
return registeredUserName;
}
public String registeredPassword() {
return registeredPassword;
}
}
@Test
void loging_dialog_correctly_invokes_authenticator() {
AuthenticatorSpy authenticatorSpy = new AuthenticatorSpy(true);
LoginDialog dialog = new LoginDialog(authenticatorSpy);
dialog.show();
boolean success = dialog.submit("user", "pw");
assertTrue(success);
assertEquals(1, authenticatorSpy.calls());
assertEquals("user", authenticatorSpy.registeredUserName());
assertEquals("pw", authenticatorSpy.registeredPassword());
}
Aquí vemos un ejemplo de implementación de un Spy. Como vemos no difiere mucho del Stub que teníamos previamente, la diferencia es que ahora guardamos los parámetros con los que se llama al método authenticate, y registramos el número de veces que se llama.
De esa forma en esta prueba, nos aseguramos de que LoginDialog
invoca correctamente a Authenticator
, comprobando de
que solo se llame una vez a authenticate
y con los argumentos que se han pasado en él submit
.
Un spy puede ser tan simple cómo un único booleano que se establece cuando se llama a un método en particular, o algo más complejo.
Son útiles para garantizar que el algoritmo está probándose de manera correcta, sobre todo en código en el que no tenemos tanto control como el código legacy. Por lo que podemos añadir cierta seguridad sobre ese código en el que a priori no tenemos control.
Mock Estricto
Llegamos al último tipo de doble de la parte izquierda del diagrama que os enseñaba al inicio, los Mocks estrictos. Este es el doble que suele dar nombre a todos los demás, ya que normalmente cuando hablamos de dobles hablamos de mocks. Un Mock estricto es aquel que no solo simula el comportamiento de un objeto, sino que también verifica que se realicen llamadas esperadas a sus métodos con parámetros específicos, y falla la prueba si se realiza alguna llamada inesperada o si las llamadas esperadas no ocurren en el orden definido. Dicho de otro modo, las aserciones de la prueba se realizan en el mock.
public class AuthenticatorStrictMock implements Authenticator {
private boolean authenticateCalled = false;
private final String expectedUsername;
private final String expectedPassword;
private final boolean authenticationResult;
public AuthenticatorStrictMock(String expectedUsername, String expectedPassword,
boolean authenticationResult) {
this.expectedUsername = expectedUsername;
this.expectedPassword = expectedPassword;
this.authenticationResult = authenticationResult;
}
@Override
public boolean authenticate(String username, String password) {
if (!expectedUsername.equals(username) || !expectedPassword.equals(password)) {
throw new AssertionError("Authenticator was called with unexpected arguments");
}
if (authenticateCalled) {
throw new AssertionError("Authenticator authenticate method called more than once");
}
authenticateCalled = true;
return authenticationResult;
}
public void verify() {
if (!authenticateCalled) {
throw new AssertionError("Expected authenticate method was not called");
}
}
}
@Test
void login_dialog_correctly_invokes_authenticator() {
AuthenticatorStrictMock authenticatorMock = new AuthenticatorStrictMock("user", "password", true);
LoginDialog dialog = new LoginDialog(authenticatorMock);
dialog.show();
dialog.submit("user", "password");
authenticatorMock.verify();
}
Como podemos ver en esta implementación, tenemos varias validaciones para verificar que todo vaya como queremos, por lo que sí que tenemos cierta lógica de control en este doble.
Por último tenemos el método verify, que será el que valide si nuestro test ha ido bien, lanzando una excepción en caso de no se haya podido hacer la llamada de autenticación.
Así podemos garantizar que el inicio de sesión ha tenido éxito, y que las expectativas del mock se han cumplido.
Así es como se vería el test, como vemos es el Mock el que verifica si el test pasa o no. El mock estricto nos puede ayudar a diseñar si partimos de un código nuevo, ya que nos permite definir cierta lógica de negocio y nos guía en el diseño de nuestro código de producción. Sin embargo, debemos tener en cuenta de que nuestros tests pueden llegar a ser frágiles en el caso de que alguna regla de negocio cambie, por ejemplo imaginemos que la autenticación ya no sea por usuario y contraseña, sino que sea a través de la cuenta de Google, nuestros tests se romperían. Por lo que debemos usarlos con cuidado.
Fake Object
Por último nos pasamos a la rama derecha del diagrama, el siguiente tipo de doble es el Fake Object. Es un objeto que simula el comportamiento real del artefacto, a diferencia de los stubs o mocks, que generalmente solo simulan respuestas a llamadas específicas, un fake implementa algunas reglas de negocio de manera rudimentaria o simplificada. Nos permiten mantener estados internos, permitiendo que se use en pruebas más complejas o integradas.
public class AuthenticatorFake implements Authenticator {
@Override
public boolean authenticate(String username, String password) {
return username.length() == 5 & password.length() == 8;
}
}
@Test
void bad_password_attempt_login_fail() {
AuthenticatorFake authenticatorFake = new AuthenticatorFake();
LoginDialog dialog = new LoginDialog(authenticatorFake);
dialog.show();
boolean success = dialog.submit("user", "pw");
assertFalse(success);
}
En este ejemplo vemos que hemos implementado una cierta lógica, y es que el usuario tiene que ser igual a “user” y la contraseña igual a “good password”. Como vemos esto podría simular perfectamente el comportamiento del artefacto Authenticator real, pero una lógica mucho más simple.
Los fakes son especialmente útiles en entornos de prueba donde interactuar con el verdadero sistema o componente sería impracticable, costoso o lento, proporcionando una simulación lo suficientemente buena para permitir una variedad de pruebas.
El problema con los Fake es que, a medida que la aplicación crezca, siempre habrá más condiciones que comprobar. Como consecuencia los Fake tienen a crecer por cada nueva condición, si añadimos un campo email, o un campo teléfono, ya tendríamos que validar más cosas. Pudiendo ser tan grandes y complejos que necesiten sus propias pruebas.
Dobles de test con librerías de terceros
Todo esto está muy bien, hemos visto los diferentes tipos de dobles de test (dummy, stub, spy, mock y fak object), pero generalmente en nuestro día a día no vamos a picarnos nuestro propio doble, normalmente haremos uso de librerías de tests que nos faciliten el desarrollo. Eso sí, recomiendo utilizarlas cuando tengamos lo más claro posible los diferentes tipos de doble, ya que como dije al principio, normalmente en las librerías los conceptos están un poco mezclados.
El ejemplo que les quiero enseñar es con Mockito, una librería de Java.
@Test
void correctly_invokes_authenticator() {
Authenticator authenticatorMock = Mockito.mock(Authenticator.class);
LoginDialog dialog = new LoginDialog(authenticatorMock);
when(authenticatorMock.authenticate("user", "pw")).thenReturn(true);
dialog.show();
boolean success = dialog.submit("user", "pw");
assertTrue(success);
verify(authenticatorMock, times(1)).authenticate("user", "pw");
}
Como vemos definir un doble es tan simple como usar el método mock
y pasarle la clase que queremos que simule. Esto
nos va a permitir definir la respuesta que queramos que tenga el mock ante ciertos parámetros de entrada, a través del
método when
.
También podemos verificar cuantas veces y con qué parámetros se ha llamado al método authenticate, a través del método
verify
, como podríamos hacer con un Spy.
Como vemos en Mockito está todo un poco unido, pero es una herramienta superpotente que nos va a permitir hacer dobles
de una manera sencilla.
Cierre
Me gustaría dejarles algunos recursos, el primer libro artesanía del código limpio, del que saque el diagrama y que explica diferentes técnicas para desarrollar haciendo código de calidad. xUnit Tests Patterns, donde nace la terminología de Meszaros sobre dobles de test. Y código sostenible de Carlos Blé, que junto al de artesanía del código limpio nos van a ayudar a aumentar el nivel de nuestros desarrollos.
Aunque si os gusta más practicar, también tenéis disponibles los cursos de Testing Sostenible y Diseño Sostenible de Savvily.
Tenéis la charla disponible en el canal de Lean Mind, por lo que podéis disfrutarla si no tuvisteis la oportunidad de verla en directo.
Dejo por aquí el enlace a las diapositivas de la charla, y el enlace al repositorio con el código de los ejemplos que hemos visto.