Skip to content

Commit

Permalink
�refactor: CallAdapter 적용 및 검증 테스트 작성 #558 (#574)
Browse files Browse the repository at this point in the history
* refactor: ResponseResult interface와 응답 class 분리

* refactor: Exception message 기본 인자 설정

- ResponseResult Exception의 message 파라미터에 기본 인자 설정

* refactor: 204 응답 처리 로직 제거

* feat: NetworkResultCall 구현

* refactor: status code, exception 메시지 상수화

* feat: NetworkResultCallAdapter 구현

* feat: CallAdapterFactory 구현

* feat: Retrofit 초기화 시 CallAdapterFactory 추가

* refactor: 타임라인 기능 CallAdapter 적용

* refactor: 카테고리(전 추억) 기능 CallAdapter 적용

* refactor: 스타카토 기능 CallAdapter 적용

* refactor: 댓글 기능 CallAdapter 적용

* refactor: 이미지 업로드 기능 CallAdapter 적용

* refactor: 마이페이지 기능 CallAdapter 적용

* refactor: 로그인 기능 CallAdapter 적용

* refactor: 멤버 기능 CallAdapter 적용

* refactor: 이전 handleApiResponse 제거

* refactor: handleApiResponse2 이름 변경

- 이전: handleApiResponse2
- 이후: handleApiResponse

* refactor: ResponseResult -> ApiResult로 이름 변경

* refactor: ApiResponseHandler 이름 변경

- 이전: ApiResponseHandler
- 이후: ApiResultHandler

* refactor: NetworkResultCall 이름 변경

- 이전: NetworkResultCall
- 이후: ApiResultCall

* refactor: NetworkResultCallAdapter 이름 변경

- 이전: NetworkResultCallAdapter
- 이후: ApiResultCallAdapter

* refactor: NetworkResultCallAdapterFactory 이름 변경

- 이전: NetworkResultCallAdapterFactory
- 이후: ApiResultCallAdapterFactory

* refactor: ApiResult 처리 로직 추가

* refactor: 카테고리 리포지터리 ApiResult 처리 확장 함수 적용

* refactor: 스타카토 리포지터리 ApiResult 처리 확장 함수 적용

* refactor: 타임라인 리포지터리 ApiResult 처리 확장 함수 적용

* refactor: 마이페이지 리포지터리 ApiResult 처리 확장 함수 적용

* refactor: 댓글 리포지터리 ApiResult 처리 확장 함수 적용

* refactor: 멤버 리포지터리 ApiResult 처리 확장 함수 적용

* refactor: 로그인 리포지터리 ApiResult 처리 확장 함수 적용

* refactor: 이미지 리포지터리 ApiResult 처리 확장 함수 적용

* build: coroutines test 의존성 추가

* build: mockwebserver 의존성 추가

* build: JUnit5 의존성 추가

* feat: CoroutinesTestExtension 추가

* test: api 요청 성공(200) 시 CallAdapter 동작 테스트 추가

- 테스트 시나리오: 유효한 형식의 카테고리로 생성을 요청

* test: api 요청 실패(400) 시 CallAdapter 동작 테스트 추가

- 테스트 시나리오: 유효하지 않은 형식의 카테고리로 생성을 요청

* test: MockResponse 생성 로직 함수로 분리

* test: api 요청 실패(413) 시 CallAdapter 동작 테스트 추가

- 테스트 시나리오: 20MB를 초과하는 사진을 업로드 요청

* test: api 요청 실패(예외 발생) 시 CallAdapter 동작 테스트 추가

- 테스트 시나리오: 서버의 응답이 없는 경우

* test: api 요청 실패(500) 시 CallAdapter 동작 테스트 추가

- 테스트 시나리오: 서버 장애 발생

* test: api 요청 실패(403) 시 CallAdapter 동작 테스트 추가

- 테스트 시나리오: 댓글 삭제를 요청한 사용자와 댓글 작성자의 인증 정보 불일치

* test: api 요청 실패(401) 시 CallAdapter 동작 테스트 추가

- 테스트 시나리오: 인증되지 않은 사용자가 카테고리 생성을 요청

* test: api 요청 성공(200) CallAdapter 테스트 상태 코드 201로 수정

- api 요청 성공(200) 코드 작성 시 201을 200으로 잘못 작성함
- 따라서 200을 201로 수정함

* test: api 요청 성공(200) 시 CallAdapter 테스트 추가

- 테스트 시나리오: 존재하는 카테고리를 조회

* test: api 응답 TestFixture 추가

* refactor: CallAdapterTest 이름 변경

- 이전: CallAdapterTest
- 이후: ApiResultCallAdapterTest

* refactor: request test fixture 변수를 함수로 변경

- Fixture를 함수로 분리하면 테스트 독립성을 더욱 보장할 수 있음

* refactor: makeFakeImageFile() 함수 이름 변경

- 이전: makeFakeImageFile()
- 이후: createFakeImageFile()

* refactor: MockWebServerFixture 이름 변경

- 이전: MockWebServerFixture
- 이후: MockWebServerProvider

* refactor: makeMockResponse() 함수 이름 변경

- 이전: makeMockResponse()
- 이후: createMockResponse()

* style: ktlintformat

* refactor: 변수 타입 명시

* build: testLogging 추가

* build: testLogging event 추가

- standardError, standardOut 테스트 로그 이벤트 추가

* build: testLogging exceptionFormat 설정

* test: setUp()에서 MockWebServer 초기화

* test: assertTrue -> assertInstanceOf로 변경

* fix: ci 수정을 위한 response 출력문 추가

* fix: ci 오류 해결을 위한 errorBody 출력문 추가

* test: StaccatoClient 초기화

* refactor: retrofit 초기화 방식 변경

- 이전: StaccatoClient에서 초기화
- 이후: StaccatoApplication의 동반 객체에서 retrofit 지연 초기화

* refactor: CI 오류 해결을 위한 print문 제거

* build: AssertJ 의존성 추가

* test: AssertJ를 활용하여 검증 코드 수정

* build: junit vintage engine 의존성 추가

- JUnit4에서 작성된 테스트를 실행시키기 위해 junit vintage engine 의존성 추가

* refactor: FakeFileProvider, ApiDataFixture 폴더 이동

* style: 불필요한 import 제거

* style: trailing comma 제거

* test: api 요청 실패(413) 시 CallAdapter 동작 테스트 명 변경

- 서버의 이미지 제한 용량 변경과 관계 없도록 수정

* refactor: callType 변수를 responseType 으로 이름 변경

- Call<T>의 파라미터 클래스 T의 타입을 획득하여 저장하는 변수라는 것을 명시적으로 나타내기 위해 변수명 변경

* refactor: proxy 변수를 delegate 로 이름 변경

- delegate 패턴을 사용하고 있다는 것을 좀 더 명시적으로 나타내도록 변수명 수정

* refactor: ApiResult<T> 처리 함수 표현식으로 변경

* test: 조회 성공(200) 테스트 FakeApiService 적용

- 네트워크 중심으로 테스트가 이루어지도록 FakeApiService를 활용해 CallAdapter 동작 테스트 수정

* test: 생성 성공/실패/예외 테스트 FakeApiService 적용

- 생성 성공(201), 실패(400, 401, 500), 예외
- 네트워크 중심으로 테스트가 이루어지도록 FakeApiService를 활용해 CallAdapter 동작 테스트 수정

* test: 인증 정보 불일치(403) 테스트 FakeApiService 적용

- 네트워크 중심으로 테스트가 이루어지도록 FakeApiService를 활용해 CallAdapter 동작 테스트 수정

* test: CallAdapterTest 내 사용하지 않는 ApiService 제거

* test: 테스트명 오타 수정

* test: dto 패키지 추가

* test: Inline 학습 테스트 추가

* style: 코드 formatting

* test: Inline 학습 테스트 assertAll 주석 처리

* refactor: 예외 세분화 및 ApiResult<T>.onException이 예외 상태를 포획 하도록 수정

* refactor: LoginRepository에서 사용자 토큰을 저장하도록 변경

- 이전: LoginViewModel에서 SharedPreferences에 사용자 토큰 저장
- 이후: LoginRepository에서 SharedPreferences에 사용자 토큰 저장

* refactor: ApiResult<T> 확장함수 inline 키워드 추가

* refactor: ApiResult<T> 성공, 서버에러, 예외 별 처리 확장함수 파라미터명 변경

- 이전: executable
- 이후: action

* refactor: MemberRepository에서 사용자 토큰을 저장하도록 변경

- 이전: RecoveryViewModel에서 SharedPreferences에 사용자 토큰 저장
- 이후: RecoveryRepository에서 SharedPreferences에 사용자 토큰 저장

* refactor: ApiResult<Unit> 함수 추가 및 적용

* refactor: ApiResult<T>.onServerError 확장함수 status 파라미터 제거
  • Loading branch information
hxeyexn authored Jan 27, 2025
1 parent 7346e49 commit 76848c4
Show file tree
Hide file tree
Showing 71 changed files with 1,063 additions and 899 deletions.
19 changes: 19 additions & 0 deletions android/Staccato_AN/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import java.io.FileInputStream
import java.util.Properties

Expand All @@ -20,6 +21,7 @@ plugins {
alias(libs.plugins.firebaseCrashlytics)
alias(libs.plugins.mapsplatformSecretsGradlePlugin)
alias(libs.plugins.hiltAndroid)
alias(libs.plugins.androidJunit5)
}

android {
Expand All @@ -34,6 +36,8 @@ android {
versionName = "1.2.1"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments["runnerBuilder"] =
"de.mannodermaus.junit5.AndroidJUnit5Builder"

buildConfigField("String", "TOKEN", "${localProperties["token"]}")
}
Expand Down Expand Up @@ -102,6 +106,13 @@ dependencies {
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

// JUnit5
testImplementation(libs.junit.jupiter)
testRuntimeOnly(libs.junit.vintage.engine)

// AssertJ
testImplementation(libs.assertj.core)

// Glide
implementation(libs.glide)

Expand All @@ -118,6 +129,7 @@ dependencies {

// OkHttp
implementation(libs.okhttp.logging.interceptor)
testImplementation(libs.okhttp.mockwebserver)

// Lifecycle
implementation(libs.lifecycle.viewmodel)
Expand Down Expand Up @@ -188,3 +200,10 @@ secrets {
ignoreList.add("keyToIgnore")
ignoreList.add("sdk.*")
}

tasks.withType<Test> {
testLogging {
events("started", "passed", "skipped", "failed", "standardError", "standardOut")
exceptionFormat = TestExceptionFormat.FULL
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,22 @@ package com.on.staccato
import android.app.Application
import com.google.android.libraries.places.api.net.PlacesClient
import com.on.staccato.data.PlacesClientProvider
import com.on.staccato.data.StaccatoClient
import com.on.staccato.data.UserInfoPreferencesManager
import dagger.hilt.android.HiltAndroidApp
import retrofit2.Retrofit

@HiltAndroidApp
class StaccatoApplication : Application() {
override fun onCreate() {
super.onCreate()
// AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
retrofit = StaccatoClient.initialize()
userInfoPrefsManager = UserInfoPreferencesManager(applicationContext)
placesClient = PlacesClientProvider.getClient(this)
}

companion object {
lateinit var retrofit: Retrofit
lateinit var userInfoPrefsManager: UserInfoPreferencesManager
lateinit var placesClient: PlacesClient
}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.on.staccato.data

import com.on.staccato.data.dto.Status

sealed interface ApiResult<T : Any>

class Success<T : Any>(val data: T) : ApiResult<T>

class ServerError<T : Any>(val status: Status, val message: String) : ApiResult<T>

sealed class Exception<T : Any> : ApiResult<T> {
class NetworkError<T : Any> : Exception<T>()

class UnknownError<T : Any> : Exception<T>()
}

inline fun <T : Any, R : Any> ApiResult<T>.handle(convert: (T) -> R): ApiResult<R> =
when (this) {
is Exception.NetworkError -> Exception.NetworkError()
is Exception.UnknownError -> Exception.UnknownError()
is ServerError -> ServerError(status, message)
is Success -> Success(convert(data))
}

fun ApiResult<Unit>.handle(): ApiResult<Unit> =
when (this) {
is Exception.NetworkError -> Exception.NetworkError()
is Exception.UnknownError -> Exception.UnknownError()
is ServerError -> ServerError(status, message)
is Success -> Success(data)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package com.on.staccato.data

import com.on.staccato.StaccatoApplication.Companion.retrofit
import com.on.staccato.data.StaccatoClient.getErrorResponse
import com.on.staccato.data.dto.ErrorResponse
import com.on.staccato.data.dto.Status
import okhttp3.Request
import okhttp3.ResponseBody
import okio.Timeout
import retrofit2.Call
import retrofit2.HttpException
import retrofit2.Response
import java.io.IOException

class ApiResultCall<T : Any>(
private val delegate: Call<T>,
) : Call<ApiResult<T>> {
override fun enqueue(callback: retrofit2.Callback<ApiResult<T>>) {
delegate.enqueue(
object : retrofit2.Callback<T> {
override fun onResponse(
call: Call<T>,
response: Response<T>,
) {
val networkResult: ApiResult<T> = handleApiResponse { response }
callback.onResponse(this@ApiResultCall, Response.success(networkResult))
}

override fun onFailure(
call: Call<T>,
throwable: Throwable,
) {
val exception = handleException<T>(throwable)
callback.onResponse(this@ApiResultCall, Response.success(exception))
}
},
)
}

override fun execute(): Response<ApiResult<T>> = throw NotImplementedError()

override fun clone(): Call<ApiResult<T>> = ApiResultCall(delegate.clone())

override fun isExecuted(): Boolean = delegate.isExecuted

override fun cancel() {
delegate.cancel()
}

override fun isCanceled(): Boolean = delegate.isCanceled

override fun request(): Request = delegate.request()

override fun timeout(): Timeout = delegate.timeout()
}

private const val CREATED = 201
private const val NOT_FOUND_ERROR_BODY = "errorBody를 찾을 수 없습니다."

private fun <T : Any> handleApiResponse(execute: () -> Response<T>): ApiResult<T> {
return try {
val response: Response<T> = execute()
val body: T? = response.body()

when {
response.isSuccessful && response.code() == CREATED -> Success(body as T)
response.isSuccessful && body != null -> Success(body)
else -> {
val errorBody: ResponseBody =
response.errorBody()
?: throw IllegalArgumentException(NOT_FOUND_ERROR_BODY)
val errorResponse: ErrorResponse = retrofit.getErrorResponse(errorBody)
ServerError(
status = Status.Message(errorResponse.status),
message = errorResponse.message,
)
}
}
} catch (httpException: HttpException) {
ServerError(status = Status.Code(httpException.code()), message = httpException.message())
} catch (throwable: Throwable) {
handleException<T>(throwable)
}
}

private fun <T : Any> handleException(throwable: Throwable) =
when (throwable) {
is IOException -> Exception.NetworkError<T>()
else -> Exception.UnknownError<T>()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.on.staccato.data

import retrofit2.Call
import retrofit2.CallAdapter
import java.lang.reflect.Type

class ApiResultCallAdapter(
private val resultType: Type,
) : CallAdapter<Type, Call<ApiResult<Type>>> {
override fun responseType(): Type = resultType

override fun adapt(call: Call<Type>): Call<ApiResult<Type>> = ApiResultCall(call)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.on.staccato.data

import retrofit2.Call
import retrofit2.CallAdapter
import retrofit2.Retrofit
import java.lang.reflect.ParameterizedType
import java.lang.reflect.Type

class ApiResultCallAdapterFactory : CallAdapter.Factory() {
override fun get(
returnType: Type,
annotations: Array<out Annotation>,
retrofit: Retrofit,
): CallAdapter<*, *>? {
if (getRawType(returnType) != Call::class.java) {
return null
}

val responseType = getParameterUpperBound(0, returnType as ParameterizedType)

if (getRawType(responseType) != ApiResult::class.java) {
return null
}

val resultType = getParameterUpperBound(0, responseType as ParameterizedType)
return ApiResultCallAdapter(resultType)
}

companion object {
fun create(): ApiResultCallAdapterFactory = ApiResultCallAdapterFactory()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.on.staccato.data

import com.on.staccato.presentation.util.ExceptionState

inline fun <T : Any> ApiResult<T>.onSuccess(action: (T) -> Unit): ApiResult<T> =
apply {
if (this is Success<T>) action(data)
}

inline fun <T : Any> ApiResult<T>.onServerError(action: (message: String) -> Unit): ApiResult<T> =
apply {
if (this is ServerError<T>) action(message)
}

inline fun <T : Any> ApiResult<T>.onException(action: (exceptionState: ExceptionState) -> Unit): ApiResult<T> =
apply {
if (this is Exception<T>) {
when (this) {
is Exception.NetworkError -> action(ExceptionState.NetworkError)
is Exception.UnknownError -> action(ExceptionState.UnknownError)
}
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -27,23 +27,19 @@ object StaccatoClient {

private val jsonBuilder = Json { coerceInputValues = true }

private val provideRetrofit =
fun initialize(): Retrofit =
Retrofit.Builder()
.baseUrl(BASE_URL)
.client(provideHttpClient)
.addConverterFactory(
jsonBuilder.asConverterFactory("application/json".toMediaType()),
)
.addCallAdapterFactory(ApiResultCallAdapterFactory.create())
.build()

fun getErrorResponse(errorBody: ResponseBody): ErrorResponse {
return provideRetrofit.responseBodyConverter<ErrorResponse>(
fun Retrofit.getErrorResponse(errorBody: ResponseBody): ErrorResponse =
responseBodyConverter<ErrorResponse>(
ErrorResponse::class.java,
ErrorResponse::class.java.annotations,
).convert(errorBody) ?: throw IllegalArgumentException("errorBody를 변환할 수 없습니다.")
}

fun <T> create(service: Class<T>): T {
return provideRetrofit.create(service)
}
}
Loading

0 comments on commit 76848c4

Please sign in to comment.