안녕하세요. 🙂 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 클래스 구현

    1. mockWebServer gradle 설정

      testImplementation("com.squareup.okhttp3:mockwebserver:$version")
      
    2. mockWebServer를 사용하는 service 클래스 생성

      테스트 코드에 사용할 API 요청에 대해 실서버를 대신하여 처리하는 역할을 합니다.

      object MockWebServerService {
        private val client = OkHttpClient.Builder()
            .connectTimeout(1, TimeUnit.SECONDS)
            .readTimeout(1, TimeUnit.SECONDS)
            .writeTimeout(1, TimeUnit.SECONDS)
            .addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY))
            .build()
      
        private fun getRetrofit(mockWebServer: MockWebServer) = Retrofit.Builder()
            .baseUrl(mockWebServer.url("/"))
            .client(client)
            .addCallAdapterFactory(NetworkResponseAdapterFactory()) // response body wrapping
            .addConverterFactory(NullOnEmptyConverterFactory.create()) // 예외 처리
            .addConverterFactory(GsonConverterFactory.create()) //파싱
            .build()
      	
      	fun mockService(mockWebServer: MockWebServer): MockService =
            getRetrofit(mockWebServer).create(MockService::class.java)
      }
      
  2. MockWebServerExtension 확장 함수 구현

    API 요청을 가로채서 요청에 대응하는 Json 파일을 응답 값으로 전달하도록 MockWebServer의 dispatcher를 재정의합니다.

    internal fun MockWebServer.dispatchResponse(
      code: Int = 200,
      apiResult: String = "success",
      account: String = "herren"
    ) {
      dispatcher = object : Dispatcher() {
        override fun dispatch(request: RecordedRequest): MockResponse {
            val uri = request.requestUrl?.toUri()
            val paths = uri?.path?.trim()?.split("/").default()
    
    				// JSON 파일명 규칙에 맞춰 파일명 정의
            val formattedFileName = StringBuilder().run {
                appendStrWithUnderBar(request.method?.lowercase())
                for (path in paths) {
                    if (path.isEmpty().not())
                        appendStrWithUnderBar(path)
                }
                appendStrWithUnderBar(uri?.query)
                appendStrWithUnderBar(code.toString())
                appendStrWithUnderBar(apiResult)
                append(account)
                append(".json")
            }
    
            val file = "${getDomainFolder(uri?.path.default())}/$formattedFileName"
            println("fileName: $file")
    
            val resultCode = if (apiResult == "apiError") 500 else code
            val inputStream = javaClass.classLoader?.getResourceAsStream(file)
            val source = inputStream?.let { inputStream.source().buffer() }
    
            return MockResponse()
                .setResponseCode(resultCode)
                .setBody(source?.readString(StandardCharsets.UTF_8)
    						.toString())
        }
      }
    }
    
  3. MockWebServerViewModelTest 상속 클래스 구현

    테스트 코드 실행 전,후 mockWebServer 를 실행, 종료하는 코드를 작성합니다.

    @ExtendWith(MockKExtension::class)
    @ExtendWith(InstantTaskExecutorExtension::class)
    @ExperimentalCoroutinesApi
    open class MockWebServerViewModelTest : NetworkExtend() {
    	
    	lateinit var mockWebServer: MockWebServer
    	
    	@BeforeEach
    	open fun setUp() {
    			mockWebServer = MockWebServer()
    	    mockWebServer.start()
    	
    	    setServerService()
    			
    	    mockWebServer.dispatchResponse()
    	}
    
    	private fun setServerService() {
    		mockService = MockWebServerService.mockService(mockWebServer)
    	}
    	
    	@AfterEach
    	fun tearDown() {
    	    mockWebServer.shutdown()
    	}
    }
    
  4. 테스트 코드 작성

    1. MockWebServerViewModelTest 클래스 상속

      @ExtendWith(MockKExtension::class)
      @ExtendWith(InstantTaskExecutorExtension::class)
      @ExperimentalCoroutinesApi
      internal class ViewModelTest: MockWebServerViewModelTest() {
      	...
      }
      
    2. 데이터 검증

      : 확장 함수 dispatchResponse의 매개 변수에 따라 API 응답 상태별 동작을 검증할 수 있습니다.

      • api 응답에 성공하고 응답 결과도 성공한 경우

        @Test
        @DisplayName("api 응답에 성공하고 응답 결과도 성공한 경우")
        fun calledFetchData_response_success() = runTest {
            // Arrange
        		val mockVo = MockVo()
        		fetchDataArrange()
        		
        		// Act
            viewModel.fetchData()
        
        		// Assert
            Assertions.assertEquals(
        	      mockVo, // expected
        	      viewModel.item // actual
        	  )
        }
        
      • api 응답은 성공했지만 응답 결과가 실패인 경우

        @Test
        @DisplayName("api 응답은 성공했지만 응답 결과가 실패인 경우")
        fun calledFetchData_result_error() = runTest {
            // Arrange
            mockWebServer.dispatchResponse(
                code = 200,
                apiResult = "fail"
            )
        		fetchDataArrange()
        		
        		// Act
            viewModel.fetchData()
        
        		// Assert
            Assertions.assertEquals(
                Consts.FAIL,
                viewModel.result
            )
        }
        
      • api 응답에 실패한 경우

        @Test
        @DisplayName("api 응답에 실패한 경우")
        fun calledFetchData_response_error() {
            // Arrange
            mockWebServer.dispatchResponse(
                code = 400,
                apiResult = "apiError"
            )
            fetchDataArrange()
        
        		// Act
            viewModel.fetchData()
        
        		// Assert
            verify {
                ...
            }
        }
        

사용해보니 어떤가요?

  1. API 상태별 테스트 용이

    mockWebServer의 확장 함수를 구현하여 네트워크 오류, 서버 에러, 느린 응답 등 다양한 상황에 대응하여 JSON 파일을 지정할 수 있습니다.

    그 결과 API 상태별, 예외 상황별 테스트 코드를 간편하게 작성할 수 있었습니다. 👍