[Android] MockWebServer를 활용한 네트워크 응답 결과 테스트 (with. CallAdapter)

hxeyexn·2025년 2월 8일

오늘은 MockWebServer와 JUnit5를 활용해 CallAdapter의 동작을 테스트하는 방법에 대해 알아보려고 한다. 다음은 글의 목차이다.

목차

  • Intro: CallAdapter 테스트를 작성하고자 했던 이유
  • MockWebServer를 활용한 이유
  • MockWebServer 사용법
  • 테스트 작성하기
  • Outro

Intro: CallAdapter 테스트를 작성하고자 했던 이유

스타카토CallAdapter를 적용하는 과정에서 CallAdapter가 네트워크 응답을 정상적으로 처리하는 지 확인해야 했다. 수동 테스트로는 오류가 발생하는 상황을 재현하기가 쉽지 않았다. 올바르지 않은 데이터 상태나, API 명세에 맞지 않은 요청 등과 같이, 유효하지 않은 형식의 요청을 서버로 보낼 수 없었다. 앱에서 UX적으로 처리해두었기 때문이다.

ex) 필수 입력값 미입력 시 버튼 비활성화 등..

그래서 테스트가 필요하다고 느꼈다. 외부 라이브러리인 Retrofit과 이를 활용하는 CallAdapter를 함께 묶어서 테스트해야했기 때문에 통합 테스트를 추가하기로 결정했다. 통합 테스트란 두 개 이상의 단위가 함께 잘 작동하는지 확인하는 테스트이다. 단위 테스트와 달리 개발자가 변경할 수 없는 부분(ex. 외부 라이브러리)까지 함께 검증할 때 사용한다.

이번 포스팅에서는 JUnit5와 AssertJ, MockWebServer를 활용해 테스트를 작성했다.


MockWebServer를 활용한 이유

A scriptable web server for testing HTTP clients

📖 MockWebServer GitHub

MockWebServer란 HTTP 클라이언트 테스트를 위한 스크립트 가능한 웹서버이다. MockWebServer를 활용한 이유는 개발 환경과 테스트 환경의 의존성을 분리하기 위해서였다.

서버의 응답에 따른 적합한 상태를 CallAdapter가 반환하는지만 확인하면 되기 때문에, 실제 서버에 요청을 보낼 필요는 없다고 판단했다. 또한, 실제 서버로 요청을 보내면 데이터가 계속 쌓이게 되어 이를 피하고자 했다.

그렇다면 MockWebServer는 어떻게 사용해야 할까?


MockWebServer 사용법

MockWebServer를 사용하려면build.gradle.kts(:app)에 의존성을 추가해줘야 한다.

build.gradle.kts(:app)

dependencies {
    testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
    ...
}

의존성을 추가한 후 테스트용 retrofit을 생성하는 과정에서 baseUrl(..)의 인자로 mockWebServer.url("/")을 전달해야 한다. mockWebServer.url("/")을 전달해주면 포트 번호가 랜덤으로 지정되어 실행된다. 만약 동일한 포트 번호를 사용하면 다른 프로세스가 해당 포트를 사용 중일 경우 충돌이 발생할 수 있다. 따라서 테스트 간의 충돌을 방지하고자 포트 번호를 랜덤으로 설정되게 했다.

fun buildRetrofitFor(mockWebServer: MockWebServer): Retrofit {
    val jsonBuilder = Json { coerceInputValues = true }
    val url = mockWebServer.url("/") // 포트 번호가 랜덤으로 지정되어 실행된다.

    return Retrofit.Builder() // Retrofit 객체 생성
        .baseUrl(url) 
        .addConverterFactory(
            jsonBuilder.asConverterFactory("application/json".toMediaType()),
        )
        .addCallAdapterFactory(ApiResultCallAdapterFactory.create())
        .build()
}

가짜 응답은 MockResponse() 객체를 활용해 만들 수 있다. 기본적으로 빈 응답 본문과 200 status code를 반환해준다. setResponseCode(...)setBody(...)를 사용해 응답을 커스텀할 수도 있다. 필자는 200 status code 이외에 다른 code들도 검증해야 했기 때문에 아래와 같이 커스텀하여 사용했다.

fun createMockResponse(
    code: Int,
    body: String, // JSON 형태의 문자열이 들어간다
) = MockResponse().setResponseCode(code).setBody(body)

이제 MockWebServer를 사용할 준비가 다 되었다. MockWebServer를 사용하고자 하는 테스트 파일에서 start() 함수를 이용해 MockWebServer를 실행시키면 된다. 테스트가 끝난 후에는 서버가 종료될 수 있도록 shutdown() 함수를 호출하면 된다.

class ApiResultCallAdapterTest {
    private val mockWebServer: MockWebServer = MockWebServer()

    @BeforeEach
    fun setUp() {
        mockWebServer.start()
    }
    
    ...
    
    @AfterEach
    fun tearDown() {
        mockWebServer.shutdown()
    }
}

이제 MockWebServer를 활용해 테스트를 작성해보자!


테스트 작성하기

응답을 가상으로 설정한 후에 ApiService 메서드의 반환값이 응답 결과에 따라 올바르게 반환 되는지 검증했다. ApiResultCallAdapter 테스트는 HTTP status code별로 예상 시나리오를 생각해 작성했다.

테스트 시나리오

  • 200: 카테고리 조회 성공
  • 201: 카테고리 생성 성공
  • 400: 카테고리 생성 오류
  • 401: 인증되지 않은 사용자가 카테고리 생성을 요청 했을 때
  • 403: 댓글 삭제를 요청한 사용자와 댓글 작성자의 사용자 인증 정보가 일치하지 않을 때
  • 413: 용량을 초과하는 사진 업로드
  • 500: 서버 장애
  • 예외: 응답 X

각 테스트 별로, 요청에 따른 서버의 응답을 가상으로 설정한다(MockResponse 생성 및 enqueue). 그 후, ApiService 메서드 요청의 반환 값인 ApiResult가 CallAdapter에 의해 올바른 타입으로 반환되는지 검증했다.

코드 리뷰 전 CallAdapter 테스트

@ExperimentalCoroutinesApi
@ExtendWith(CoroutinesTestExtension::class)
class ApiResultCallAdapterTest {
    private val mockWebServer: MockWebServer = MockWebServer()

    private lateinit var memoryApiService: MemoryApiService
    private lateinit var imageApiService: ImageApiService
    private lateinit var commentApiService: CommentApiService

    @BeforeEach
    fun setUp() {
        mockWebServer.start()

        retrofit = buildRetrofitFor(mockWebServer)
        memoryApiService = retrofit.create(MemoryApiService::class.java)
        imageApiService = retrofit.create(ImageApiService::class.java)
        commentApiService = retrofit.create(CommentApiService::class.java)
    }

    @Test
    fun `존재하는 카테고리를 조회하면 카테고리 조회에 성공한다`() {
        val success: MockResponse =
            createMockResponse(
                code = 200,
                body = createMemoryResponse(),
            )
        mockWebServer.enqueue(success)

        runTest {
            val actual: ApiResult<MemoryResponse> =
                memoryApiService.getMemory(memoryId = 1)

            assertThat(actual).isInstanceOf(Success::class.java)
        }
    }

    @Test
    fun `유효한 형식의 카테고리로 생성을 요청하면 카테고리 생성에 성공한다`() {
        val success: MockResponse =
            createMockResponse(
                code = 201,
                body = createMemoryCreationResponse(),
            )
        mockWebServer.enqueue(success)

        runTest {
            val actual: ApiResult<MemoryCreationResponse> =
                memoryApiService.postMemory(createValidMemoryRequest())

            assertThat(actual).isInstanceOf(Success::class.java)
        }
    }

    @Test
    fun `유효하지 않은 형식의 카테고리로 생성을 요청하면 오류가 발생한다`() {
        val serverError: MockResponse =
            createMockResponse(
                code = 400,
                body = createErrorBy400(),
            )
        mockWebServer.enqueue(serverError)

        runTest {
            val actual: ApiResult<MemoryCreationResponse> =
                memoryApiService.postMemory(createInvalidMemoryRequest())

            assertThat(actual).isInstanceOf(ServerError::class.java)
        }
    }

    @Test
    fun `인증되지 않은 사용자가 카테고리 생성을 요청하면 오류가 발생한다`() {
        val serverError: MockResponse =
            createMockResponse(
                code = 401,
                body = createErrorBy401(),
            )
        mockWebServer.enqueue(serverError)

        runTest {
            val actual: ApiResult<MemoryCreationResponse> =
                memoryApiService.postMemory(createValidMemoryRequest())

            assertThat(actual).isInstanceOf(ServerError::class.java)
        }
    }

    @Test
    fun `댓글 삭제를 요청한 사용자와 댓글 작성자의 인증 정보가 일치하지 않으면 오류가 발생한다`() {
        val serverError: MockResponse =
            createMockResponse(
                code = 403,
                body = createErrorBy403(),
            )
        mockWebServer.enqueue(serverError)

        runTest {
            val actual: ApiResult<Unit> =
                commentApiService.deleteComment(commentId = 1)

            assertThat(actual).isInstanceOf(ServerError::class.java)
        }
    }

    @Test
    fun `20MB를 초과하는 사진을 업로드 요청하면 오류가 발생한다`() {
        val serverError =
            createMockResponse(
                code = 413,
                body = createErrorBy413(),
            )
        mockWebServer.enqueue(serverError)

        runTest {
            val actual: ApiResult<ImageResponse> =
                imageApiService.postImage(imageFile = createFakeImageFile())

            assertThat(actual).isInstanceOf(ServerError::class.java)
        }
    }

    @Test
    fun `카테고리 생성 요청 중 서버 장애가 생기면 오류가 발생한다`() {
        val serverError =
            createMockResponse(
                code = 500,
                body = createErrorBy500(),
            )
        mockWebServer.enqueue(serverError)

        runTest {
            val actual: ApiResult<MemoryCreationResponse> =
                memoryApiService.postMemory(createValidMemoryRequest())

            assertThat(actual).isInstanceOf(ServerError::class.java)
        }
    }

    @Test
    fun `카테고리 생성 요청 중 서버의 응답이 없다면 예외가 발생한다`() {
        mockWebServer.enqueue(MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE))

        runTest {
            val actual: ApiResult<MemoryCreationResponse> =
                memoryApiService.postMemory(createValidMemoryRequest())

            assertThat(actual).isInstanceOf(Exception::class.java)
        }
    }

    @AfterEach
    fun tearDown() {
        mockWebServer.shutdown()
    }
}

(여기서 끝인 줄 알았으나... 스타카토엔 고봉밥 코드 리뷰가 있지..)

위 코드의 문제점?

아뿔싸! CallAdapter의 동작을 검증하는 테스트를 작성하면서, 각 HTTP status code 별 테스트 케이스를 고민하다 보니 테스트에서 특정 도메인이 드러나게 되었다.
순수하게 네트워크 요청 및 응답을 테스트하는 과정에서 특정 도메인이 드러날 경우, 어떤 장단점이 있을까?

도메인이 드러났을 때 장점은?
어떤 상황에서 특정 응답이 나타나는 지 한눈에 보인다.

도메인이 드러났을 때 단점은?
도메인명과 비즈니슥 로직이 변경되면 테스트에도 변경사항이 적용되어야 한다.

위와 같은 장단점을 고려해본 결과, 장점보다 단점이 더 크게 느껴져 리뷰 내용을 반영하기로 결정했다.

비즈니스 로직이나 도메인과 직접적인 관련이 없는 테스트를 작성하는 방법에 대해 동료들과 코드 리뷰를 통해 의견을 주고 받았다.

결과적으로 네트워크 요청 및 응답을 테스트하기 위한 가짜 ApiService를 만들기로 결정했다. 가짜 ApiService를 유지보수하는 것에 대한 걱정은 있었지만, 주로 CRUD 작업을 다루므로 변경이 많지 않을 것으로 판단했다.

FakeApiService.kt

interface FakeApiService {
    @GET("/get/{id}")
    suspend fun get(
        @Path("id") id: Long,
    ): ApiResult<GetResponse>

    @POST("/post")
    suspend fun post(
        @Body request: PostRequest,
    ): ApiResult<PostResponse>

    @DELETE("/delete/{id}")
    suspend fun delete(
        @Path("id") id: Long,
    ): ApiResult<Unit>

    @Multipart
    @POST("/images")
    suspend fun postImage(
        @Part imageFile: MultipartBody.Part,
    ): ApiResult<ImagePostResponse>
}

결과적으로 FakeApiService를 활용함으로써 네트워크 요청 및 응답에 집중한 순수한 CallAdapter 검증 테스트를 작성할 수 있었다.

코드 리뷰 후 CallAdapter 테스트

@ExperimentalCoroutinesApi
@ExtendWith(CoroutinesTestExtension::class)
class ApiResultCallAdapterTest {
    private val mockWebServer: MockWebServer = MockWebServer()

    private lateinit var fakeApiService: FakeApiService

    @BeforeEach
    fun setUp() {
        mockWebServer.start()

        val retrofit = buildRetrofitFor(mockWebServer)
        fakeApiService = retrofit.create(FakeApiService::class.java)
    }

    @Test
    fun `존재하는 데이터를 조회하면 데이터 조회에 성공한다`() {
        val success: MockResponse =
            createMockResponse(
                code = 200,
                body = createGetResponse(),
            )
        mockWebServer.enqueue(success)

        runTest {
            val actual: ApiResult<GetResponse> = fakeApiService.get(id = 1)

            assertThat(actual).isInstanceOf(Success::class.java)
        }
    }

    @Test
    fun `유효한 형식의 데이터로 생성을 요청하면 데이터 생성에 성공한다`() {
        val success: MockResponse =
            createMockResponse(
                code = 201,
                body = createPostResponse(),
            )
        mockWebServer.enqueue(success)

        runTest {
            val actual: ApiResult<PostResponse> = fakeApiService.post(request = createValidRequest())

            assertThat(actual).isInstanceOf(Success::class.java)
        }
    }

    @Test
    fun `유효하지 않은 형식의 데이터로 생성을 요청하면 오류가 발생한다`() {
        val serverError: MockResponse =
            createMockResponse(
                code = 400,
                body = createErrorBy400(),
            )
        mockWebServer.enqueue(serverError)

        runTest {
            val actual: ApiResult<PostResponse> = fakeApiService.post(request = createInvalidRequest())

            assertThat(actual).isInstanceOf(ServerError::class.java)
        }
    }

    @Test
    fun `인증되지 않은 사용자가 데이터 생성을 요청하면 오류가 발생한다`() {
        val serverError: MockResponse =
            createMockResponse(
                code = 401,
                body = createErrorBy401(),
            )
        mockWebServer.enqueue(serverError)

        runTest {
            val actual: ApiResult<PostResponse> = fakeApiService.post(request = createValidRequest())

            assertThat(actual).isInstanceOf(ServerError::class.java)
        }
    }

    @Test
    fun `데이터 삭제를 요청한 사용자와 데이터 작성자의 인증 정보가 일치하지 않으면 오류가 발생한다`() {
        val serverError: MockResponse =
            createMockResponse(
                code = 403,
                body = createErrorBy403(),
            )
        mockWebServer.enqueue(serverError)

        runTest {
            val actual: ApiResult<Unit> = fakeApiService.delete(id = 1)

            assertThat(actual).isInstanceOf(ServerError::class.java)
        }
    }

    @Test
    fun `서버의 제한 용량을 초과하는 사진을 업로드하면 오류가 발생한다`() {
        val serverError =
            createMockResponse(
                code = 413,
                body = createErrorBy413(),
            )
        mockWebServer.enqueue(serverError)

        runTest {
            val actual: ApiResult<ImagePostResponse> = fakeApiService.postImage(imageFile = createFakeImageFile())

            assertThat(actual).isInstanceOf(ServerError::class.java)
        }
    }

    @Test
    fun `데이터 생성 요청 중 서버 장애가 생기면 오류가 발생한다`() {
        val serverError =
            createMockResponse(
                code = 500,
                body = createErrorBy500(),
            )
        mockWebServer.enqueue(serverError)

        runTest {
            val actual: ApiResult<PostResponse> = fakeApiService.post(request = createValidRequest())

            assertThat(actual).isInstanceOf(ServerError::class.java)
        }
    }

    @Test
    fun `데이터 생성 요청 중 서버의 응답이 없다면 예외가 발생한다`() {
        mockWebServer.enqueue(MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE))

        runTest {
            val actual: ApiResult<PostResponse> = fakeApiService.post(request = createValidRequest())

            assertThat(actual).isInstanceOf(Exception::class.java)
        }
    }

    @AfterEach
    fun tearDown() {
        mockWebServer.shutdown()
    }
}

Outro

테스트를 작성하며 많은 난관에 부딪혔지만, 결국 네트워크 요청 및 응답에 집중한 순수한 CallAdapter 검증 테스트를 완성할 수 있어 뿌듯했다. 모처럼 재밌었다!😆

profile
Android Developer

0개의 댓글