Es bastante conocido que debemos favorecer la composición sobre la herencia.
Lo que nos sigue costando asimilar, en general, es llevarnos las buenas prácticas de código de producción también al código de tests.
Los tests es código que igualmente evoluciona, que requiere nuevas features y por lo tanto vamos a tener que mantener y es necesario que sea flexible para evitarnos problemas futuros.
Hay ciertas situaciones muy comunes donde podemos caer en la tentación de utilizar herencia en los test con el objetivo de reutilizar cierta lógica que se repite.
Test de integracón contra una base de datos
En las aplicaciones móviles es habitual tener una base de datos como cache de los resultados del servidor.
Por lo tanto, lo habitual es tener pruebas de integración que verifican que en la base de datos se almacena lo que se debe y cuando se debe.
Cuando empiezas con este tipo de tests llega un momento donde te das cuenta que no quieres compartir la misma base de datos que utilizas en los tests, para que no haya datos mezclados de diferentes pruebas que nos lleven a tener errores o falsos resultados en los tests.
Al empezar un test, lo ideal es tener una base de datos limpia para poder prepararla con los datos adecuados para cada prueba.
Es habitual utilizar los métodos que se ejecutan al iniciar ó finalizar cada test y que normalmente cualquier framework de test tiene.
Este sería un ejemplo en Android utilizando JUnit y Realm como base de datos.
public class UserRealmDataSourceShould{
@Before
public void setUp() {
Realm.init(InstrumentationRegistry.getContext());
RealmConfiguration realmConfiguration = new RealmConfiguration.Builder().build();
Realm.deleteRealm(realmConfiguration);
}
@Test
public void save_user...() {
}
@Test
public void delete_user...() {
}
}
En este caso, en el metodo setup que se va a ejecutar al inicio de cada test lo que estamos haciendo en borrar la base de datos.
En otras situaciones y si el ORM o API de base de datos lo permite, como si sucede con Realm, podemos utilizar una base de datos en memoria pero igualmente tendremos que crearnos una instancia nueva en cada test o vaciar los datos al finalizar la prueba.
El problema es que a medida que tenemos bastantes tests de este tipo, el código se repite.
Una posible solución es utilizar herencia, nos creamos una clase base para este tipo de tests y en ella añadimos la lógica de borrado de base de datos, de crear nueva instancia o limpiar según la solución que hayamos elegido:
public abstract class StoreTestCase {
@Before
public void setUp() throws IOException {
Realm.init(InstrumentationRegistry.getContext());
RealmConfiguration realmConfiguration = new RealmConfiguration.Builder().build();
Realm.deleteRealm(realmConfiguration);
}
@After
public void tearDown() throws IOException {
}
}
Y luego simplemente heredamos de esta clase base en nuestras clases de tests:
public class UserRealmDataSourceShould extends StoreTestCase {
@Test
public void save_user...() {
}
@Test
public void delete_user...() {
}
}
Test de integración contra un servidor
Otra práctica habitual al desarrollar una aplicación móvil es tener tests de integración contra el servidor.
El objetivo de validar el comportamiento de nuestra aplicación en diferentes situaciones de respuesta del servidor.
En este tipo de test donde podemos validar nuestro cliente de la API es común volver a hacer uso de los metodos que se ejecutan al inicio y al final de cada test.
En particular en Android existe una librería llamada MockWebServer, que nos permite preparar respuestas especificas que podria devolver el servidor para validar el comportamiento de nuestra aplicación.
public class UserRetrofitDataSourceShould{
private MockWebServer server;
@Before
public void setUp() {
this.server.start();
}
@After
public void tearDown() {
server.shutdown();
}
@Test
public void parse_user...() {
...
enqueueMockResponse("UserResponse.json")
...
}
private void enqueueMockResponse(String fileName) throws IOException {
String body = fileReader.getStringFromFile(fileName);
MockResponse response = new MockResponse();
response.setResponseCode(200);
response.setBody(body);
server.enqueue(response);
}
}
En este tipo de tests se vuelve a dar la circunstacia de tener código duplicado a medida que crece el número de tests donde se usa MockWebServer.
De nuevo una solución puede ser la herencia, creandonos una clase base con el comportamiento común.
public abstract class MockServerTestCase{
private MockWebServer server;
@Before
public void setUp() {
this.server.start();
}
@After
public void tearDown() {
server.shutdown();
}
protected void enqueueMockResponse(String fileName) throws IOException {
String body = fileReader.getStringFromFile(fileName);
MockResponse response = new MockResponse();
response.setResponseCode(200);
response.setBody(body);
server.enqueue(response);
}
}
Y heredando de esta clase base en aquellos tests que lo necesiten:
public class UserRetrofitDataSourceShould extends MockServerTestCase{
@Test
public void parse_user...() {
...
enqueueMockResponse("UserResponse.json")
...
}
}
Ampliando el alcance de los test
Estas dos situaciones que hemos visto de compartir código cuando realizamos test de integración contra una base de datos y contra un servidor se pueden solucionar mediante herencia.
Pero ¿qué ocurre si queremos ampliar el alcance de los tests?, ¿Y si ahora se quiere probar las dos cosas juntas?.
Imaginemos que ahora se quiere probar la integración del repositorio con los dos datasources, el remoto y el local.
Podemos querer verificar que dependiendo de la respuesta del servidor se realiza una inserción específica de datos en la cache.
Aquí tenemos un problema si hemos elegido la herencia ya que en java no existe herencia múltiple.
¿Qué hacemos nos creamos una mega clase base con todas las opciones posibles?
Para tener una mayor flexibilidad es mejor utilizar composición.
Al utilizar composición voy a poder decidir si tengo tests donde reutilizo el código común de infraestructura de base de datos y además el de MockWebServer o si los utilizo por separado.
Utilizando composición
Aplicar la composición como solución al código duplicado como el que hemos visto que puede existir en ciertos tests podemos afrontarlo de varias maneras.
La más sencilla es tener las clases donde esta alojado el código cómun. De forma similar a cómo alojabamos este código en las clases base pero sin utilizar herencia.
Podríamos tener una clase para el código referente a preparar respuestas del servidor.
public abstract class MockServer{
private MockWebServer server;
public void start() {
this.server.start();
}
public void shutdown() {
server.shutdown();
}
protected void enqueueMockResponse(String fileName) throws IOException {
String body = fileReader.getStringFromFile(fileName);
MockResponse response = new MockResponse();
response.setResponseCode(200);
response.setBody(body);
server.enqueue(response);
}
}
Y luego utilizarlo desde las clases de test:
public class UserRetrofitDataSourceShould{
private MockServer mockServer = new MockServer();
@Before
public void setUp() {
mockServer.start();
}
@After
public void tearDown() {
mockServer.shutdown();
}
@Test
public void parse_user...() {
...
mockServer
.enqueueMockResponse("UserResponse.json")
...
}
}
De esta forma resolvemos el problema de flexibilidad que nos plantea la herencia pero seguimos teniendo cierto código duplicado en los metodos cleanUp y tearDown.
Podemos ir un pasito más allá y utilizar un tipo de clases que suelen traer los framework de test y que sirven para separar de la propia clase de test cierta lógica que debe ejecutarse al inicio y fin de los test.
En el caso de Java y Junit existen las rules y en el caso de .Net y xUnit los attributes.
Con JUnit lo que tendriamos que hacer es crear en nuestro caso dos rule personalizadas:
Una rule personalizada para la lógica de infraestructura de base de datos:
public class DatabaseRule implements TestRule {
@Override
public Statement apply(final Statement base, Description description) {
return new Statement() {
@Override
public void evaluate() throws Throwable {
//código a ejecutar antes del test
before();
base.evaluate();
//código a ejecutar después del test
after();
}
};
}
private void before() {
Realm.init(InstrumentationRegistry.getContext());
RealmConfiguration realmConfiguration = new RealmConfiguration.Builder().build();
Realm.deleteRealm(realmConfiguration);
}
private void after() {
}
}
Y otra para las respuestas preparadas del servidor:
public class MockServerRule implements TestRule {
private MockWebServer server;
...
@Override
public Statement apply(final Statement base, Description description) {
return new Statement() {
@Override
public void evaluate() throws Throwable
{
before();
base.evaluate();
after();
}
};
}
private void before() throws IOException {
server.start();
}
private void after() throws IOException {
server.shutdown();
}
public void enqueueMockResponse(int code, String response) throws IOException {
MockResponse mockResponse = new MockResponse();
mockResponse.setResponseCode(code);
mockResponse.setBody(response);
server.enqueue(mockResponse);
}
...
}
Ahora veamos como se utilizarían estas rules de forma conjunta en una clase de tests:
public class UserRepositoryShould{
@Rule
public MockServerRule mockServerRule = new MockServerRule(new AssetsFileReader());;
@Rule
public DatabaseRule rule = new DatabaseRule();
...
@Test
public void insert...() {
...
mockServerRule
.enqueueMockResponse("UserResponse.json")
...
}
}
Curso y artículos relacionados
Conclusiones
En el código de los tests como hemos visto podemos aplicar las mismas buenas prácticas que aplicamos en el código de producción.
Al fin y al cabo es código que vamos a tener que mantener y puede requerir nuevas features en el futuro.
Por lo tanto el código de test tiene que ser lo suficientemente flexible para adaptarse a los cambios.
Favorecer la composición sobre la herencia también en una buena inversión en los tests.