Como testear Interceptores de Retrofit – Android

Hace poco veíamos como testear nuestra API con Retrofit en Android. Quedaron cosas por ver, así que hoy continuamos con más testing en Android.

Retrofit es una excelente librería que nos facilita la labor de llamar a nuestras APIs, a la vez que nos facilita herramientas para que el control sobre los procesos sea absoluto.

Uno de los mecanismos que nos proporciona son los interceptores. Estos son una capa de procesado que, tal como el nombre indica, interceptan las peticiones y las transforman. Pueden capturar tanto a la salida como a la entrada de la petición.

En mi caso, el uso que suelo darle es añadir parámetros de forma genérica a mis peticiones, por ejemplo, el token de usuario. Otro suele ser verificar si mi petición devuelve un código 401 y así intentar hacer un refresco de token.

En nuestra aplicación de ejemplo ya tenemos un interceptor que se encarga de añadir el apiKey necesaria a todas las peticiones, ApiKeyInterceptor, hoy crearemos un test para esta parte.

En un vistazo rápido a nuestra clase, por un lado ya estoy viendo un problema, y es que la clase necesita como parámetro el Context, esto en test unitarios de Android es un problema, ya que nos obligaría a tener tests instrumentados, y es difícil de Mockear. Así que lo que haremos será sustituir a este por algo que sea más sencillo de sustituir.

Siguiendo con la explicación de esta clase, esta implementa Interceptor, y para ello debe sobreescribir el método intercept, que será donde capturaremos la petición y la transformaremos a nuestro gusto. En este caso, añadimos un parámetro queryString a nuestra petición.

Así que, básicamente, esto es lo que testearemos, que dada una petición de entrada, su url de salida debe incluir el parámetro «api_key».

Antes de empezar haremos el cambio del Context. Simplemente en el constructor de ApiKeyInterceptor, lo cambiamos por la clase Configuration, que ya nos da el apiKey. Y sustituimos.

class ApiKeyInterceptor(private val configuration: Configuration): Interceptor {

Y dentro de la clase buscamos la siguiente línea

val apiKey: String = context.resources.getString(R.string.apikey)

Y sustituimos por la siguiente

val apiKey: String = configuration.apiKey

En AppModule, donde se definen las dependencias, debemos añadir Configuration a la hora de crear MovieService, que es quien utiliza nuestro interceptor.

@Singleton @Provides
    fun provideMovieService(@ApplicationContext appContext: Context, configuration: Configuration): MovieService {

....

httpClient.addInterceptor(ApiKeyInterceptor(configuration))
....

A continuación, creamos nuestro archivo de test.

@RunWith(JUnit4::class)
class ApiKeyInterceptorTest {

Y la estrategia para probar en este caso será, al igual que el artículo anterior, crear un servidor Mock, en este caso, también haremos una interface Mock para crear una petición que podamos controlar, y verificar finalmente que la request se forma correctamente.

Así que para no hacer más largo de lo necesario esto, lo que haré será copiar y pegar la parte de la creación del servidor mock de nuestro test anterior.

    private lateinit var mockWebServer: MockWebServer
    private val mainThreadSurrogate = newSingleThreadContext("UI thread")

   
    @Before
    fun createService() {
        Dispatchers.setMain(mainThreadSurrogate)

        mockWebServer = MockWebServer()

        service = Retrofit.Builder()
            .baseUrl(mockWebServer.url("/"))
            .addConverterFactory(GsonConverterFactory.create())
            .addCallAdapterFactory(ApiResponseCallAdapterFactory())
            .build()
            .create(MovieService::class.java)

    }

    @After
    fun stopService() {
        mockWebServer.shutdown()

        Dispatchers.resetMain()
        mainThreadSurrogate.close()
    }

    private fun enqueueResponse(fileName: String) {
        val inputStream = javaClass.classLoader
            ?.getResourceAsStream("api-response/$fileName")?.source()?.buffer()

        mockWebServer.enqueue(
            MockResponse()
                .setBody(inputStream!!.readString(Charsets.UTF_8))
        )
    }

Ahora mismo tiene que fallar, porque service no está definido, e incluso no será de tipo MovieService. Así que vamos a arreglar eso.

Fuera de nuestra clase de test, crearemos una interface TestService, y definimos una petición GET. Devolverá un objeto ApiObjectTest, que también definiremos aquí.

data class ApiObjectTest(val data: String)

interface TestService {
    @GET("test_request")
    suspend fun testRequest(): ApiResponse<ApiObjectTest>
}

Una vez tenemos esto, acabamos de definir nuestro service. Definimos a nivel de clase un testService.

private lateinit var testService: TestService
Y en el método createService corregimos como lo definimos. Cambiamos el nombre y al final, debemos indicarle que nuestras peticiones están definidas en TestService.
testService = Retrofit.Builder()
            .baseUrl(mockWebServer.url("/"))
            .addConverterFactory(GsonConverterFactory.create())
            .addCallAdapterFactory(ApiResponseCallAdapterFactory())
            .build()
            .create(TestService::class.java)

Aquí nos falta otra cosa, que es añadir nuestro interceptor. En los tests anteriores lo eliminamos porque no queríamos que interfirieran en nuestros tests, pero en este caso, queremos ver que efectos tienen.

Así que otra vez más en createService, vamos a instanciar nuestro interceptor y añadirlo a testService. La forma de hacerlo es muy similar a como lo hacemos en AppModule.

        val httpClient = OkHttpClient.Builder()
        
        val interceptor = ApiKeyInterceptor(configuration)
        httpClient.addInterceptor(interceptor)

        testService = Retrofit.Builder()
            .baseUrl(mockWebServer.url("/"))
            .addConverterFactory(GsonConverterFactory.create())
            .addCallAdapterFactory(ApiResponseCallAdapterFactory())
            .client(httpClient.build())
            .build()
            .create(TestService::class.java)

Si tenéis esto ya integrado, os daréis cuenta que falla porque configuration no está definido, vamos a solucionarlo.

Por un lado lo definimos a nivel de clase

private lateinit var configuration: Configuration

Y después, en createService, creamos un mock. Además, le decimos que cuando pidamos el apiKey devuelva el valor que necesitemos. Esto último lo podemos hacer aquí, o si necesitamos valores diferentes en cada test, podemos hacerlo a nivel de cada test.

        configuration = Mockito.mock(Configuration::class.java)
        Mockito.`when`(configuration.apiKey).thenReturn("my_api_key")

Para acabar las preparaciones, vamos a crear un archivo «test.json» dentro de la carpeta api-response de resources.

Y será un json muy sencillo, que debe conformar la estructura de ApiObjectTest.

{
  "data": "Esta es mi respuesta mock"
}

Vale, ahora ya podemos empezar a testear. Creamos nuestro test, y como siempre que tenemos coroutines, lo creamos con runBlocking.

@Test
fun `Check if is added Query Parameter Api Key OK`() = runBlocking(Dispatchers.Main) {
        
} 

En nuestro test, empezamos indicando al servidor que respuesta debe devolver, en este caso, será nuestro archivo test.json

enqueueResponse("test.json")

A continuación, ejecutamos nuestra petición mock con testService.testRequest() y con nuestro servidor mock recogemos el resultado con takeRequest

val data = testService.testRequest()

val request = mockWebServer.takeRequest()

Y por último, verificamos que el path que nos devuelve request, es el correcto, con el parámetro queryString del api Key.

MatcherAssert.assertThat(request.path, CoreMatchers.`is`("/test_request?api_key=my_api_key"))

Y con esto podemos estar seguros de que nuestras peticiones siempre llevarán el parámetro api key.

Por último, voy a hacer rápidamente un segundo test, añadiendo un parámetro queryString a la petición.

En TestService añado una segunda petición

@GET("test_request_param")
    suspend fun testRequestParam(@Query("param1") param1: String): ApiResponse<ApiObjectTest>

Después copio y pego el primer test y hago los cambios necesario para llamar a esta segunda petición, quedando de la siguiente forma.

@Test
    fun `Check if is added Query Parameter With param Api Key OK`() = runBlocking(Dispatchers.Main) {
        enqueueResponse("test.json")

        val data = testService.testRequestParam("custom_param")

        val request = mockWebServer.takeRequest()

        MatcherAssert.assertThat(request.path, CoreMatchers.`is`("/test_request_param?param1=custom_param&api_key=my_api_key"))
    }

Y de esta forma, hemos añadido un caso más, el cual nos dará más seguridad.

Al principio he comentado que uso los interceptores para renovar el access token. Este artículo ya se acaba aquí, pero será algo que abordaremos con total seguridad.