🔗 [Project] 우리 집 레시피 - 테스트 코드(test code)
이전 글에 이어서, 나는 Service (단위 테스트)와 Controller(통합 테스트)에 대해 테스트 코드를 작성하고,
이후 RestDocs + Swagger를 활용하여 API 테스트를 문서화하는 코드를 추가했다.
이 과정에서 나의 테스트 코드도 리팩토링을 진행했다.
RestDocs + Swagger란?
Spring RestDocs:
- 테스트 코드를 기반으로 API 문서를 자동으로 생성한다.
- 실제 테스트를 통과한 API만 문서화하므로, 높은 신뢰성을 보장한다.
Swagger:
- API 구조를 시각적으로 표현하여 더 쉽게 이해할 수 있도록 돕는다.
- Interactive 한 UI를 제공하여, API를 직접 테스트해 볼 수 있다.
- OpenAPI 명세를 기반으로 하여, 다양한 도구와의 연동이 가능하다.
RestDocs와 Swagger의 결합
RestDocs와 Swagger를 함께 사용하면 다음과 같은 장점이 있다:
- RestDocs의 정확성과 Swagger의 사용자 친화적인 인터페이스를 동시에 활용할 수 있다.
- 테스트 기반의 신뢰할 수 있는 API 문서와 함께, 시각적으로 뛰어난 API 문서를 제공할 수 있다.
- 개발자뿐만 아니라 프론트엔드 사용자도 인터랙티브한 API 문서를 통해
직접 테스트하고 이해할 수 있는 포괄적인 문서화 설루션을 구축할 수 있다.
💡 RestDocs와 Swagger를 함께 사용하면, 정확하고 보기 쉬운 API 문서를 만들 수 있다.
이를 통해 개발자들이 API를 쉽게 이해하고 테스트할 수 있어 협업이 더 원활해진다.
본격적으로 변경된 코드를 보면서, 나의 코드에서 어떤 점들이 리팩토링 되었는지 AraBoZa!
@ExtendWith(RestDocumentationExtension.class)
// RestDocs가 RestDocumentationContextProvider를 테스트 메소드에 주입할 수 있도록 활성화
@SpringBootTest(webEnvironment = RANDOM_PORT)
@TestMethodOrder(value = MethodOrderer.DisplayName.class)
class MemberControllerTest extends TestContainerConfig {
@LocalServerPort
private int port; // 테스트 시 사용할 랜덤 포트 설정
private RequestSpecification spec;
@BeforeEach
void setUp(RestDocumentationContextProvider restDocumentation) { // RequestSpecification을 설정하는 ReauestSpecBuilder 생성
// documentationSpec에 포트, baseUri, basePath 및 문서화 필터를 모두 설정
this.spec = new RequestSpecBuilder()
.setPort(port) // 포트 설정 -> 랜덤포트를 생성
.setBaseUri("http://localhost") // 기본 URI 설정
.setBasePath("/v1") // 기본 경로 설정
.addFilter(documentationConfiguration(restDocumentation)) // 문서화 필터 설정
.build();
// RestAssured의 포트와 경로를 추가로 설정할 필요 없음 (documentationSpec에 이미 포함됨)
// 기본 RestAssured 설정도 포트와 경로를 명시적으로 설정 (필요시)
RestAssured.port = port;
}
@Test
@DisplayName("1-1. 회원가입 테스트 - 성공")
void registerMember_Success() {
// Given: 회원가입 요청에 필요한 데이터 준비
MemberRegisterRequestDto requestDto = MemberRegisterRequestDto.builder()
.name("테스트")
.email("test@example.com")
.password("Pass123!@")
.passwordConfirm("Pass123!@")
.phoneNumber("010-1234-5678")
.nickname("testUser")
.build();
// 회원가입 API 문서화
given(spec)
.filter(document("회원 가입 API - 성공",
resourceDetails()
.tag("회원 API")
.summary("회원 가입"),
// 요청 필드 문서화
requestFields(
fieldWithPath("email").type(STRING).description("이메일(아이디)"),
fieldWithPath("password").type(STRING).description("패스워드"),
fieldWithPath("passwordConfirm").type(STRING).description("패스워드 확인"),
fieldWithPath("phoneNumber").type(STRING).description("핸드폰 번호"),
fieldWithPath("nickname").type(STRING).description("닉네임"),
fieldWithPath("name").type(STRING).description("이름")
),
// 응답 필드 문서화
responseFields(
fieldWithPath("code").type(NUMBER).description("상태 코드"),
fieldWithPath("message").type(STRING).description("상태 메시지"),
fieldWithPath("data.id").type(NUMBER).description("아이디 고유번호")
)))
.contentType(JSON) // 요청 본문의 Content-Type 설정 (JSON 형식)
.body(requestDto) // 요청 본문 데이터 설정
.when()
.post("/member/register")
.then().log().all() // 요청 및 응답 로그 출력
.statusCode(201);
}
@Test
@DisplayName("1-2. 회원가입 테스트 - 실패: 유효하지 않은 값")
// 유효하지 않는 입력값으로 인한 회원가입 실패
void registerMember_InvalidInput() {
// Given: 유효하지 않은 회원가입 요청 데이터 준비 (필수 값 누락, 유효하지 않은 값)
MemberRegisterRequestDto requestDto = MemberRegisterRequestDto.builder()
.name("테스트124123124")
.password("Pass123")
.passwordConfirm("Pass123")
.phoneNumber("012-1234-5678")
.nickname("testUsertestUsertestUser")
.build();
// When: 잘못된 회원가입 API 호출
// Then: 유효성 검사 실패로 400(BAD REQUEST) 응답 확인
given(spec)
// 문서화 필터 추가
.filter(document("회원 가입 API - 실패: 필드 유효성",
resourceDetails()
.tag("회원 API")
.summary("회원 가입"),
requestFields(
fieldWithPath("email").type(NULL).description("이메일(아이디)"),
fieldWithPath("password").type(STRING).description("패스워드"),
fieldWithPath("passwordConfirm").type(STRING).description("패스워드 확인"),
fieldWithPath("phoneNumber").type(STRING).description("핸드폰 번호"),
fieldWithPath("nickname").type(STRING).description("닉네임"),
fieldWithPath("name").type(STRING).description("이름")
),
responseFields(
fieldWithPath("errorCode").description("에러 코드"),
fieldWithPath("errorMessage").description("에러 메시지"),
subsectionWithPath("validation").type(OBJECT).description("유효하지 않은 필드")
)))
.contentType(JSON)
.body(requestDto)
.when()
.post("/member/register")
.then().log().all()
.statusCode(400);
}
@Test
@DisplayName("1-3. 회원가입 테스트 - 실패: 중복 이메일")
// 중복된 이메일로 인한 회원가입 실패 테스트
void registerMember_DuplicateEmail() {
// Given: 회원가입 요청 데이터 준비(1-1 테스트에서 이미 가입된 정보)
MemberRegisterRequestDto requestDto = MemberRegisterRequestDto.builder()
.name("테스트")
.email("test@example.com")
.password("Pass123!@")
.passwordConfirm("Pass123!@")
.phoneNumber("010-1234-5678")
.nickname("testUser")
.build();
// When: 중복된 이메일로 회원가입 API 호출
// Then: 중복된 이메일로 인해 409(CONFLICT) 응답 확인
given(spec)
// 문서화 필터 추가
.filter(document("회원 가입 API - 실패: 중복 이메일",
resourceDetails()
.tag("회원 API")
.summary("회원 가입"),
requestFields(
fieldWithPath("email").type(STRING).description("이메일(아이디)"),
fieldWithPath("password").type(STRING).description("패스워드"),
fieldWithPath("passwordConfirm").type(STRING).description("패스워드 확인"),
fieldWithPath("phoneNumber").type(STRING).description("핸드폰 번호"),
fieldWithPath("nickname").type(STRING).description("닉네임"),
fieldWithPath("name").type(STRING).description("이름")
),
responseFields(
fieldWithPath("errorCode").description("에러 코드"),
fieldWithPath("errorMessage").description("에러 메시지")
)))
.contentType(JSON) // 요청 본문의 Content-Type 설정 (JSON 형식)
.body(requestDto) // 요청 본문으로 DTO 전송
.when()
.post("/member/register") // POST 요청을 보냄 (회원가입 API)
.then()
.statusCode(409); // 응답 상태 코드가 409(CONFLICT)인지 확인
}
}
🚦 코드가 너무 길어서 핵심적인 변경사항들을 구분해서 다시 정리해 보도록 해야겠다!
🛠️ RestDoscs + Swagger 추가- / Refactoring
- 테스트 설정 방식 (@BeforeEach)
@BeforeEach
void setUp() {
RestAssured.port = port;
RestAssured.basePath = "/v1";
}
각 테스트마다 RestAssured.port와 RestAssured.basePath를 수동으로 설정하여 테스트 환경을 준비한다.
---------------------------------------------------------------------------------
@BeforeEach
void setUp(RestDocumentationContextProvider restDocumentation) {
this.spec = new RequestSpecBuilder()
.setPort(port)
.setBaseUri("http://localhost")
.setBasePath("/v1")
.addFilter(documentationConfiguration(restDocumentation))
.build();
RestAssured.port = port;
}
RequestSpecBuilder를 사용해 포트, URI, 경로, RestDocs 필터를 한 번에 설정하고,
이 설정을 각 테스트에서 재사용한다.
중복 설정을 줄이고 효율성을 높이는 설정 방식입니다. RestDocs와 함께 동작해 문서화 작업도 가능하게 한다.
- RestDocs 문서화 필터
.filter(document("회원 가입 API - 성공",
resourceDetails()
.tag("회원 API")
.summary("회원 가입"),
requestFields(
fieldWithPath("email").type(STRING).description("이메일(아이디)"),
fieldWithPath("password").type(STRING).description("패스워드"),
fieldWithPath("passwordConfirm").type(STRING).description("패스워드 확인"),
fieldWithPath("phoneNumber").type(STRING).description("핸드폰 번호"),
fieldWithPath("nickname").type(STRING).description("닉네임"),
fieldWithPath("name").type(STRING).description("이름")
),
responseFields(
fieldWithPath("code").type(NUMBER).description("상태 코드"),
fieldWithPath("message").type(STRING).description("상태 메시지"),
fieldWithPath("data.id").type(NUMBER).description("아이디 고유번호")
)))
RestDocs 필터를 추가하여 API 테스트 시 문서화 작업을 자동으로 진행한다.
요청 필드와 응답 필드를 문서화하여, API 문서도 생성된다.
- 테스트 순서 지정 및 memberRepository 사용 여부
@Autowired
private MemberRepository memberRepository;
@Test
@DisplayName("중복 이메일 회원가입 테스트")
void registerMember_DuplicateEmail() {
Member existingMember = new Member();
existingMember.setEmail("existing@example.com");
memberRepository.save(existingMember); // 중복 데이터를 직접 저장
MemberRegisterRequestDto requestDto = MemberRegisterRequestDto.builder()
.name("테스트")
.email("existing@example.com")
.password("Pass123!@")
.build();
given()
.contentType(ContentType.JSON)
.body(requestDto)
.when()
.post("/member/register")
.then()
.statusCode(409);
}
중복된 이메일 테스트를 위해 memberRepository로 중복된 데이터를 직접 저장해야 한다.
각 테스트가 독립적으로 실행되기 때문에 데이터가 필요할 때마다 수동으로 삽입한다.
---------------------------------------------------------------------------------
@TestMethodOrder(MethodOrderer.DisplayName.class)
@Test
@DisplayName("1-3. 회원가입 테스트 - 실패: 중복 이메일")
void registerMember_DuplicateEmail() {
MemberRegisterRequestDto requestDto = MemberRegisterRequestDto.builder()
.name("테스트")
.email("test@example.com") // 첫 번째 테스트에서 생성된 이메일 사용
.password("Pass123!@")
.build();
given(spec)
.filter(document("회원 가입 API - 실패: 중복 이메일",
resourceDetails()
.tag("회원 API")
.summary("회원 가입"),
requestFields(
fieldWithPath("email").type(STRING).description("이메일(아이디)"),
fieldWithPath("password").type(STRING).description("패스워드")
),
responseFields(
fieldWithPath("errorCode").description("에러 코드"),
fieldWithPath("errorMessage").description("에러 메시지")
)))
.contentType(JSON)
.body(requestDto)
.when()
.post("/member/register")
.then()
.statusCode(409);
}
테스트 순서를 지정하고, 첫 번째 테스트에서 성공적으로 생성된 데이터를 재사용하여
중복 이메일 테스트를 수행한다.
memberRepository를 사용하지 않으며, 테스트 순서에 따라 데이터가 자연스럽게 활용된다.
- 에러 및 응답 처리 방식
given()
.contentType(ContentType.JSON)
.body(requestDto)
.when()
.post("/member/register")
.then()
.statusCode(400)
.body("errorCode", equalTo(400))
.body("errorMessage", equalTo("유효성 검사 오류")):
에러 응답에서 errorCode와 errorMessage만을 간단하게 검증하고,
응답 필드에 대한 상세한 문서화 작업은 없다.
--------------------------------------------------------------------------------
.filter(document("회원 가입 API - 실패: 필드 유효성",
responseFields(
fieldWithPath("errorCode").description("에러 코드"),
fieldWithPath("errorMessage").description("에러 메시지"),
subsectionWithPath("validation").type(OBJECT).description("유효하지 않은 필드")
)))
에러 응답의 세부 필드를 문서화하여, validation 필드를 통해 유효성 검증에서 실패한 필드에 대한
정보를 명확히 제공합니다. API 응답 필드도 문서화된다.
- 로깅 및 디버깅
.then().log().all() // 요청 및 응답 로그 출력
log().all() 설정을 추가하여 테스트 중 발생한 요청과 응답을 기록하여,
테스트 결과를 추적하고 디버깅할 수 있게 한다.
내 코드는 주로 테스트 로직에 집중하고 설정과 데이터 처리를 명시적으로 해주어야 했다.
테스트 순서를 지정해 데이터 재사용을 가능하게 하고, RestDocs로 테스트와문서화를 통합하여
코드 중복을 줄이고 테스트 효율성을 높였다.
로깅과 에러 응답 필드 처리를 통해 테스트의 추적 가능성과 신뢰성을 높였으며, API 문서화 작업도 자동으로 수행한다.
✅ 실행 결과
/**
* allGeneratedDocs
* 각 서비스의 openapi3문서를 생성하고, 생성된 문서를 gateway 모듈에 복사하여 관리
*/
tasks.register("allGeneratedDocs", Copy) {
def services = ['member']
services.each { service ->
dependsOn(":api:${service}:openapi3")
def buildDir = project(":api:${service}").layout.buildDirectory
from(buildDir.file("api-spec/openapi3.yaml")) {
rename { fileName ->
fileName.replace("openapi3.yaml", "${service}-openapi3.yaml")
}
}
into(project(":api:gateway").file("src/main/resources/static"))
}
}
이 코드는 'member' 서비스의 OpenAPI 3.0 문서를 생성하고, 이를 gateway 모듈의 지정된 디렉토리로 복사하여
중앙 관리하는 Gradle 태스크를 정의한다.
- Gradle Task로 API 문서 생성 및 복사
API 문서를 생성하고 Gateway 모듈에 복사하는 과정은 Gradle Task를 통해 자동화할 수 있습니다.
./gradlew allGeneratedDocs를 실행하여 API 문서를 생성

- Docker Compose를 사용해 서비스 실행
API 문서화 작업을 위해 필요한 서비스(예: MySQL 등)를 로컬 환경에서 실행
이를 위해 docker compose up -d를 사용하여 필요한 서비스를 백그라운드에서 실행

- Application 실행
Member / GateWay Application : MemberApplication과 GatewayApplication을 실행

- Swagger UI 접속 및 API 테스트
이제 브라우저에서 Swagger UI에 접속하여 API를 테스트할 수 있다.
Swagger UI는 API의 엔드포인트를 시각적으로 보여주고, 직접 API를 호출할 수 있는 기능을 제공합니다.

API 문서화: 회원가입 성공 시 201 응답
회원가입 API를 호출한 결과, 201(Created) 응답 코드를 확인할 수 있었다.
이는 회원가입이 성공적으로 처리되었음을 의미한다.
해당 테스트를 통해 API가 정확하게 동작하고 있음을 검증했다!


🙇♂️ 결론
이번에 나의 테스트 코드를 통해 REST Docs와 Swagger를 연계하여 API 문서화를 진행해 보았다.
초반에는 환경 세팅이 다소 복잡하고 어려웠지만, 설정을 완료한 후에는
확실히 프론트엔드와의 협업이 더 쉬워질 것이라는 기대가 생겼다.
백엔드 개발을 먼저 경험한 친구의 말에 따르면, 프론트와의 통신 문제가 꽤 골치 아팠다고 한다.
하지만 이번 세팅을 통해 그 부분이 많이 수월해지지 않을까 싶다.
앞으로도 꾸준히 발전해 나가야겠다. 추석 동안 조금 나태해졌지만, 다시 열심히 달려보자! 💪🏃♂️➡️
'Project > 우리집 레시피' 카테고리의 다른 글
| [Project] 우리집 레시피 - 테스트 코드(test code) (2) | 2024.09.12 |
|---|---|
| [Project] 우리집 레시피 - 첫 정규 회의 (1) | 2024.09.06 |