Mockito es una librería ampliamente extendida para la realización de los tests de nuestros desarrollos en Java, eso es una realidad incuestionable. Está tan popularizada que los frameworks más extendidos incluyen anotaciones y ayudas para que puedan integrarse fácilmente sin necesidad de emplear la cantidad de código que había que escribir para su configuración cuando esta librería surgió.

La práctica de “mockear” clases con esta librería está tan integrada culturalmente entre los equipos de desarrollo que, en bastantes ocasiones, tengo que dar largas explicaciones sobre mi práctica habitual de no utilizar mocks en mis pruebas y no incluir Mockito en mis proyectos (o, por lo menos, no utilizarlo con carácter general y sí en situaciones muy específicas).

Todas las argumentaciones que he recibido a lo largo de mi carrera en relación con la obligación de utilizar esta librería en las pruebas que se hacen desde desarrollo se acaban centrando en dos argumentarios:

  1. No haces test unitarios si no utilizas Mockito (entiéndase, si no utilizas mocks).
  2. Si no utilizas Mockito, estás haciendo test de caja negra y no de caja blanca.

En este artículo pretendo ofrecer mi visión sobre uso de mocks en general a la hora de realizar las pruebas de desarrollo. No entraré en disquisiciones sobre test unitarios, de integración, caja negra, caja blanca, end to end, etc. ya que creo que son cuestiones técnicas muy útiles a la hora de planificar una correcta estrategia de pruebas de un sistema, pero que no son la razón fundamental por la que los equipos de desarrollo deben incluir pruebas automatizadas de su software.

Hablemos de fundamentos

Después de un largo historial de proyectos, hay una serie de fundamentos que han acabado concretándose a la hora de abordar cualquier desarrollo. No pretendo con esto proponer un decálogo sobre pruebas de desarrollo, son solo el marco de trabajo que procuro promover en los equipos en los que trabajo. Estos fundamentos podrían resumirse en los siguientes puntos:

  1. Un/a desarrollador/a tiene la obligación de ofrecer un conjunto de pruebas fiable que demuestren que su desarrollo se comporta como se supone que debe comportarse. Forma parte de su trabajo.
  2. La mejor forma de detectar las carencias funcionales y estructurales de un código es usarlo.
  3. El desarrollador/a debe ser la primera persona en utilizar su código. Las pruebas son la evidencia de que sabe qué hace su código y cómo se usa (puede parecer una obviedad, pero, más a menudo de lo que parece, no está tan claro).
  4. Prueba funcionalidades, no implementaciones.
  5. Disponer de un sistema que garantice la funcionalidad de nuestro desarrollo es imprescindible para su mejora (en eficiencia, en estructura, en flexibilidad, en lo que sea necesario).

En principio parece algo sencillo y, con la mayor parte de las personas con las que he compartido estos fundamentos, estamos de acuerdo. Podemos tener diferencias sobre dónde está el límite entre el perfil de desarrollador/a y el de QA, o cuándo es importante probar detalles de una implementación concreta pero, en general, no solemos discrepar.

Las diferencias surgen cuando vamos a llevarlo a la práctica. Porque el diablo está en los detalles.

Show me the code

Para mostrar algunas de las carencias en los tests funcionales basados en mocks, vamos a poner como ejemplo el siguiente interfaz que define una determinada funcionalidad:

public interface CarStorage {

  void allocate(Car car);

  Car fetch(UUID id);

}

Esta interfaz está indocumentada deliberadamente para facilitar la tesis que pretendo explicar. Una prueba típica de una implementación de esta interfaz podría ser la siguiente:

@ExtendWith(MockitoExtension.class)
class CarStorageImplTest {

    @Mock
    private StoreRepository storeRepository;

    private CarStorage carStorage;

    @BeforeEach
    void setUp() {
        carStorage = new CarStorageImpl(storeRepository);
    }

    @Test
    void fetch_shouldReturnCar_whenCarExists() {
        // Arrange
        UUID carId = UUID.randomUUID();
        Car expectedCar = new Car("Toyota", "Corolla");

        when(storeRepository.find(carId)).thenReturn(expectedCar);

        // Act
        Car actualCar = carStorage.fetch(carId);

        // Assert
        assertEquals(expectedCar, actualCar);
        verify(storeRepository, times(1)).find(carId);
    }
}

Hasta aquí, todo bien. Es lo que el común de los equipos de desarrollo considera un test unitario. Sin embargo, dista mucho de cumplir con los fundamentos enunciados anteriormente. Por poner algunas cuestiones sobre la mesa.

Para ejecutar la operación fetch se precisa un id (cardId en el ejemplo). ¿De dónde se obtiene ese id? Si acabara de llegar al proyecto, este test no aporta información sobre cómo se usa el api y no tenemos por qué asumir que quien lo desarrolló tiene alguna idea de cómo se usa.

Esta prueba no está realmente utilizando el código (Fundamento 2) ni demuestra que quien lo programó sepa cómo funciona (Fundamento 3) porque, de hecho, no “utiliza” el código. Solo manipula el contexto de ejecución de la implementación para asegurar que las aserciones funcionan.

Por otro lado, supongamos que se requiere un cambio en nuestro componente. Ya no accede a una base de datos, sino que se integra con otra forma de acceso a la información (tan compleja como queráis imaginar) cambiando la estructura interna de la implementación. ¿Qué tendría que hacer entonces? Cambiar la prueba y los mocks creados.

¿Por qué tengo que cambiar una prueba si la funcionalidad que pretende garantizar no ha cambiado? Porque la prueba está grapada a la implementación, no a la funcionalidad violando el Fundamento 4.

¿Cómo garantizo la funcionalidad cuando estoy cambiando al mismo tiempo la implementación y las pruebas? Pues con un libro de oraciones al lado del teclado y rezando mucho. En otras palabras, tampoco cumplimos el Fundamento 5.

Según mi apreciación, este sistema de pruebas no puede considerarse fiable (Fundamento 1) por estar sujeto a todas las incertidumbres anteriores. O, por lo menos, lo suficientemente fiable como para ser una herramienta útil para el equipo de desarrollo de cara a realizar una de nuestras labores fundamentales durante el desarrollo, que es la reducción de la complejidad y el volumen del desarrollo a través de la refactorización.

Probemos una alternativa

Intentemos hacer una prueba de la siguiente forma:

class CarStorageTest {

    @Inject
    private CarStorage carStorage;

    @BeforeEach
    void setUp() {
        removeAll();
    }

    @Test
    @DisplayName("An allocated car can be retrieved through fetch operation.")
    void fetch_shouldReturnCar_whenCarExists() {
        // Arrange;
        Car expectedCar = new Car("Toyota", "Corolla");

        UUID carId = carStorage.allocate(expectedCar);

        // Act
        Car actualCar = carStorage.fetch(carId);

        // Assert
        assertEquals(expectedCar, actualCar);
    }
}

En este tipo de pruebas, la utilización del API está mucho más clara. Para la implementación del test, nos estamos apoyando en el sistema de inyección de dependencias del proyecto (Spring, el CDI de Quarkus, o el que debáis utilizar).

De entrada, aparece un problema estructural del API. La operación allocate, según está definida, no devuelve el cardId (uno de los objetivos del Fundamento 2). En este ejemplo puede parecer trivial, no obstante, os podéis encontrar con situaciones en que la lógica de procesamiento y persistencia de determinada información sea incompatible con la lógica de recuperación y validación de dicha información y que ninguno de vuestros tests mockeados fallen (como fue mi caso en un proyecto).

Por otro lado, se puede ver perfectamente cuáles son los escenarios de uso del API en relación con la operación fetch (Fundamentos 2 y 3). Esto es especialmente útil cuando se incorporan nuevos miembros al equipo. Si alguno necesita utilizar tu componente, es tan fácil como decir: “Mira los casos de test, allí están los escenarios de uso”. Te ahorras largas charlas de introducción y explicaciones, solo hay que enseñar el código.

En el caso de que se deba rediseñar o refactorizar la implementación, este test es invariable, no existe ningún detalle que lo grape a la implementación realizada y podemos utilizarlo como una garantía de que las modificaciones realizadas no rompen la funcionalidad (Fundamentos 4 y 5). Este aspecto se aprecia especialmente cuando se modifica algún detalle técnico dentro de la aplicación y no debemos modificar una enorme cantidad de tests con el consiguiente hartazgo y riesgo de fallo.

Qué utilizar cuando no usamos Mockito

Una vez que se comienza a implementar pruebas en las que no se utilizan mocks, siempre surge la cuestión de cómo armar la implementación a probar de forma que pueda funcionar para ser probada. Para ello, lo mejor es utilizar los recursos disponibles en el proyecto.

Diseña el software para ser testeable

En muchas ocasiones, nuestros propios diseños fuerzan la existencia de acoplamientos entre clases o módulos que son absolutamente innecesarios.

El uso de patrones (como strategy o template method), así como recursos de programación funcional tales como functions, suppliers, consumers, etc. pueden desacoplar la lógica de negocio de las fuentes y consumidores de información. Esto, además de hacer la lógica más flexible y reutilizable, permite crear implementaciones de estos recursos para realizar las pruebas sin necesidad de utilizar mocks.

Uso de stubs

Nada obliga a utilizar una implementación concreta para el repositorio, un sistema externo o cualquier dependencia que precisemos. Perfectamente, podemos utilizar una implementación propia (stubs) que emule aquella dependencia externa o interna de nuestro código. Una vez inyectada la implementación de stub, el código de la aplicación sigue funcionando sin problemas. Yo suelo crear stubs que emulan repositorios o sistemas externos persistiendo información en memoria o ficheros.

Cualquier sistema de inyección de dependencias debería permitirnos inyectar nuestras propias implementaciones stub para la realización de las pruebas. De esta forma, no necesitamos incluir en el test ningún código que instrumentalice partes de la aplicación como hacemos con los mocks.

Uso de testcontainers

Otra posibilidad disponible es utilizar testcontainers. En la actualidad, la tecnología y la potencia de nuestras máquinas ha avanzado lo suficiente para poder soportar el uso de Docker y tescontainers levantando contenedores para las bases de datos o para Mockserver.

En efecto, esto puede ralentizar nuestros tests unitarios pero, en mi caso, es algo asumible para obtener la ventaja de desacoplar los tests unitarios de la implementación de la aplicación existente en cada momento del desarrollo.

Utiliza las ventajas del bus

Cuando la arquitectura de tu aplicación está soportada por un bus (como puede ser vertx directamente o utilizando Quarkus), las distintas unidades de computación (verticles en vertx) de tu aplicación están desacopladas por la propia arquitectura.

Un bus es un sistema en el que puedes interactuar con lógica de aplicación sin tener ningún conocimiento de ésta. El único conocimiento necesario es el formato de mensaje a enviar y la respuesta esperada. Por lo tanto, nada impide realizar implementaciones que, conectadas a las direcciones del bus, permitan que el software a probar interactúe con estos stubs en lugar del código real.

¿Es realmente útil Mockito?

Pues eso depende de si los fundamentos de los que hemos hablado anteriormente dejan de ser fundamentos y se convierten en un dogma, en cuyo caso la respuesta es no y no hay discusión posible.

No obstante, siempre es mucho mejor ser pragmáticos y aprovechar las ventajas de las herramientas que tenemos a mano. En determinadas situaciones, es mucho mejor utilizar Mockito por su simplicidad y asumir el coste que nos llevará saltarnos algún fundamento. En concreto, comentaré dos situaciones donde lo utilizo siempre.

Pruebas de gestión de excepciones

Implementar el lanzamiento de excepciones desde stubs puede llegar a ser bastante complicado cuando queremos realizar pruebas que aseguren el comportamiento ante determinados errores.

Añadir capacidades para almacenar las excepciones que se van a lanzar y dotar al stub de operaciones para configurarlos es engorroso y, además, no graparemos nuestro código a un mock, pero lo hacemos a nuestro stub, lo cual tampoco representa ninguna ventaja.

Yo normalmente utilizo mocks para este tipo de pruebas. Es sencillo, potente y eficaz.

Forzar la optimización de implementaciones

En muchas ocasiones, proveemos en nuestro desarrollo de adaptadores o servicios que hemos desarrollado con optimizaciones específicas para determinados procesos de negocio.

En estos casos no nos interesa que cualquier refactor que hagamos del proceso utilice otras alternativas distintas de la que hemos optimizado. Queremos forzar que cualquier implementación del proceso o servicio utilice las operaciones que hemos dispuesto al efecto.

Para estas situaciones, es muy conveniente utilizar un Spy y verificar que se llama a las operaciones que queremos el número de veces que hemos dispuesto para seguir garantizando la performance deseada.

Esto es igualmente recomendable cuando queremos limitar el número de llamadas al adaptador de base de datos o de un webservice durante la ejecución de un proceso de negocio.

Conclusión

Este post pretende ser una propuesta de un conjunto de fundamentos que deberían regir los diseños de pruebas realizadas por el/la desarrollador/a. Los fundamentos pretenden asegurar no solo que el código sea fiable, sino que, además, sean una herramienta para transmitir conocimiento sobre la correcta forma de utilizar el código y para facilitar las sucesivas refactorizaciones que se deben realizar durante el desarrollo.

Desde este punto de partida, el uso generalizado de Mockito en la elaboración de estas pruebas se debería evitar, ya que existen alternativas de diseño y tecnologías que permiten desarrollar pruebas más acordes con los objetivos que pretenden los citados fundamentos.

No obstante, Mockito es muy útil en contextos concretos en los que el desarrollo de código adicional para dar soporte a las pruebas resulta complejo y costoso.

Cuéntanos qué te parece.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.

Suscríbete