¿Qué son los dobles de test?
Antonio Leiva

Los dobles de prueba (también conocidos como «doubles» o «fakes») son herramientas comunes en la programación y en particular en el testing de software.

Se utilizan para simular el comportamiento de una dependencia de una aplicación en un entorno de pruebas, sin tener que depender de la implementación real de dicha dependencia.

Ventajas de los dobles de test

Esto tiene varias ventajas. En primer lugar, permite realizar pruebas aisladas de una parte específica de una aplicación, sin tener que preocuparse por el comportamiento de sus dependencias.

Esto es especialmente útil cuando estamos trabajando con dependencias externas que pueden ser difíciles de controlar o replicar en un entorno de pruebas.

En segundo lugar, los dobles de prueba nos permiten controlar el comportamiento de las dependencias de nuestra aplicación.

Esto es útil para probar diferentes escenarios y ver cómo nuestra aplicación se comporta en cada uno de ellos.

Por ejemplo, podemos crear un double que simule una respuesta fallida de un servicio web y probar cómo nuestra aplicación maneja dicho fallo.

Tipos de dobles de test

Existen diferentes tipos de dobles de prueba, cada uno con sus propias características y usos.

Dummy

Los dobles de prueba «Dummy» son aquellos que son utilizados simplemente para proporcionar una implementación vacía para una dependencia necesaria en nuestro código.

Estos dobles son muy simples y no tienen ningún tipo de lógica en su implementación.

Se utilizan principalmente cuando necesitamos un objeto para cumplir con una firma de método o una interfaz, pero no tenemos intención de utilizar realmente ese objeto.

En pocas palabras, queremos hacer que compile.

Por ejemplo, imaginemos que tenemos una clase llamada Customer con un método llamado makePayment() que depende de un objeto CreditCard para realizar el pago.

En lugar de proporcionar una implementación real de CreditCard, podemos utilizar un Dummy para cumplir con la firma del método y poder probar nuestra clase Customer de forma aislada.

Aquí tienes un ejemplo de código en Kotlin que muestra cómo se podría utilizar un Dummy para proporcionar una dependencia a nuestra clase Customer:

class CreditCardDummy: CreditCard {
    // Implementación vacía de los métodos de CreditCard
}

class Customer {
    fun makePayment(creditCard: CreditCard) {
        // Lógica para realizar el pago con la tarjeta
    }
}

// Utilizamos una instancia de CreditCardDummy como dependencia
val customer = Customer()
val creditCard = CreditCardDummy()
customer.makePayment(creditCard)

Stub

Los dobles de prueba «Stub» son aquellos que se utilizan para simular el comportamiento de una dependencia en un entorno de pruebas. Estos dobles proporcionan respuestas predefinidas a ciertas entradas y son muy útiles para probar cómo nuestra aplicación se comporta en diferentes escenarios.

Por ejemplo, imaginemos que tenemos una clase Customer con un método getDiscount() que depende de un objeto DiscountService para obtener un descuento para un determinado cliente. En lugar de depender del servicio real, podemos utilizar un Stub para proporcionar respuestas predefinidas y así poder probar nuestra clase Customer de forma aislada.

Aquí tienes un ejemplo de código en Kotlin que muestra cómo se podría utilizar un Stub para proporcionar una dependencia a nuestra clase Customer:

class DiscountServiceStub: DiscountService {
    // Implementación del método getDiscount que siempre devuelve un descuento del 10%
    override fun getDiscount(customer: Customer): Double {
        return 0.1
    }
}

class Customer {
    fun getDiscount(discountService: DiscountService): Double {
        // Lógica para obtener el descuento utilizando el servicio de descuentos
        return discountService.getDiscount(this)
    }
}

// Utilizamos una instancia de DiscountServiceStub como dependencia
val customer = Customer()
val discountService = DiscountServiceStub()
val discount = customer.getDiscount(discountService)

Fake

Los dobles de prueba «Fake» son aquellos que imitan el comportamiento y la interfaz de una dependencia real, pero utilizan una implementación simplificada y más fácil de probar. Estos dobles son muy útiles cuando la dependencia es compleja o cuesta mucho trabajo configurarla para un entorno de pruebas.

Por ejemplo, imaginemos que tenemos una clase Customer con un método sendEmail() que depende de un objeto EmailService para enviar un correo electrónico a un cliente. En lugar de depender del servicio real, que podría ser complicado de configurar y utilizar en un entorno de pruebas, podemos utilizar un Fake que imite el comportamiento del servicio real pero que sea más fácil de probar.

Aquí tienes un ejemplo de código en Kotlin que muestra cómo se podría utilizar un Fake para proporcionar una dependencia a nuestra clase Customer:

class EmailServiceFake: EmailService {
    // Implementación del método sendEmail que simplemente guarda el correo en una lista en memoria
    val sentEmails: MutableList<Email> = mutableListOf()

    override fun sendEmail(email: Email) {
        sentEmails.add(email)
    }
}

class Customer {
    fun sendEmail(emailService: EmailService, email: Email) {
        // Lógica para enviar el correo utilizando el servicio de correo electrónico
        emailService.sendEmail(email)
    }
}

// Utilizamos una instancia de EmailServiceFake como dependencia
val customer = Customer()
val emailService = EmailServiceFake()
customer.sendEmail(emailService, Email(to = "john@example.com", subject = "Hello", body = "Hello, how are you?"))

Para preparar un test, podríamos incluso añadir emails iniciales de forma manual y partir desde el punto que necesitemos

Spy

Los dobles de prueba «Spy» son aquellos que registran el comportamiento de una dependencia durante una prueba para poder verificar su correcto funcionamiento.

Estos dobles son muy útiles cuando queremos asegurarnos de que una dependencia se está utilizando correctamente y que está produciendo el resultado esperado.

Por ejemplo, imaginemos que tenemos una clase Customer con un método addItemToCart() que depende de un objeto ShoppingCart para añadir un producto al carrito de la compra.

En lugar de depender del carrito real, podemos utilizar un Spy para registrar el comportamiento del carrito durante la prueba y así poder verificar que se está utilizando correctamente.

Algunas definiciones afirman que se usa el objeto real, y simplemente añadimos código que nos permite verificar las llamadas.

Aquí tienes un ejemplo de código en Kotlin que muestra cómo se podría utilizar un Spy para proporcionar una dependencia a nuestra clase Customer:

class ShoppingCartSpy: ShoppingCart {
    // Implementación del método addItem que simplemente guarda el producto en una lista en memoria
    private val addedItems: MutableList<Product> = mutableListOf()
    var addItemCalled = false

    override fun addItem(product: Product) {
        addItemCalled = true
        addedItems.add(product)
    }
}

class Customer {
    fun addItemToCart(shoppingCart: ShoppingCart, product: Product) {
        // Lógica para añadir el producto al carrito de la compra
        shoppingCart.addItem(product)
    }
}

// Utilizamos una instancia de ShoppingCartSpy como dependencia
val customer = Customer()
val shoppingCart = ShoppingCartSpy()
customer.addItemToCart(shoppingCart, Product(name = "Phone", price = 500.0))

La principal diferencia entre un fake y un spy es que un fake imita el comportamiento y la interfaz de una dependencia real, pero utiliza una implementación simplificada y más fácil de probar, mientras que un spy registra el comportamiento de una dependencia durante una prueba para poder verificar su correcto funcionamiento.

Un fake se utiliza principalmente cuando queremos simular el comportamiento de una dependencia sin tener que utilizar la implementación real, ya sea porque es complicada de configurar o porque queremos probar nuestro código de forma aislada.

Por otro lado, un spy se utiliza para verificar que una dependencia se está utilizando correctamente y que está produciendo el resultado esperado.

Mock

Los dobles de prueba «Mock» son un tipo de doble de prueba que se utiliza para verificar el comportamiento de una dependencia durante una prueba. A diferencia de los fake y los spy, los mock tienen un comportamiento totalmente controlado por la prueba y no se basan en la implementación real de la dependencia.

Los mock se utilizan principalmente para asegurar que nuestro código se está comportando de la forma esperada cuando se produce un evento o se invoca una función en una dependencia. Al poder controlar el comportamiento del mock, podemos simular diferentes escenarios y verificar que nuestro código se está ejecutando correctamente en cada uno de ellos.

Los mocks por tanto son una evolución de los spies donde podemos controlar cada interacción y proveer el código que queremos que se ejecute ante cada llamada.

Normalmente se utilizan librerías que los implementan por nosotros. Las más populares en Kotlin son Mockito y MockK.

class ShoppingCart(private val priceCalculator: PriceCalculator) {
    fun checkout(items: List<Item>) = 
        priceCalculator.calculateTotalPrice(items)
    }
}

// Creamos un Mock de PriceCalculator utilizando Mockito-Kotlin
val priceCalculatorMock = mock<PriceCalculator> {
    on { calculateTotalPrice(any()) } doReturn 100.0
}

// Utilizamos el Mock como dependencia
val shoppingCart = ShoppingCart(priceCalculatorMock)

Ejemplo real de creación de los distintos dobles de test

Imagina que tienes una app con un repositorio de películas, que tiene una fuente local y una remota.

Cuando quiere recuperar las películas a mostrar, buscará primero en local.

  • Si encuentra contenido lo devolverá.
  • Si no, irá al servidor, lo descargará y lo guardará en local, devolviendo entonces el contenido local
data class Movie(val id: Int, val title: String)

class MoviesRepository(
    private val moviesLocaDataSource: MoviesLocalDataSource,
    private val moviesRemoteDataSource: MoviesRemoteDataSource
) {

    fun findAll(): List<Movie> {
        if (moviesLocaDataSource.isEmpty()) {
            val movies = moviesRemoteDataSource.findPopularMovies()
            moviesLocaDataSource.saveAll(movies)
        }
        return moviesLocaDataSource.findAll()
    }

}

interface MoviesLocalDataSource {
    fun isEmpty(): Boolean
    fun saveAll(movies: List<Movie>)
    fun findAll(): List<Movie>
}

interface MoviesRemoteDataSource {
    fun findPopularMovies(): List<Movie>
}

Imagina que quieres testear el primer caso:

class MoviesRepositoryTest {

    @Test
    fun `getMovies() returns a list of local movies if local data source is not empty`() {
        ...
    }

}

En este caso, el DataSource remoto no me interesa, ya que solo voy a usar el local. Por tanto, puede ser un dummy:

class MoviesRemoteDataSourceDummy : MoviesRemoteDataSource {
    override fun findPopularMovies(): List<Movie> = TODO()
}

Sin embargo, del local necesito que devuelva algo, y por tanto, tiene que ser al menos un Stub que me devuelva siempre el mismo valor (aquí no voy a probar a guardar):

class MoviesLocalDataSourceStub : MoviesLocalDataSource {
    override fun isEmpty(): Boolean = false
    override fun findAll(): List<Movie> = listOf(Movie(1, "title"))
    override fun saveAll(movies: List<Movie>) = TODO()

}

El último método ni me molesto en implementarlo, porque no lo voy a usar. El test podría ser:

    @Test
    fun `getMovies() returns a list of local movies if local data source is not empty`() {
        val moviesLocalDataSource = MoviesLocalDataSourceStub()
        val moviesRemoteDataSource = MoviesRemoteDataSourceDummy()
        val moviesRepository = MoviesRepository(moviesLocalDataSource, moviesRemoteDataSource)

        val movies = moviesRepository.findAll()

        Assert.assertEquals(1, movies[0].id)
    }

Pero imagina que quiero probar que si la fuente local está vacía, almacena lo que está en la remota. Ahora la fuente remota necesita ser un Stub:

class MoviesRemoteDataSourceStub : MoviesRemoteDataSource {
    override fun findPopularMovies(): List<Movie> =
        listOf(
            Movie(1, "title1"), 
            Movie(2, "title2")
        )
}

Pero ahora la fuente local no puede ser tan simple, porque necesita poder guardar valores y devolverlos para que el código funcione y haga su trabajo.

Es decir, necesito una versión sencilla de la implementación real, que puede ser simplemente una persistencia en memoria.

A esto se lo conoce como Fake:

class MoviesLocalDataSourceFake : MoviesLocalDataSource {

    private val movies = mutableListOf<Movie>()

    override fun isEmpty(): Boolean = movies.isEmpty()
    
    override fun saveAll(movies: List<Movie>) {
        this.movies.addAll(movies)
    }

    override fun findAll(): List<Movie> = movies

}

Finalmente, el test sería:

    @Test
    fun `getMovies() returns a list of remote movies if local data source is empty`() {
        val moviesLocalDataSource = MoviesLocalDataSourceFake()
        val moviesRemoteDataSource = MoviesRemoteDataSourceStub()
        val moviesRepository = MoviesRepository(moviesLocalDataSource, moviesRemoteDataSource)

        val movies = moviesRepository.findAll()

        Assert.assertEquals(1, movies[0].id)
        Assert.assertEquals(2, movies[1].id)
    }

Esto está muy bien, ¿pero cómo me aseguro de que lo que me devuelve el repositorio es realmente lo que está en el datasource local, y que se está llamando a las funciones que tocan?

Para ello, me puedo crear un Spy, donde puedo exponer el contenido, e incluso añadir información sobre si se ha llamado a la función que me interesa:

class MoviesLocalDataSourceSpy : MoviesLocalDataSource {

    val movies = mutableListOf<Movie>()
    var saveAllCalled = false

    override fun saveAll(movies: List<Movie>) {
        saveAllCalled = true
        this.movies.addAll(movies)
    }

    ...

}

Ahora en el test podemos comprobar eso:

Assert.assertEquals(2, moviesLocalDataSource.movies.size)
Assert.assertTrue(moviesLocalDataSource.saveAllCalled)

Pero este se puede volver un poco tedioso cuando tenemos muchas funciones y distintas funciones que espiar.

Así que podemos dar el salto y usar una librería como Mockito para crear el objeto. En el primer test, podríamos hacer:

val moviesLocalDataSource = mock<MoviesLocalDataSource> {
    on { isEmpty() } doReturn false
    on { findAll() } doReturn listOf(Movie(1, "title1"))
}

Y finalmente comprobar que se ha llamado a la función que queremos:

verify(moviesLocalDataSource).findAll()

El caso del segundo test sería más complicado hacerlo con mocks, ya que el estado de un componente cambia a partir de la interacción con otro, y por tanto ya estamos testeando cómo se integran varios componentes.

Pero en general, la mayoría de los dobles de tests se pueden sustituir por mocks cuando usamos una librería de mocking como Mockito.

Es por eso que muchas veces escucharás referirse a todos ellos como mocks.

En lo personal, normalmente distingo entre Mocks y Fakes y nada más, pero está bien que conozcas las sutilezas de cada uno de ellos para saber hasta qué detalle entrar en cada caso.

Conclusión

En conclusión, los dobles de test son una herramienta muy útil en el desarrollo de aplicaciones con Kotlin.

Existen varios tipos de dobles de test, cada uno con sus propias ventajas y usos específicos.

En el ejemplo del repositorio de películas, se ha visto cómo se pueden utilizar diferentes tipos de dobles de test en diferentes etapas del desarrollo, lo que permite probar el código de manera más eficiente y aislar problemas específicos.

Es importante tener en cuenta que el uso adecuado de los dobles de test puede mejorar significativamente la calidad del código y hacer que el proceso de desarrollo sea más eficiente.

Quizá también te interese…

Las reglas FIRST de los tests

Las reglas FIRST de los tests

Las reglas FIRST son un conjunto de principios que se utilizan para diseñar y escribir tests de software de manera efectiva. Las siglas FIRST significan: F - Fast: Un test debe ser rápido de ejecutar. I - Independent: Un test debe ser independiente de otros tests y...

Kata del TicTacToe en Kotlin

Kata del TicTacToe en Kotlin

Escribe el código para representar una entidad que almacene el tablero de juego del 3 en raya, y que además tenga: Un método move() con 2 parámetros, fila y columna, que permita añadir un movimiento al tablero Un método findWinner(), que devuelva el ganador (X, Y o...

¿Cuál es la diferencia entre un algoritmo y un patrón de diseño?

¿Cuál es la diferencia entre un algoritmo y un patrón de diseño?

Un algoritmo es un conjunto de pasos lógicos y precisos que se siguen en un orden determinado para resolver un problema o realizar una tarea específica. Por otro lado, un patrón de diseño es una solución reutilizable a un problema común que se presenta en el diseño de...

0 comentarios

Enviar un comentario

Los datos personales que proporciones a través de este formulario quedarán registrados en un fichero de DevExpert, S.L.U., con el fin de gestionar los comentarios que realizas en este blog. La legitimación se realiza a través del consentimiento de la parte interesada. Si no se acepta, no podrás comentar en este blog. Los datos que proporciona solo se utilizan para evitar el correo no deseado y no se usarán para nada más. Puede ejercer los derechos de acceso, rectificación, cancelación y oposición en contacto@devexperto.com.

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Acepto la política de privacidad *