본문 바로가기
Test

외부 API를 사용하는 테스트 코드

by oncerun 2023. 7. 19.
반응형

 

개발을 하다 보면 외부 서버의 API를 이용하는 경우가 매우 많다. 혹은 외부 라이브러리를 테스트해야 하는 일도 생긴다.

 

현재 나는 회원 인증을 위한 코드를 작성하는데, 해당 테스트 코드를 어떻게 작성해야 하는지 고민 중이다.

 

첫 번째 고민은 다음과 같다. 테스트 코드를 실행함에 있어 실제 외부 API 호출을 진행할 수 없다. 

비용적인 문제와 테스트 코드의 검증 시간적인 문제가 발생한다. 

 

 private val vonageClient: VonageClient

fun verify(verifyRequest: VerifyReq): VonageVerifyResult {
        runCatching {
            vonageClient.verifyClient.verify(createVerifyRequest(verifyRequest))
        }.onFailure {
            when (it) {
                is VonageClientException -> throw VonageException("vonage server network disconnected", it)
                is VonageResponseParseException -> throw VonageException("response parsing error", it)
                else -> throw VonageException("unknown error", it)
            }
        }.getOrThrow().run {
            return VonageAdapter.adaptVerifyResponse(this)
        }
}

 

위 코드를 테스트하기 위해 코드르 작성해 보자. 어떤 문제가 있고 이를 어떻게 해결하려 했는지 정리해 본다. 

 

 

만약 내가 해당 코드를 테스트한다면 다음과 같이 작성할 수 있다.

 @DisplayName("국가 코드와 사용자 번호를 통해 휴대폰 인증 코드 발송에 성공한다.")
    @Test
    fun verifyPhone() {
		...
        verifyService.verifyPhone(VerifyReq("82", "1012345678"))
		....
    }

 

 

첫 번째 문제

 

"vonageClinet.verifyClinet.verify" 코드는 REST call을 실행한다. 

 

테스트 마다 실제 서비스에 요청을 하면 비용이 발생하며 전체 테스트에 악영향을 줄 수 있다. 

 

그렇기 때문에 verify 메서드의 응답을 대체할 수 있는 "무언가"가 필요하다. 

 

이에 대한 해답은 대역이다. TDD 관련 책(최범균 저자)에서 얻은 정보이다. 

 

다만 대역을 영어로 된 테스트 관련 글에서는 이를 test double이라고 표현한다. 

 

대역이라는 말 그대로 진짜 객체 대신 가짜 객체를 사용하여 테스트를 하겠다는 것이다. 

대역은 그 종류 또한 다양한데 stub, fake, spy, mock가 존재한다.

이 대역들은 하나만 사용하는 것이 아니라 특정 상황에 따라 혼합되어 사용된다. 

 

책에서는 Mockito 라이브러리를 활용한 모의 객체를 활용한다.

 

모의 객체를 통해 의존하는 대상을 모킹 하여 행동하도록 정의할 수 있다고 한다. 

 

우선 코드를 조금 리팩터링 했다. 

 

서비스 로직이 Exception 처리나, Adaptor를 통한 반환을 하는 코드가 그대로 드러나있으니, 추후 인증 요청 이후 어떠한 작업을 하는 경우 가독성이 떨어진다고 보았다.

 

그래서 다음과 같은 구조로 변경한다.

 

VerifyService <<interface>> 

: interface로 만든 이유는 해당 라이브러리가 v1, v2로 나누어 있다. 마이그레이션을 위해 인터페이스를 사용해 놓고

추후 v2로 변경될 때 이에 맞는 serivce 클래스를 사용하기 위함이다.

 

VonageVerifySerivce < class >

v1을 사용한 버전.

@Service
class VonageVerifyService(
    private val vonageClientWrapper: VonageClientWrapper,
) : VerifyService {
	
    ...
    
    override fun verifyPhone(verifyReq: VerifyReq): VerifyRes {
        runCatching {
            vonageClientWrapper.verify(verifyReq)
        }.onFailure {
            when (it) {
                is VonageClientException -> throw VonageException("vonage server network disconnected", it)
                is VonageResponseParseException -> throw VonageException("response parsing error", it)
                else -> throw VonageException("unknown error", it)
            }
        }.getOrThrow().run {
            val result = VonageAdapter.adaptVerifyResponse(this)
            return VerifyRes(
                    requestId = result.requestId,
                    success = result.success,
                    errorText = result.errorText
            )
        }
    }
    ...
}

 

VonageClientWrapper < class >

@Component
class VonageClientWrapper {

    ....

   fun verify(verifyRequest: VerifyReq): VerifyResponse{
            return vonageClient.verifyClient.verify(createVerifyRequest(verifyRequest))
    }
    
    ...

}

 

사실 Wrapper에서 Exception을 처리하고 Adaptor로 응답까지 반환하는 것으로 변경했었다. 

이 상황에서 service를 테스트하는 게 아닌 wrapper를 테스트하는 것이 아닌가라는 생각이 들어 반환된 응답에 대한 검증과 반환에 대한 코드가 Wrapper에 있으면 안 된다고 느껴 다시 위 코드로 변경했다. 

 

만약 더 깔끔한 코드를 한다면 다음과 같이 구조를 잡을 수 있을 것이다.

 

VonageVerifySerivce -->>??. class의 verify 호출 -->> vonageClientWrapper 호출 

 

응답을 반환하는 Adaptor를 더 확장해서 사용하면 어떨까? 

 

요청을 가지고 와서 중간 Exception이 발생하면 Exception을 던지고, 공통된 응답을 주는 것.

이게 Adaptor가 해야 하는 일이 아닐까?

 

이 생각을 가지고 다음과 같이 리팩토링 했다.

 

Service

@Service
class VonageVerifyService(
        private val vonageVerifyAdapter: VonageVerifyAdapter,
) : VerifyService {
    override fun verifyPhone(verifyReq: VerifyReq): VerifyRes = vonageVerifyAdapter.verifyPhone(verifyReq)
    override fun verifyCode(requestId: String, code: String): VerifyRes = vonageVerifyAdapter.check(requestId, code)
}

 

VonageVerifyAdaptor

@Component
class VonageVerifyAdapter(
        private val vonageClientWrapper: VonageClientWrapper
) {
    fun verifyPhone(verifyReq: VerifyReq): VerifyRes {
        runCatching {
            vonageClientWrapper.verify(verifyReq)
        }.onFailure {
            when (it) {
                is VonageClientException -> throw VonageException("vonage server network disconnected", it)
                is VonageResponseParseException -> throw VonageException("response parsing error", it)
                else -> throw VonageException("unknown error", it)
            }
        }.getOrThrow().run {
            val result = adaptVerifyResponse(this)
            return VerifyRes(
                    requestId = result.requestId,
                    success = result.success,
                    errorText = result.errorText
            )
        }
    }

    fun check(requestId: String, code: String): VerifyRes {
        runCatching {
            vonageClientWrapper.check(requestId, code)
        }.onFailure {
            when (it) {
                is VonageClientException -> throw VonageException("vonage server network disconnected", it)
                is VonageResponseParseException -> throw VonageException("response parsing error", it)
                else -> throw VonageException("unknown error", it)
            }
        }.getOrThrow().run {
            val result = adaptCheckResponse(this)
            return VerifyRes(
                    requestId = result.requestId,
                    success = result.success,
                    errorText = result.errorText
            )
        }
    }

    private fun adaptVerifyResponse(response: VerifyResponse): VonageVerifyResult {
        val vonageExceptionDto = VonageExceptionDto(
                requestId = response.requestId,
                status = response.status,
                errorText = response.errorText,
                network = response.network
        )

        when (response.status) {
            VerifyStatus.NUMBER_BARRED -> throw BlackListNumberException("blacklist number", vonageExceptionDto)
            VerifyStatus.UNSUPPORTED_NETWORK -> throw RestrictedCountryException("Request restricted country", vonageExceptionDto)
            else -> checkCriticalStatusCode(vonageExceptionDto)
        }

        return VonageVerifyResult(
                requestId = response.requestId.ifBlank { response.network },
                success = response.status == VerifyStatus.OK,
                errorText = response.errorText ?: ""
        )
    }

    private fun adaptCheckResponse(response: CheckResponse): VonageVerifyResult {
        val vonageExceptionDto = VonageExceptionDto(
                response.requestId,
                response.status,
                response.errorText
        )

        when (response.status) {
            VerifyStatus.INVALID_CODE -> throw VonageVerificationCodeMismatchException("verify code mismatch", vonageExceptionDto)
            VerifyStatus.WRONG_CODE_THROTTLED -> throw RepeatedInvalidCodeException("The wrong code was provided too many times", vonageExceptionDto)
            else -> checkCriticalStatusCode(vonageExceptionDto)
        }

        return VonageVerifyResult(
                requestId = response.requestId ?: "",
                success = response.status == VerifyStatus.OK,
                errorText = response.errorText ?: ""
        )
    }

    private fun checkCriticalStatusCode(vonageExceptionDto: VonageExceptionDto) {
        val isCritical = vonageExceptionDto.status in setOf(
                VerifyStatus.INVALID_CREDENTIALS,
                VerifyStatus.INTERNAL_ERROR,
                VerifyStatus.PARTNER_QUOTA_EXCEEDED
        )

        if (isCritical) {
            throw CriticalStatusCodeException("critical status code: ${vonageExceptionDto.status}", vonageExceptionDto)
        }
    }
}

 

VonageClientWrapper

 

@Component
class VonageClientWrapper {

    @Value("\${vonage_auth.brand_name}")
    private lateinit var brandName: String

    @Value("\${vonage_auth.expiry}")
    private lateinit var expiry: String

    @Value("\${vonage_auth.api_key}")
    private lateinit var apiKey: String

    @Value("\${vonage_auth.api_secret}")
    private lateinit var apiSecret: String

    private val vonageClient: VonageClient by lazy {
        createVonageClient()
    }
    fun verify(verifyRequest: VerifyReq): VerifyResponse = vonageClient.verifyClient.verify(createVerifyRequest(verifyRequest))
    fun check(requestId: String, code: String): CheckResponse = vonageClient.verifyClient.check(requestId, code)

    private fun createVonageClient(): VonageClient {
        return VonageClient.builder()
                .apiKey(apiKey)
                .apiSecret(apiSecret)
                .build()
    }
    private fun createVerifyRequest(verifyReq: VerifyReq): VerifyRequest {
        return VerifyRequest.builder(verifyReq.phoneNumber(), brandName)
                .pinExpiry(expiry.toExpirySeconds())
                .workflow(VerifyRequest.Workflow.SMS)
                .build()
    }
    private fun String.toExpirySeconds(default: Int = 180): Int {
        return runCatching {
            this.toInt()
        }.getOrDefault(default)
    }
}

 

 

해당 리팩토링이 외부 API를 테스트하는 구조와 어느 정도 확장성을 챙긴 구조라고 생각했으니 실제로 테스트 코드를 작성해 보자.(실 테스트는 끝 맞췄다.)

 

 

1. 정상요청인 경우 인증 코드를 발송해야 한다. 

@SpringBootTest
class VonageVerifyServiceTest {

    @Autowired
    lateinit var verifyService: VonageVerifyService

    @MockBean
    lateinit var vonageClientWrapper: VonageClientWrapper

    @DisplayName("국가 코드와 사용자 번호를 통해 휴대폰 인증 코드 발송에 성공한다.")
    @Test
    fun verifyPhone(@Value("\${vonage_auth.brand_name}") brandName: String,
                    @Value("\${vonage_auth.expiry}") expiry: String = "180") {
        val verifyReq = VerifyReq("82", "01012345678")
        given(vonageClientWrapper.verify(verifyReq))
                .willReturn(VerifyResponse(VerifyStatus.OK))

        val verifyRes = verifyService.verifyPhone(verifyReq)

        assertThat(verifyRes.success).isTrue
    }

}

 

 

외부 API의 객체를 테스트할 수 없었기에 이를 래핑 하여 모의 객체로 사용하니 테스트하기 힘든 상황에 대해 코드를 테스트할 수 있게 되었다.!!

 

 


 

지금까지 외부 API를 사용하는 경우 테스트 코드 작성에서 직면할 수 있는 문제를 알아보았다.

 

문제는 외부 요인으로 인해 테스트 코드를 작성하는 데 있어 테스트 코드의 작성이 힘든 불가피한 상황이 있을 수 있다는 것이다. 

 

외부 의존 요인을 테스트 코드에서 제거하기 위해 우리는 대역 ( test double )을 사용했다. 

 

여러 대역 중 모의 객체를 사용했으며 라이브러리는 Mockito를 활용했다. 

 

이를 테스트하기 위해 외부 라이브러리를 한 번 감싸 사용하는 식으로 코드 구조를 변경했다.

 

반응형

'Test' 카테고리의 다른 글

IntelliJ Test 목록  (0) 2023.07.04
테스트 범위와 종류  (0) 2022.12.15
테스트 가능한 설계  (2) 2022.12.15
Spy  (0) 2022.12.14
Junit 5  (0) 2022.12.12

댓글