Android – Como testear nuestra base de datos

Hace unas semanas dejábamos lista nuestra aplicación de ejemplo para testear. Ya tenía una funcionalidad mínima y faltaban los tests. Si recordáis, nuestra aplicación se conecta a un API, descarga datos y los almacena en base de datos local.

Hoy vamos a testear nuestra base de datos. Ya veréis como es muy sencillo. Vamos allá!

Antes de testear, empezaré explicando muy rápido esta parte. Las bases de datos locales nos ayudan a persistir datos de tal forma que no sea necesaria una conexión a internet ininterrumpida. En el caso de nuestra aplicación, estamos usando la librería Room de Android, la cual es muy potente, y con pocas líneas podemos persistir nuestros datos.

En la carpeta «db» de nuestro proyecto tenemos 2 archivos.

MovieDb es la clase que gestiona nuestra base de datos. A través de anotaciones indicaremos varios parámetros, por un lado las entidades y por otro la versión de nuestra base de datos. Debemos acordarnos de subir esta versión a medida que se introduzcan cambios en esta.

Además, definimos los DAOs que manejará. Vamos a ver este, el cual se encuentra en MovieDao. Como en nuestra base de datos, debemos marcarlo con una anotación, @Dao, para indicar que se trata de un Dao y nuestra librería Room lo reconozca como tal.

También debemos definir las entidades o tablas de nuestro modelo de datos, para ellos, en la carpeta model del proyecto tenemos una entidad Movie. Veámosla.

Ya veis que es muy sencillo, indicamos con una anotación, @Entity, que se trata de una entidad, e indicamos el nombre de las propiedades que forman parte de la primary key. En este caso solo tenemos una pero pueden ser varias.

Y definimos los campos que queremos tener en nuestra entidad como propiedades.

Por último, para inicializar todo esto y empezar a utilizarlo, en nuestra carpeta di, en AppModule, y con la ayuda de Hilt+Dagger, vamos a inicializar nuestra base de datos.

No quiero entrar en detalle con el tema de Hilt+Dagger, ya que haré un artículo más profundo sobre esto, pero basicamente nos ayudará a instanciar las clases que necesitamos a lo largo de la aplicación, y de este modo usar el principio de inyección de dependencias.

Volviendo a como instanciamos nuestra base de datos, por un lado instanciamos nuestra base de datos, dándole un nombre, indicando la clase MovieDb para indicarle que esta es nuestra base de datos.

Además, le decimos con fallbackToDestructiveMigration que cuando tengamos una nueva versión, elimine la versión actual y cree una nueva. Opción interesante si de momento no nos planteamos un sistema de migración.

También instanciamos los DAOs por si queremos acceder directamente a ellos, pero teniendo acceso a la clase de base de datos no nos haría falta.

Bien, si has llegado hasta aquí, ya hemos dado un repaso rápido a la parte que gestiona la base de datos. Vamos ahora a testear nuestra base de datos.

Lo primero que hay que saber es que los tests de base de datos son instrumentados, ¿esto que quiere decir? que los tests se deben ejecutar o en dispositivo físico o en emulador. Este tipo de tests se deben crear dentro de la carpeta que tiene el namespace y entre paréntesis androidTest.

De todo lo que hemos ido viendo vamos a testear nuestros DAOs, de esta forma podremos comprobar que nuestras consultas devuelven los resultados que se esperan de ellas.

Para poder probar esto, lo que haremos será crear una versión de nuestra base de datos que en lugar almacenar en base de datos local, lo hará en memoria. Además, haremos que las peticiones se hagan en el hilo principal de ejecución, de esta forma nos evitaremos efectos colaterales.

Veamos como será esta base de datos de pruebas:

Para empezar tendremos un objeto (db) de tipo MovieDb, en el cual almacenaremos los datos en memoria, y el cual se inicializará antes de cada test.

Al finalizar cada test cerraremos nuestra base de datos. De esta forma siempre tendremos una base de datos vacía, lo cual hará que los datos de un test no interfieran con los datos de otro.

Por último, también creamos un hilo en el cual ocurrirán todas las ejecuciones del test.

abstract class MovieDbTest {

    @OptIn(DelicateCoroutinesApi::class)
    private val mainThreadSurrogate = newSingleThreadContext("Main Thread")

    private lateinit var _db: MovieDb

    val db: MovieDb
        get() = _db

    @OptIn(ExperimentalCoroutinesApi::class)
    @Before
    fun initDb() {
        Dispatchers.setMain(mainThreadSurrogate)
        _db = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            MovieDb::class.java
        ).build()
    }

    @After
    fun closeDb() {
        _db.close()
        Dispatchers.resetMain()
        mainThreadSurrogate.close()
    }
}

Vamos ahora a testear nuestro DAO, para ello creamos una nueva clase MovieDaoTest que heredará la clase anterior, MovieDbTest. Y que ya será, por fin, nuestro test. Para ello, debemos indicar una anotación, @RunWith, para indicar que será un test.

@RunWith(AndroidJUnit4::class)
class MovieDaoTest: MovieDbTest() {

}

En MovieDao el primer método que tenemos es el de inserción. Comprobemos que se insertan bien los datos.

@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract fun insert(movies: List<Movie>)

Para ello, creamos un método para testear. Lo marcamos con la anotación @Test, e importante, al utilizar coroutines, debemos bloquear la ejecución y enviarla al hilo principal con runBlocking.

@Test
fun when_insert_load_same_data() = runBlocking(Dispatchers.Main) {

}

Para poder insertar, primero definiremos un set de datos. En este caso, un listado de Movie.

val list = listOf(
            Movie(id = "01", imdbId = "imdb01", title = "title_01", originalTitle = "original_title_01",
                originalLang = "en", overview = "overview_01", voteAverage = 4.3f, voteCount = 1000L,
                posterPath = null, releaseDate = "release_date_01", popularity = 5f,
                releaseDateTimestamp = 12132313132L),
            Movie(id = "02", imdbId = "imdb02", title = "title_02", originalTitle = "original_title_02",
                originalLang = "en", overview = "overview_02", voteAverage = 2.8f, voteCount = 900L,
                posterPath = null, releaseDate = "release_date_02", popularity = 4.5f,
                releaseDateTimestamp = 12132313132L),
        )

Seguidamente, ejecutamos el método a testear

db.movieDao().insert(list)

Vale, una vez tenemos en nuestra base de datos en memoria nuestros datos, la forma más sencilla para verificar si se insertó es leyendo de nuevo los datos. Después comprobamos que aparezcan los registros que insertamos.

val loaded = db.movieDao().loadMovies().take(1).toList()[0]
assertThat(loaded.size, CoreMatchers.`is`(2))

Utilizamos el mismo método loadMovies de nuestro DAO, que nos leerá todas nuestras películas. Al devolver este método un objeto tipo Flow, debemos tratarlo con el método take, y convertir a lista para finalmente tener la lista que necesitamos para verificar.

Vamos a crear otro test, esta vez del tirón. En este caso, si volvemos a nuestro archivo MovieDao, y nos fijamos otra vez en el método insert, vemos que tiene una anotación @Insert, en la cual se ha configurado que al insertar un registro con una clave duplicada se haga un Replace. Testeemos si esto se cumple.

@Test
    fun when_insert_repeated_replace_data() = runBlocking(Dispatchers.Main) {

        val list = listOf(
            Movie(id = "02", imdbId = "imdb02", title = "title_02", originalTitle = "original_title_02",
                originalLang = "en", overview = "overview_02", voteAverage = 2.8f, voteCount = 900L,
                posterPath = null, releaseDate = "release_date_02", popularity = 4.5f,
                releaseDateTimestamp = 12132313132L),
        )

        // Método a testear
        db.movieDao().insert(list)

        val loaded01 = db.movieDao().loadMovies().take(1).toList()[0]
        assertThat(loaded01.size, CoreMatchers.`is`(1))

        assertThat(loaded01[0].imdbId, CoreMatchers.`is`("imdb02"))

        val list2 = listOf(Movie(id = "02", imdbId = "imdb02_new", title = "title_02b", originalTitle = "original_title_02b",
            originalLang = "en", overview = "overview_02b", voteAverage = 2.8f, voteCount = 900L,
            posterPath = null, releaseDate = "release_date_02b", popularity = 4.5f,
            releaseDateTimestamp = 12132313132L),)

        db.movieDao().insert(list2)

        val loaded02 = db.movieDao().loadMovies().take(1).toList()[0]
        assertThat(loaded02.size, CoreMatchers.`is`(1))

        assertThat(loaded02[0].imdbId, CoreMatchers.`is`("imdb02_new"))

    }

Empezamos muy parecido, creamos un listado con un solo registro, lo insertamos, leemos y verificamos que se ha insertado un registro. Además, tomamos el primer registro que leemos, lo cual nos dará un objeto Movie, y verificamos el valor de la propiedad imdbId.

assertThat(loaded01[0].imdbId, CoreMatchers.`is`("imdb02"))

Ahora volvemos a insertar otro registro, el cual debe tener el mismo valor para la primary key. Leemos otra vez y comprobamos que seguimos teniendo un único registro.

Además, verificamos el valor de la propiedad imdbId otra vez, el cual ahora debe haber cambiado.

Por último, vamos a testear el método que lee un registro concreto. Para ello, hacemos una nueva función de test.

@Test
    fun when_load_on_movie_get_correct_entry() = runBlocking(Dispatchers.Main) {
        val list = listOf(
            Movie(id = "01", imdbId = "imdb01", title = "title_01", originalTitle = "original_title_01",
                originalLang = "en", overview = "overview_01", voteAverage = 4.3f, voteCount = 1000L,
                posterPath = null, releaseDate = "release_date_01", popularity = 5f,
                releaseDateTimestamp = 12132313132L),
            Movie(id = "02", imdbId = "imdb02", title = "title_02", originalTitle = "original_title_02",
                originalLang = "en", overview = "overview_02", voteAverage = 2.8f, voteCount = 900L,
                posterPath = null, releaseDate = "release_date_02", popularity = 4.5f,
                releaseDateTimestamp = 12132313132L),
        )


        db.movieDao().insert(list)

        val loaded = db.movieDao().loadMovie("02").take(1).toList()[0]
        assertThat(loaded.id, CoreMatchers.`is`("02"))
        assertThat(loaded.imdbId, CoreMatchers.`is`("imdb02"))
    }

En este caso, volvemos a crear un listado con datos y los insertamos. Leemos los datos utilizando el método que estamos testeando, loadMovie, y le decimos que lea el registro cuyo valor sea «02».

Una vez tenemos el objeto, verificamos que los valores de sus propiedades coincidan con lo que se espera.

En este caso concreto se está testeando una consulta muy sencilla, pero imaginad cuando tengáis consultas más complicadas, con varios joins o parámetros en el where. La tranquilidad y seguridad que os pueden dar este tipo de test no tiene precio.

Por último, comentaros que he tenido que añadir y eliminar algunas dependencias del proyecto que tengo en github donde voy poniendo el código. Revisar los cambios para saber cuales son las nuevas dependencias.

También os recuerdo que he grabado una sesión en Youtube hablando de este tema, revisarlo, suscribiros y no olvidéis darle un like si queréis que haga más videos testeando aplicaciones.

Código del repositorio https://github.com/3pies/moviesios