현대 소프트웨어 개발에서 효과적인 테스트는 애플리케이션의 신뢰성과 안정성을 보장하는 데 핵심적인 역할을 합니다.
이 기사에서는 통합 테스트 작성을 위한 실용적인 권장 사항을 제공하고, 외부 서비스와의 상호 작용 사양에 중점을 두고 테스트를 더 읽기 쉽고 유지 관리하기 쉽게 만드는 방법을 보여줍니다. 이 접근 방식은 테스트 효율성을 향상시킬 뿐만 아니라 애플리케이션 내 통합 프로세스에 대한 더 나은 이해를 촉진합니다. 구체적인 예를 통해 DSL 래퍼, JsonAssert 및 Pact와 같은 다양한 전략과 도구를 탐색하여 독자에게 통합 테스트의 품질과 가시성을 향상시키기 위한 포괄적인 가이드를 제공합니다.
이 기사에서는 Spring 애플리케이션에서 HTTP 상호 작용을 테스트하기 위해 Groovy의 Spock Framework를 사용하여 수행되는 통합 테스트의 예를 제시합니다. 동시에 제안된 주요 기술과 접근 방식은 HTTP를 넘어서는 다양한 유형의 상호 작용에 효과적으로 적용될 수 있습니다.
Spring에서 효과적인 통합 테스트 작성: HTTP 요청 모의를 위한 조직화된 테스트 전략 기사에서는 각각 특정 역할을 수행하는 별개의 단계로 명확하게 구분하여 테스트를 작성하는 접근 방식을 설명합니다. 이러한 권장 사항에 따라 하나가 아닌 두 개의 요청을 조롱하는 테스트 예제를 설명하겠습니다. 간결함을 위해 Act 단계(실행)는 생략됩니다(전체 테스트 예는 프로젝트 저장소 에서 찾을 수 있습니다).
제시된 코드는 조건부로 "지원 코드"(회색)와 "외부 상호 작용 사양"(파란색) 부분으로 나뉩니다. 지원 코드에는 요청 가로채기 및 응답 에뮬레이션을 포함한 테스트용 메커니즘과 유틸리티가 포함되어 있습니다. 외부 상호 작용 사양은 예상되는 요청 및 응답을 포함하여 테스트 중에 시스템이 상호 작용해야 하는 외부 서비스에 대한 특정 데이터를 설명합니다. 지원 코드는 테스트를 위한 기반을 마련하는 반면, 사양은 테스트하려는 시스템의 비즈니스 로직 및 주요 기능과 직접적으로 관련됩니다.
사양은 코드의 작은 부분을 차지하지만 테스트를 이해하는 데 중요한 가치를 나타내는 반면, 더 큰 부분을 차지하는 지원 코드는 가치가 적고 각 모의 선언에 대해 반복됩니다. 이 코드는 MockRestServiceServer와 함께 사용하기 위한 것입니다. WireMock의 예를 참조하면 동일한 패턴을 볼 수 있습니다. 사양은 거의 동일하고 지원 코드는 다릅니다.
이 기사의 목적은 사양에 중점을 두고 지원 코드를 뒷자리에 두는 방식으로 테스트 작성에 대한 실질적인 권장 사항을 제공하는 것입니다.
테스트 시나리오에서는 OpenAI API에 요청을 전달하고 사용자에게 응답을 다시 보내는 가상의 Telegram 봇을 제안합니다.
서비스와 상호 작용하기 위한 계약은 작업의 주요 논리를 강조하기 위해 단순화된 방식으로 설명됩니다. 다음은 애플리케이션 아키텍처를 보여주는 시퀀스 다이어그램입니다. 디자인이 시스템 아키텍처 관점에서 의문을 제기할 수 있다는 점을 이해하지만, 이해심을 갖고 접근하시기 바랍니다. 여기서 주요 목표는 테스트에서 가시성을 높이는 접근 방식을 보여주는 것입니다.
이 문서에서는 테스트 작성에 대한 다음과 같은 실제 권장 사항에 대해 설명합니다.
DSL 래퍼를 사용하면 상용구 모의 코드를 숨길 수 있으며 사양 작업을 위한 간단한 인터페이스를 제공합니다. 제안된 내용은 특정 DSL이 아니라 DSL이 구현하는 일반적인 접근 방식이라는 점을 강조하는 것이 중요합니다. DSL을 사용한 수정된 테스트 예가 아래에 나와 있습니다( 전체 테스트 텍스트 ).
setup: def openaiRequestCaptor = restExpectation.openai.completions(withSuccess("{...}")) def telegramRequestCaptor = restExpectation.telegram.sendMessage(withSuccess("{}")) when: ... then: openaiRequestCaptor.times == 1 telegramRequestCaptor.times == 1
예를 들어, restExpectation.openai.completions
메소드는 다음과 같이 설명됩니다.
public interface OpenaiMock { /** * This method configures the mock request to the following URL: {@code https://api.openai.com/v1/chat/completions} */ RequestCaptor completions(DefaultResponseCreator responseCreator); }
메서드에 대한 설명이 있으면 코드 편집기에서 메서드 이름 위로 마우스를 가져갈 때 조롱할 URL을 보는 등의 도움을 받을 수 있습니다.
제안된 구현에서 모의 응답 선언은 ResponseCreator
인스턴스를 사용하여 이루어지며 다음과 같은 사용자 정의 인스턴스를 허용합니다.
public static ResponseCreator withResourceAccessException() { return (request) -> { throw new ResourceAccessException("Error"); }; }
일련의 응답을 지정하는 실패한 시나리오에 대한 예제 테스트는 다음과 같습니다.
import static org.springframework.http.HttpStatus.FORBIDDEN setup: def openaiRequestCaptor = restExpectation.openai.completions(openaiResponse) def telegramRequestCaptor = restExpectation.telegram.sendMessage(withSuccess("{}")) when: ... then: openaiRequestCaptor.times == 1 telegramRequestCaptor.times == 0 where: openaiResponse | _ withResourceAccessException() | _ withStatus(FORBIDDEN) | _
WireMock의 경우 응답 형식이 약간 다르다는 점( 테스트 코드 , 응답 팩토리 클래스 코드 )을 제외하면 모든 것이 동일합니다.
DSL을 구현할 때 @Language("JSON")
로 메서드 매개변수에 주석을 달아 IntelliJ IDEA의 특정 코드 조각에 대한 언어 기능 지원을 활성화할 수 있습니다. 예를 들어 JSON을 사용하면 편집기는 문자열 매개변수를 JSON 코드로 처리하여 구문 강조, 자동 완성, 오류 확인, 탐색 및 구조 검색과 같은 기능을 활성화합니다. 다음은 주석 사용법의 예입니다.
public static DefaultResponseCreator withSuccess(@Language("JSON") String body) { return MockRestResponseCreators.withSuccess(body, APPLICATION_JSON); }
편집기에서는 다음과 같이 표시됩니다.
JSONAssert 라이브러리는 JSON 구조 테스트를 단순화하도록 설계되었습니다. 이를 통해 개발자는 다양한 비교 모드를 지원하여 높은 수준의 유연성으로 예상 JSON 문자열과 실제 JSON 문자열을 쉽게 비교할 수 있습니다.
이를 통해 다음과 같은 확인 설명에서 이동할 수 있습니다.
openaiRequestCaptor.body.model == "gpt-3.5-turbo" openaiRequestCaptor.body.messages.size() == 1 openaiRequestCaptor.body.messages[0].role == "user" openaiRequestCaptor.body.messages[0].content == "Hello!"
이런 것에
assertEquals("""{ "model": "gpt-3.5-turbo", "messages": [{ "role": "user", "content": "Hello!" }] }""", openaiRequestCaptor.bodyString, false)
제 생각에는 두 번째 접근 방식의 가장 큰 장점은 문서, 로그, 테스트 등 다양한 컨텍스트에서 데이터 표현의 일관성을 보장한다는 것입니다. 이는 테스트 프로세스를 크게 단순화하여 오류 진단의 정확성과 비교 유연성을 제공합니다. 따라서 테스트 작성 및 유지 관리에 소요되는 시간을 절약할 뿐만 아니라 가독성과 정보성도 향상됩니다.
Spring Boot 내에서 작업할 때 버전 2 이상부터 org.springframework.boot:spring-boot-starter-test
이미 org.skyscreamer:jsonassert
에 대한 종속성이 포함되어 있으므로 라이브러리 작업에 추가 종속성이 필요하지 않습니다.
우리가 관찰할 수 있는 한 가지는 JSON 문자열이 테스트의 상당 부분을 차지한다는 것입니다. 숨겨야 할까요? 예, 아니오. 무엇이 더 많은 이점을 가져오는지 이해하는 것이 중요합니다. 이를 숨기면 테스트가 더 간결해지고 첫 눈에 테스트의 본질을 쉽게 파악할 수 있습니다. 반면, 철저한 분석을 위해 외부 상호 작용 사양에 대한 중요한 정보의 일부가 숨겨지므로 파일 간에 추가 점프가 필요합니다. 결정은 편의성에 달려 있습니다. 자신에게 더 편안한 것을 선택하세요.
JSON 문자열을 파일에 저장하기로 선택한 경우 간단한 옵션 중 하나는 응답과 요청을 JSON 파일에 별도로 보관하는 것입니다. 다음은 구현 옵션을 보여주는 테스트 코드( 정식 버전 )입니다.
setup: def openaiRequestCaptor = restExpectation.openai.completions(withSuccess(fromFile("json/openai/response.json"))) def telegramRequestCaptor = restExpectation.telegram.sendMessage(withSuccess("{}")) when: ... then: openaiRequestCaptor.times == 1 telegramRequestCaptor.times == 1
fromFile
메소드는 단순히 src/test/resources
디렉토리의 파일에서 문자열을 읽고 혁신적인 아이디어를 전달하지는 않지만 참조용으로 프로젝트 저장소에서 계속 사용할 수 있습니다.
문자열의 변수 부분의 경우 org.apache.commons.text.StringSubstitutor 로 대체를 사용하고 모의를 설명할 때 값 세트를 전달하는 것이 좋습니다. 예를 들면 다음과 같습니다.
setup: def openaiRequestCaptor = restExpectation.openai.completions(withSuccess(fromFile("json/openai/response.json", [content: "Hello! How can I assist you today?"])))
JSON 파일에서 대체 부분은 다음과 같습니다.
... "message": { "role": "assistant", "content": "${content:-Hello there, how may I assist you today?}" }, ...
파일 저장소 접근 방식을 채택할 때 개발자가 직면하는 유일한 과제는 테스트 리소스에서 적절한 파일 배치 체계와 명명 체계를 개발하는 것입니다. 이러한 파일 작업 환경을 악화시킬 수 있는 실수를 저지르기 쉽습니다. 이 문제에 대한 한 가지 해결책은 나중에 논의할 Pact의 사양과 같은 사양을 사용하는 것입니다.
Groovy로 작성된 테스트에서 설명된 접근 방식을 사용할 때 불편을 겪을 수 있습니다. IntelliJ IDEA에는 코드에서 파일을 탐색하는 기능이 지원되지 않지만 이 기능에 대한 지원은 향후 추가될 예정입니다 . Java로 작성된 테스트에서는 이것이 훌륭하게 작동합니다.
용어부터 시작해 보겠습니다.
계약 테스트는 각 애플리케이션을 개별적으로 테스트하여 전송하거나 수신하는 메시지가 "계약"에 문서화된 상호 이해를 준수하는지 확인하는 통합 지점을 테스트하는 방법입니다. 이 접근 방식은 시스템의 여러 부분 간의 상호 작용이 기대치를 충족하도록 보장합니다.
계약 테스트의 맥락에서 계약은 애플리케이션 간에 교환되는 메시지(요청 및 응답)의 형식과 구조에 대한 합의를 기록하는 문서 또는 사양입니다. 이는 각 애플리케이션이 통합에서 다른 애플리케이션이 보내고 받는 데이터를 올바르게 처리할 수 있는지 확인하는 기반 역할을 합니다.
계약은 소비자(예: 일부 데이터를 검색하려는 클라이언트)와 공급자(예: 클라이언트에 필요한 데이터를 제공하는 서버의 API) 사이에 설정됩니다.
소비자 중심 테스트는 소비자가 자동화된 테스트 실행 중에 계약을 생성하는 계약 테스트에 대한 접근 방식입니다. 이러한 계약은 공급자에게 전달되고 공급자는 일련의 자동화된 테스트를 실행합니다. 계약 파일에 포함된 각 요청은 공급자에게 전송되고 수신된 응답은 계약 파일에 지정된 예상 응답과 비교됩니다. 두 응답이 모두 일치하면 소비자와 서비스 제공자가 호환된다는 의미입니다.
마지막으로 팩트입니다. Pact는 소비자 중심 계약 테스트 아이디어를 구현하는 도구입니다. 코드 우선 테스트 개발에 중점을 두고 HTTP 통합과 메시지 기반 통합 테스트를 모두 지원합니다.
앞서 언급했듯이 우리 작업에는 Pact의 계약 사양과 도구를 사용할 수 있습니다. 구현은 다음과 같습니다( 전체 테스트 코드 ).
setup: def openaiRequestCaptor = restExpectation.openai.completions(fromContract("openai/SuccessfulCompletion-Hello.json")) def telegramRequestCaptor = restExpectation.telegram.sendMessage(withSuccess("{}")) when: ... then: openaiRequestCaptor.times == 1 telegramRequestCaptor.times == 1
계약 파일을 검토할 수 있습니다.
계약 파일을 사용하면 요청 및 응답 본문뿐만 아니라 외부 상호 작용 사양의 다른 요소(요청 경로, 헤더, HTTP 응답 상태)도 포함되어 이러한 계약을 기반으로 모의 파일을 완전히 설명할 수 있다는 장점이 있습니다.
이 경우 우리는 계약 테스트로 제한하고 소비자 중심 테스트로 확장하지 않는다는 점에 유의하는 것이 중요합니다. 그러나 누군가는 Pact를 더 자세히 살펴보고 싶어할 수도 있습니다.
이 기사에서는 Spring Framework를 사용한 개발 맥락에서 통합 테스트의 가시성과 효율성을 향상시키기 위한 실용적인 권장 사항을 검토했습니다. 내 목표는 외부 상호 작용의 사양을 명확하게 정의하고 상용구 코드를 최소화하는 것의 중요성에 초점을 맞추는 것이었습니다. 이 목표를 달성하기 위해 저는 DSL 래퍼와 JsonAssert를 사용하고, 사양을 JSON 파일에 저장하고, Pact를 통해 계약 작업을 수행할 것을 제안했습니다. 이 기사에 설명된 접근 방식은 테스트 작성 및 유지 관리 프로세스를 단순화하고, 가독성을 향상시키며, 가장 중요하게는 시스템 구성 요소 간의 상호 작용을 정확하게 반영하여 테스트 자체의 품질을 향상시키는 것을 목표로 합니다.
테스트를 보여주는 프로젝트 저장소( sandbox/bot ) 에 대한 링크입니다.
기사에 관심을 가져주셔서 감사합니다. 효과적이고 눈에 띄는 테스트를 작성하는 데 행운이 있기를 바랍니다!