Bloco

View Original

Mocking Retrofit API Responses with MockWebServer + Hilt

If your app integrates with a server API, mocking server responses is important for tests. In normal circumstances, you don't want your tests to hit the server every time they run. They would be slow and could fail for reasons you don't control.

In unit tests it's easy to mock the object doing the API calls, but on a UI test it's harder. Ideally, you want to fake as little as possible, to ensure results are similar to how the app would behave in production. This means overriding just the HTTP calls and returning what the server would, like a JSON response for example.

We're going to show how to mock Retrofit API calls in UI tests with a quick solution using MockWebServer and Hilt.

1. Replacing the Hilt Module with a Mock

It's a good practice to have all you network dependencies together, it makes it easier for testing and debugging. We are using Hilt modules.

Since we want to override every single HTTP request, using @UninstallModules in every test class is too troublesome. We could use custom flavors, but that's too much work and I promised a quick solution.

Hilt has the @TestInstallIn annotation.

@Module
@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [NetworkModule::class]
)
class MockNetworkModule {}

With this, the app will use the MockNetworkModule within the testing scope of the project, instead of the regular NetworkModule.

2. Creating the MockServer

We need to access the MockWebServer from both the module and the test classes. A simple way is a static constant that holds the MockWebServer.

class MockServer {
    companion object {
        val server = MockWebServer()
    }
}

3. Replacing the URL

We replaced the NetworkModule with MockNetworkModule in our test environment, but we don't want to replace all of it, we only want to replace the base url:

@Module
@InstallIn(SingletonComponent::class)
class NetworkModule {

    @Provides
    @Singleton
    fun blocoApiService(): BlocoApi = Retrofit.Builder()
        .baseUrl("https://api.bloco.io/") // We need to replace this
        .addConverterFactory(GsonConverterFactory.create(Gson()))
        .build()
        .create(BlocoApi::class.java)
}

It makes no sense to redefine everything. So let's make a small refactor so we can extend this class and override only what we want.

@Module
@InstallIn(SingletonComponent::class)
// 1. Open the class for overriding
open class NetworkModule {

    // 2. Declare the method we want/can change (no annotations)
    protected open fun baseUrl() = "https://api.bloco.io/".toHttpUrl()

    @Provides
    @Singleton
    fun blocoApiService(): BlocoApi = Retrofit.Builder()
        .baseUrl(baseUrl()) // 3. make use of our method instead of hardcoded
        .addConverterFactory(GsonConverterFactory.create(Gson()))
        .build()
        .create(BlocoApi::class.java)
}

Now we can make our MockNetworkModule sub-class the production one.

@Module
@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [NetworkModule::class]
)
class MockNetworkModule: NetworkModule() {

    override fun baseUrl(): HttpUrl {
        return MockServer.server.url("http://localhost/")
    }
}

4. Run the tests and close the server

@Test
fun testURLCall() {
    // Test with the url call
}

@After
fun tearDown() {
    MockServer.server.shutdown()
}

DONE! Now you have to implement the tests.

Tips:

It's handy to have multiple dispatchers, like for Success/Unsuccessful tests. Using an example:

fun successDispatcher(): Dispatcher {
  return object : Dispatcher() {
      override fun dispatch(request: RecordedRequest): MockResponse {
          return when(request.path) {
              else -> MockResponse().setResponseCode(200).setBody("{ \\"public_repos\\": 24}")
          }
      }
  }
}
fun errorDispatcher(): Dispatcher {
  return object : Dispatcher() {
      override fun dispatch(request: RecordedRequest): MockResponse {
          return when(request.path) {
              else -> MockResponse().setResponseCode(404).setBody("{ \\"error\\": \\"error\\"}")
          }
      }
  }
}

// In the test itself pick the appropriate dispatcher
@Before
fun setUp() {
    MockServer.server.dispatcher = MockServer.dispatcher()
}

Notes:

  1. Simply using the same URL String between NetworkModule and MockNetworkModule won't work, you have to use the same HttpUrl object.

  2. And while the start of the MockWebServer will happen with our MockNetworkModule, don't forget to shut it down like we did on the example above.

  3. You may run into android.os.NetworkOnMainThreadException when returning the localhost URL since this also starts the server. Since our project uses coroutines we wrapped the MockWebServer.url(...) call with a runBlocking.

  4. If you encounter issues with SSL, there are two easy ways to fix them.


📲 We are a studio specialized in designing and developing Android products. Keep up with us through Twitter, Facebook, and Instagram.