안녕하세요.
Android를 담당하고 있는 다나, 밀리입니다!
공비서 CRM Android 앱을 좀 더 효율적으로 구현하기 위해 Mock API를 도입하여 이를 소개해보려고 합니다!
0. Mock File 컨벤션 정의
우선, Android 셀원들의 코드를 빠르게 이해하고 구현할 수 있도록 Mock 데이터의 JSON 파일을 생성하기 위한 컨벤션을 정의했습니다.
1.
저장 공간으로 testFixtures 모듈 사용
테스트 및 기능 테스트에 필요한 Mock API 데이터 및 테스트 코드를 위한 Mock 데이터를 공통된 JSON 파일로 사용하기 위해 testFixtures 모듈을 활용하기로 했습니다.
testFixtures module이란?
•
Android 프로젝트에서 테스트를 수행할 때 사용되는 픽스처를 제공하는 모듈로,
•
주로 단위 테스트, 통합 테스트 또는 기타 Android 애플리케이션 테스트에서 필요한 리소스, 데이터, 설정을 포함합니다.
•
가상의 테스트 데이터를 제공하는 데 사용가능하며, 테스트 시에 일관된 테스트 환경을 유지하고, 테스트가 서로 영향을 끼치지 않도록 할 수 있습니다.
•
테스트 코드와 실제 애플리케이션 코드 간의 결합을 최소화하고 테스트의 격리성을 유지하는 데 도움이 됩니다.
2.
JSON 파일명 규칙 정의
API request path를 기준으로 파일명의 컨벤션을 정의했습니다.
이를 통해 각 파일이 어떤 요청에 대응되는지 빠르게 파악할 수 있었습니다.
•
파일명 구분자는 _
•
Request method: GET/POST/PATCH/PUT 소문자
•
Request URL path
•
Request URL query 또는 param
•
Response code
•
Response apiResult
•
Account ID (임시: herren)
•
ex)
@GET("/api/v2/users/mock/{$TEST_NO}")
→ get_api_v2_users_2_200_success_herren.json
3.
API 도메인 별로 폴더 분리
모든 JSON 파일을 한 곳에 두면 관리가 어려워집니다. 따라서 API 도메인에 따라 폴더를 분리하여 구조화하고, 각 도메인에 해당하는 폴더 경로를 반환하는 함수를 구현했습니다.
이를 통해 관련 파일을 빠르게 찾을 수 있었습니다.
/**
* request url의 각 도메인에 해당하는 폴더 경로를 반환하는 함수
* */
private fun getDomainFolder(path: String): String {
with(path) {
return when {
contains("login") -> "login"
contains("users") -> "users"
// 다른 도메인에 대한 분기 처리 ...
else -> "shop"
}
}
}
Kotlin
복사
1. 화면 및 기능 구현에서의 Mock API
왜 구현하게 되었나요?
보통 앱을 개발할 때에는 기획, 디자인, API(서버)가 필요합니다.
그러나 이러한 세 단계를 차례대로 진행하면 실제 API가 완성될 때까지 상당한 시간이 소요될 수 있습니다.
디자인 기반으로 UI를 먼저 구현하고 실제 API가 완성될 때까지 기다린 후 연결하는 식으로 작업을 진행했더니, 프로젝트 중간에 텀이 생겨 몰입도가 깨지거나, 프로젝트 끝 무렵에 급하게 작업하는 경우가 많이 있었습니다.
그렇다면, 이러한 시간 소요를 줄이기 위해 어떻게 하면 좋을까요?
기획 및 디자인 단계에서 백엔드 셀과의 협의를 통해 Mock API나 데이터를 미리 얻을 수 있습니다.
이를 활용하여 Android 앱의 화면과 기능을 실제 API 없이도 먼저 구현할 수 있습니다.
백엔드 셀에서 실제 API를 개발하는 동안 Android 셀은 이미 디자인과 Mock API를 활용하여 화면의 초기 버전을 만들어 놓을 수 있습니다.
나중에 실제 API가 완성되면 해당 API로 간단히 교체하여 최종 화면을 완성할 수 있게 됩니다
구현방법
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
}
}
Kotlin
복사
2.
특정 상황에서만 동작하도록 설정
•
Debug 모드에서만 동작
•
Mock api를 사용하고 싶을 때만 동작
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)
}
...
}
}
Kotlin
복사
3.
OkHttpClient에 JsonInterceptor 추가
공비서 CRM은 네트워크 통신을 하기 위해서 retrofit2+Okhttp 사용하므로, 구현한 JsonInterceptor을 okHttpClientBuilder에 추가해줍니다.
@Singleton
@Provides
fun provideOKHttpClient(
interceptor: Interceptor,
httpLoggingInterceptor: HttpLoggingInterceptor,
): OkHttpClient {
val okHttpClientBuilder =
OkHttpClient().newBuilder()
return okHttpClientBuilder
.addInterceptor(interceptor)
.addInterceptor(httpLoggingInterceptor)
.addInterceptor(JsonInterceptor()) // JsonInterceptor 추가
.build()
}
Kotlin
복사
4.
Service에서 query isMock: Boolean = true로 호출
•
getMockTest 메서드 를 호출할 때,
•
isMock = ture를 전달하면 해당하는 JSON 파일이 응답으로 반환합니다.
•
isMock = false이거나, 전달하지 않으면 실제 서버로 요청 전송합니다.
/**
* 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>
Kotlin
복사
사용해 보니 어떤가요?
1.
개발 구현 속도 단축
Mock API를 활용함으로써 백엔드 셀에서 실제 API를 개발하는 동안에도 Android 셀에서는 이미 초기 버전의 화면을 개발할 수 있었습니다.
이로 인해 전체적인 개발 속도가 단축되었습니다.
2.
다양한 케이스 테스트 가능
Mock API를 활용하면 예외 케이스나 다양한 시나리오에 대한 테스트를 쉽게 수행할 수 있었습니다.
실제 API의 응답을 기다릴 필요 없이, 원하는 데이터를 빠르게 설정하여 다양한 시나리오를 테스트할 수 있었습니다.
3.
isMock 값으로 언제든지 전환 가능
JsonInterceptor에서 사용한 isMock 상수나 Debug 모드로 Mock API와 실제 API 간을 전환할 수 있어 편리했습니다.
이는 필요에 따라 실제 서버로 전환하여 실제 환경에서의 동작을 테스트할 수 있음을 의미합니다.
4.
테스트 코드 작성 용이
미리 정의된 JSON 파일을 사용하여 테스트 코드를 작성할 수 있었습니다.
특정 응답이 어떻게 처리되는지를 미리 알 수 있어 테스트 코드 작성이 편리해졌습니다.
Mock API를 추출하기 위한 셀 간 협업을 강화하고, 개발 중 발생할 수 있는 다양한 상황에 대응할 수 있도록 도와주었습니다!
결론으로는 Android 앱 개발 프로세스를 효율적으로 만들어 주었습니다!!!
2. 테스트코드의 MockWebServer
왜 구현하게 되었나요?
지난 글에서 소개해 드렸던 것처럼 저희 Android셀에서는 API 응답 성공, 실패에 따른 비즈니스 로직을 검증하기 위해 ViewModel 에 대한 테스트 코드를 작성하고 있습니다.
저희 서비스에는 약 50개 정도의 ViewModel 이 있고 각 ViewModel 마다 적게는 1~2개 많게는 6~7개의 repository와 usecase를 주입해서 사용하고 있습니다.
각 ViewModel 에서 사용하는 모든 API 응답값 (성공 / 실패 / 예외 케이스)에 대해 Entity 데이터를 만들어서 작업하다보니 데이터 설정하는 작업도 하나의 일이 되어 귀찮고 불편하다는 의견이 나왔습니다.
이런 불편함을 어떻게 해결할 수 있을까요?
Entity 또는 VO object 를 일일이 설정하지 않고 API 응답값으로 받는 Json 파일을 그대로 활용하면 데이터 설정 작업을 줄일 수 있습니다.
구현 방법
1.
MockWebServerService 클래스 구현
a.
mockWebServer gradle 설정
testImplementation("com.squareup.okhttp3:mockwebserver:$version")
Groovy
복사
b.
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)
}
Kotlin
복사
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())
}
}
}
Kotlin
복사
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()
}
}
Kotlin
복사
4.
테스트 코드 작성
a.
MockWebServerViewModelTest 클래스 상속
@ExtendWith(MockKExtension::class)
@ExtendWith(InstantTaskExecutorExtension::class)
@ExperimentalCoroutinesApi
internal class ViewModelTest: MockWebServerViewModelTest() {
...
}
Kotlin
복사
b.
데이터 검증
: 확장 함수 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
)
}
Kotlin
복사
•
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
)
}
Kotlin
복사
•
api 응답에 실패한 경우
@Test
@DisplayName("api 응답에 실패한 경우")
fun calledFetchData_response_error() {
// Arrange
mockWebServer.dispatchResponse(
code = 400,
apiResult = "apiError"
)
fetchDataArrange()
// Act
viewModel.fetchData()
// Assert
verify {
...
}
}
Kotlin
복사
사용해보니 어떤가요?
1.
API 상태별 테스트 용이
mockWebServer의 확장 함수를 구현하여 네트워크 오류, 서버 에러, 느린 응답 등 다양한 상황에 대응하여 JSON 파일을 지정할 수 있습니다.
그 결과 API 상태별, 예외 상황별 테스트 코드를 간편하게 작성할 수 있었습니다.
2.
리팩터링 용이성
API 수정에 따라 테스트 코드를 수정하거나 개선할 때 더 간편하게 작업할 수 있습니다.
JSON 파일만 수정하여 예상대로 기능이 동작하는 지 쉽게 검증할 수 있었습니다.
3.
불필요한 데이터 설정 코드 감소
정의한 컨벤션에 맞다면 JSON 파일 그대로 데이터로 파싱하여 활용 할 수 있습니다.
중복을 제외 해도 200개가 넘는 Entity 데이터에 대한 설정 코드를 감소할 수 있었습니다.
3. 결론
현재 공비서는 더 나은 서비스를 제공하기 위해 초기 개발 단계의 3가지 프로젝트를 동시에 개발하고 있습니다.
사실 초기 개발 단계에는 기획과 디자인, API(서버) 등 많은 부분이 미완성 상태이고 변화가 많은 시점이기 때문에 잦은 코드 변경을 감수하고 개발을 진행하는 것이 현실입니다.
하지만 이번 Mock API 도입을 통해 실제 API 없이도 프론트엔드, 백엔드 팀과 병렬적으로 개발을 하면서 빠르고 쉽게 테스트 할 수 있었습니다.
또한 각 ViewModel의 구현이 완료되는 시점마다 MockWebServer 를 이용해 테스트 코드를 작성하여 안정성을 보장하면서도 효과적으로 개발을 하고 있습니다.
꾸준한 개선을 이뤄나가며 점점 더 발전하고 가치 있는 공비서가 되기를 바라면서 이만 마치겠습니다.
감사합니다