안녕하세요. 🙂 Android를 담당하고 있는 다나, 밀리입니다! 🙇🏻‍♀️

공비서 CRM Android 앱을 좀 더 효율적으로 구현하기 위해 Mock API를 도입하여 이를 소개해보려고 합니다!

0. Mock File 컨벤션 정의


우선, Android 셀원들의 코드를 빠르게 이해하고 구현할 수 있도록 Mock 데이터의 JSON 파일을 생성하기 위한 컨벤션을 정의했습니다.

  1. 저장 공간으로 testFixtures 모듈 사용

    테스트 및 기능 테스트에 필요한 Mock API 데이터 및 테스트 코드를 위한 Mock 데이터를 공통된 JSON 파일로 사용하기 위해 testFixtures 모듈을 활용하기로 했습니다.

    <aside> 💡 testFixtures module이란?

  2. JSON 파일명 규칙 정의

    API request path를 기준으로 파일명의 컨벤션을 정의했습니다.

    이를 통해 각 파일이 어떤 요청에 대응되는지 빠르게 파악할 수 있었습니다.😆

  3. API 도메인 별로 폴더 분리

    모든 JSON 파일을 한 곳에 두면 관리가 어려워집니다. 따라서 API 도메인에 따라 폴더를 분리하여 구조화하고, 각 도메인에 해당하는 폴더 경로를 반환하는 함수를 구현했습니다.

    이를 통해 관련 파일을 빠르게 찾을 수 있었습니다.😏

    /**
     * request url의 각 도메인에 해당하는 폴더 경로를 반환하는 함수
     * */
    private fun getDomainFolder(path: String): String {
        with(path) {
            return when {
                contains("login") -> "login"
                contains("users") -> "users"
    						// 다른 도메인에 대한 분기 처리 ...
                else -> "shop"
            }
        }
    }
    

1. 화면 및 기능 구현에서의 Mock API


왜 구현하게 되었나요?

보통 앱을 개발할 때에는 기획, 디자인, API(서버)가 필요합니다. 그러나 이러한 세 단계를 차례대로 진행하면 실제 API가 완성될 때까지 상당한 시간이 소요될 수 있습니다.😢

디자인 기반으로 UI를 먼저 구현하고 실제 API가 완성될 때까지 기다린 후 연결하는 식으로 작업을 진행했더니, 프로젝트 중간에 텀이 생겨 몰입도가 깨지거나, 프로젝트 끝 무렵에 급하게 작업하는 경우가 많이 있었습니다.

그렇다면, 이러한 시간 소요를 줄이기 위해 어떻게 하면 좋을까요?

기획 및 디자인 단계에서 백엔드 셀과의 협의를 통해 Mock API나 데이터를 미리 얻을 수 있습니다. 이를 활용하여 Android 앱의 화면과 기능을 실제 API 없이도 먼저 구현할 수 있습니다.

<aside> 💡 백엔드 셀에서 실제 API를 개발하는 동안 Android 셀은 이미 디자인과 Mock API를 활용하여 화면의 초기 버전을 만들어 놓을 수 있습니다. 나중에 실제 API가 완성되면 해당 API로 간단히 교체하여 최종 화면을 완성할 수 있게 됩니다👍

</aside>

구현방법

  1. JsonInterceptor구현

    네트워크 요청 및 응답 중에서 JSON 데이터를 가로채는 역할을 합니다.

    class JsonInterceptor : Interceptor {
    		override fun intercept(chain: Interceptor.Chain): Response {
            val request = chain.request()
    
    				val formattedFileName = StringBuilder().run {
    						// 정의된 컨벤션에 따라 파일명 생성 ...
    
                append(".json")
            }
    
            println("fileName: ${getDomainFolder(uri.path.default())}/$formattedFileName")
    
            val inputStream = javaClass.classLoader?.getResourceAsStream(
                "${getDomainFolder(uri.path.default())}/$formattedFileName"
            )
    
    			  // 요청에 해당하는 .json 파일 읽기 / 해당하는 .json 파일이 없을 경우 실제 서버로 요청 전송
            val source =
                inputStream?.let { inputStream.source().buffer() } ?: return chain.proceed(request)
    
    				// 새로운 mock JSON response 생성
            val response = chain.proceed(request)
                .newBuilder()
                .apply {
                    body(
                        source.readString(StandardCharsets.UTF_8)
                            .toResponseBody("application/json".toMediaType())
                    )
                    protocol(Protocol.HTTP_2)
                    addHeader("content-type", "application/json")
                    code(code.toInt())
                }
                .build()
    
    				return response
    		}
    }
    
  2. 특정 상황에서만 동작하도록 설정

    class JsonInterceptor : Interceptor {
    		override fun intercept(chain: Interceptor.Chain): Response {
            val request = chain.request()
    				val isMock = request.url.queryParameter(MOCK)
    
    				...
    
    				// Mock api를 사용하고 싶지 않거나, Debug모드가 아니면 실제 서버로 요청 전송
    				if (isMock.not() || BuildConfig.DEBUG.not()) {
                return chain.proceed(request)
            }
    				
    				...
    
    		}
    }
    
  3. OkHttpClientJsonInterceptor 추가

    공비서 CRM은 네트워크 통신을 하기 위해서 retrofit2+Okhttp 사용하므로, 구현한 JsonInterceptorokHttpClientBuilder에 추가해줍니다.

    @Singleton
    @Provides
    fun provideOKHttpClient(
        interceptor: Interceptor,
        httpLoggingInterceptor: HttpLoggingInterceptor,
    ): OkHttpClient {
        val okHttpClientBuilder =
            OkHttpClient().newBuilder()
    
        return okHttpClientBuilder
            .addInterceptor(interceptor)
            .addInterceptor(httpLoggingInterceptor)
            .addInterceptor(JsonInterceptor()) // JsonInterceptor 추가
            .build()
    }
    
  4. Service에서 query isMock: Boolean = true로 호출

    /**
     * Mock api
     */
    @GET("/api/v2/users/test")
    suspend fun getMockTest(
        @Header(GD_AUTH_TOKEN) token: String,
        @Query(JsonInterceptor.MOCK) isMock: Boolean = true  // ture: mock api / false: 실제 api)
    ): NetworkResponse<ResponseBase<MockTestEntity>, ErrorResponse>
    
    /**
     * 실제 api
     */
    @GET("/api/v2/users/test")
    suspend fun getMockTest(
        @Header(GD_AUTH_TOKEN) token: String
    ): NetworkResponse<ResponseBase<MockTestEntity>, ErrorResponse>
    

사용해 보니 어떤가요?

  1. 개발 구현 속도 단축

    Mock API를 활용함으로써 백엔드 셀에서 실제 API를 개발하는 동안에도 Android 셀에서는 이미 초기 버전의 화면을 개발할 수 있었습니다.

    이로 인해 전체적인 개발 속도가 단축되었습니다. 👍

  2. 다양한 케이스 테스트 가능

    Mock API를 활용하면 예외 케이스나 다양한 시나리오에 대한 테스트를 쉽게 수행할 수 있었습니다.

    실제 API의 응답을 기다릴 필요 없이, 원하는 데이터를 빠르게 설정하여 다양한 시나리오를 테스트할 수 있었습니다. 👍👍

  3. isMock 값으로 언제든지 전환 가능

    JsonInterceptor에서 사용한 isMock 상수나 Debug 모드로 Mock API와 실제 API 간을 전환할 수 있어 편리했습니다.

    이는 필요에 따라 실제 서버로 전환하여 실제 환경에서의 동작을 테스트할 수 있음을 의미합니다. 👍👍👍

  4. 테스트 코드 작성 용이

    미리 정의된 JSON 파일을 사용하여 테스트 코드를 작성할 수 있었습니다.

    특정 응답이 어떻게 처리되는지를 미리 알 수 있어 테스트 코드 작성이 편리해졌습니다. 👍👍👍👍

<aside> 💡 Mock API를 추출하기 위한 셀 간 협업을 강화하고, 개발 중 발생할 수 있는 다양한 상황에 대응할 수 있도록 도와주었습니다! 결론으로는 Android 앱 개발 프로세스를 효율적으로 만들어 주었습니다!!!

</aside>

2. 테스트코드의 MockWebServer


왜 구현하게 되었나요?

지난 글에서 소개해 드렸던 것처럼 저희 Android셀에서는 API 응답 성공, 실패에 따른 비즈니스 로직을 검증하기 위해 ViewModel 에 대한 테스트 코드를 작성하고 있습니다.

저희 서비스에는 약 50개 정도의 ViewModel 이 있고 각 ViewModel 마다 적게는 1~2개 많게는 6~7개의 repository와 usecase를 주입해서 사용하고 있습니다.

각 ViewModel 에서 사용하는 모든 API 응답값 (성공 / 실패 / 예외 케이스)에 대해 Entity 데이터를 만들어서 작업하다보니 데이터 설정하는 작업도 하나의 일이 되어 귀찮고 불편하다는 의견이 나왔습니다. 😱

이런 불편함을 어떻게 해결할 수 있을까요?

Entity 또는 VO object 를 일일이 설정하지 않고 API 응답값으로 받는 Json 파일을 그대로 활용하면 데이터 설정 작업을 줄일 수 있습니다. 👍

구현 방법

  1. MockWebServerService 클래스 구현