MockWebServer는 로컬에서 HTTP 서버를 시뮬레이션하여 클라이언트 코드의 네트워크 요청/응답을 제어하고 테스트할 수 있도록 도와주는 도구입니다.
MockWebServer를 왜 사용할까?
사실 테스트 자체를 거의 만들어본 적이 없는 나로써는 테스트 코드에 힘을 쏟을 필요가 있나 생각이 많이 들었었다.
하지만 작은 스타트업에 들어가면서 테스트 코드에 대한 중요성을 많이 느꼈었다. 회사 프로젝트는 개인 프로젝트보다 많은 기능들이 구현되어 있는데 이것을 하나하나 직접 확인하기에는 무리가 있었고 리팩토링 과정에서 다른 기능에 대한 이슈들이 생기는 것을 보니 처음부터 테스트 코드를 구축하면 코드를 변경했을 때 다른 기능들이 정상적으로 동작되는지 바로 확인이 가능하다는 것이다.
안드로이드 테스트는 많은 테스트 종류가 있지만, 이번 포스트에서는 많이 사용하는 Retrofit2 라이브러리를 사용할 때 API 테스트 동작 관련된 부분을 다룬다.
[versions]
#retrofit2
retrofit = "2.11.0"
#okhttp3
okhttp = "4.12.0"
[libraries]
retrofit2 = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
mock-web-server = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "retrofit" }
logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor" }
okhttp-bom = { module = "com.squareup.okhttp3:okhttp-bom", version.ref = "okhttp" }
retrofit-kotlin-serialization = { module = "com.squareup.retrofit2:converter-kotlinx-serialization", version.ref = "retrofit" }
plugins {
alias(libs.plugins.kotlin.serialization)
}
dependencies {
//...
testImplementation(libs.assertj)
testImplementation(libs.mock.web.server)
testImplementation(libs.kotlin.coroutine.test)
implementation(libs.bundles.retrofit2)
implementation(platform(libs.okhttp.bom)
}
retrofit2, serialization, assertj, mock-web-server를 주로 사용했다.
참고로 이번 포스트에서는 api 호출을 할 때 단일 flow로 데이터 결과를 보내는 방식을 이용했다.
물론 Result 커스텀 객체로 만들어서 성공 데이터와 오류 데이터를 사용하는 방법도 있지만 간단한 프로젝트 기능을 구현했기 때문에 단일 flow를 사용했다.
때문에 이 flow 테스트를 하기 위해 coroutine-test도 추가했다.
interface ResultRepository {
//api 호출 후 토큰값을 datastore에 저장
suspend fun postUserAuthentication(username: String, password: String): Flow<Unit>
//토큰값을 이용해 api호출해 UserId 값 가져오기
suspend fun getUserInformation(): Flow<UserId>
//UserId에 있는 정보를 토대로 결과 가져오기
suspend fun retrieveUserAttribute(userId: String): Flow<RetrieveUserAttribute>
}
domain에 있는 repository 코드다 각 호출을 통해 Flow 객체를 반환하고 있다.
이제 이 인터페이스를 이용해 테스트 Fake repository를 만들었다.
//여기는 test패키지에서 만듦
class ResultRepositoryFake(
private val resultService: ResultService,
private val accessTokenProvider: AccessTokenProvider
) : ResultRepository {
override suspend fun postUserAuthentication(username: String, password: String): Flow<Unit> {
return safeCall(
execute = { resultService.postUserAuthentication(UserTokenRequest(username, password)) }
) { response ->
accessTokenProvider.saveAccessToken(response.token)
accessTokenProvider.saveRefreshToken(response.refreshToken)
}
}
override suspend fun getUserInformation(): Flow<UserId> {
return safeCall(execute = { resultService.getUserInformation() }) { response ->
response.toDomain()
}
}
override suspend fun retrieveUserAttribute(userId: String): Flow<RetrieveUserAttribute> {
return safeCall(execute = { resultService.retrieveUserAttributes(userId) }) { response ->
response.toRetrieveUserAttributes()
}
}
}
----
//ResultRepository는 AccessTokenProvider를 받는데, 이 부분이 바로 DataStore 관련된 역할을 하는 인터페이스다. 이 또한 Fake로 생성했다.(실제 구현과 거의 비슷)
class TokenProviderFake constructor(
private val dataStore: DataStore<Preferences>
): AccessTokenProvider {
companion object {
val ACCESS_TOKEN = stringPreferencesKey("access_token")
val REFRESH_TOKEN = stringPreferencesKey("refresh_token")
}
override suspend fun getAccessToken(): String? {
return dataStore.data.first()[ACCESS_TOKEN]
}
override suspend fun getRefreshToken(): String? {
return dataStore.data.first()[REFRESH_TOKEN]
}
override suspend fun saveAccessToken(token: String) {
dataStore.edit { it[ACCESS_TOKEN] = token }
}
override suspend fun saveRefreshToken(token: String) {
dataStore.edit { it[REFRESH_TOKEN] = token }
}
}
이 정보들을 토대로 ResultRepositoryTest 코드를 아래와 같이 구현했다.
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
class ResultRepositoryTest {
companion object {
private const val TEST_DATASTORE_NAME: String = "test_datastore"
}
//MockWebServer 사용
private lateinit var mockWebServer: MockWebServer
//Retrofit2에서 사용하는 Service 인터페이스
private lateinit var sut: ResultService
//DataStore - accessToken, refreshToken, 저장 및 가져오기
private lateinit var accessTokenProvider: AccessTokenProvider
//테스트할 Repository
private lateinit var repository: ResultRepository
//Test용 디스패처
private val dispatcher = StandardTestDispatcher()
//테스트용 스코프
private lateinit var testScope: TestScope
//테스트용 DataStore
private val testContext: Context = RuntimeEnvironment.getApplication().applicationContext
private lateinit var testDataStore: DataStore<Preferences>
//테스트 시작 전 실행할 함수
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
testScope = TestScope()
//데이터스토어 생성하기
testDataStore = PreferenceDataStoreFactory.create(
scope = testScope,
produceFile = { testContext.preferencesDataStoreFile(TEST_DATASTORE_NAME) }
)
//MockWebServer 초기 작업
mockWebServer = MockWebServer()
mockWebServer.start()
//okHttpClient 설정 - Logging으로 확인
//TestAuthInterceptor는 accessToken 값을 넣는 과정이다.
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(ApiTestUtils.httpLoggingInterceptor)
.addInterceptor(TestAuthInterceptor())
.build()
//retrofit Builder 생성
val retrofit = ApiTestUtils.createRetrofit(
baseUrl = mockWebServer.url("/"),
client = okHttpClient
)
//해당 빌더를 통해 ResultService를 만듦
sut = retrofit.create(ResultService::class.java)
accessTokenProvider = TokenProviderFake(testDataStore)
//repository 생성(생성자 주입)
repository = ResultRepositoryFake(sut, accessTokenProvider)
}
@Test
fun `인증정보요청시_데이터스토어_토큰저장`() = testScope.runTest {
//given
val username = "test@test.com"
val pw = "test"
val json = """
{
"token": "1234",
"refreshToken": "5678",
"scope": null
}
""".trimIndent()
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(json)
)
// //when
repository.postUserAuthentication(username, pw).firstOrNull()
val recordRequest = mockWebServer.takeRequest()
//then
//api가 호출 되었는지
assertThat(recordRequest.path).isEqualTo("/api/auth/login")
//저장된 데이터 (accessToken, refreshToken)이 잘 저장되엇는지
assertThat(accessTokenProvider.getAccessToken()).isEqualTo("1234")
assertThat(accessTokenProvider.getRefreshToken()).isEqualTo("5678")
}
@Test
fun `유저_정보_가져오기`() = testScope.runTest {
val json = """
{
"id": {
"entityType": "USER",
"id": "userIdToken"
}
}
""".trimIndent()
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(json)
)
val userInfo = repository.getUserInformation().first()
val recordRequest = mockWebServer.takeRequest()
//api가 호출 되었는지
assertThat(recordRequest.path).isEqualTo("/api/auth/user")
assertThat(userInfo.id.entityType).isEqualTo("USER")
assertThat(userInfo.id.id).isEqualTo("userIdToken")
}
@Test
fun `유저의_건강상태_정보_가져오기`() = testScope.runTest {
val userId = "userIdToken"
val json = """
[
{
"lastUpdateTs": 1738562805419,
"key": "gender",
"value": 1
},
{
"lastUpdateTs": 1738562788741,
"key": "birth",
"value": 20000101
},
{
"lastUpdateTs": 1738562888033,
"key": "hr",
"value": 88
},
{
"lastUpdateTs": 1738562869064,
"key": "bp",
"value": "133,74"
}
]
""".trimIndent()
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(json)
)
val retrieveUserAttribute = repository.retrieveUserAttribute(userId).first()
val recordRequest = mockWebServer.takeRequest()
//api가 호출 되었는지
assertThat(recordRequest.path).isEqualTo("/api/plugins/telemetry/USER/$userId/values/attributes/SERVER_SCOPE")
assertThat(retrieveUserAttribute.gender).isEqualTo(Gender.FE_MALE)
assertThat(retrieveUserAttribute.age).isEqualTo(20000101.getAge())
assertThat(retrieveUserAttribute.hr).isEqualTo(Hr(88))
assertThat(retrieveUserAttribute.bp).isEqualTo(Bp(133, 74))
}
//테스트가 끝날 때마다 진행하는 함수
@After
fun cleanUp() {
Dispatchers.resetMain()
testScope.cancel()
mockWebServer.shutdown()
}
}
Flow객체를 반환하기 때문에 runTest를 진행했는데, 여기서 주목해야할 점은 testScope.runTest를 진행했다는 것이다. 이는 데이터 스토어를 testScope를 사용했는데 다른 runTest를 실행하게 되면 의도하지 않는 결과가 나올 수 있다.
때문에 하나의 scope를 통해서 runTest를 진행을 했으며 결과를 first()를 통해 가져왔다.
이후 mockWebServer.takeRequest()를 통해 path 정보가 호출됐는지 확인이 가능하다. 이외에도 다양한 기능들이 있다.

보니까 body나 header 정보도 있어서 인증관련 토큰 값이 왔는지도 확인이 가능할 것 같다.