워밍업 클럽 4기 BE - 4주차 발자국
4주차 미션 회고
레이어 아키텍처에서 각 계층별로 테스트 코드를 작성하는 실용적인 방법을 체계적으로 익힐 수 있었다. 특히 Controller, Service, Repository 계층에서 어떤 부분을 중점적으로 테스트해야 하는지, 그리고 각 계층 간의 의존성을 어떻게 효과적으로 격리할 수 있는지에 대한 명확한 가이드라인을 얻었다. 또한 @Mock, @MockBean, @Spy 등 여러 Mock 애노테이션들의 차이점과 사용 시점을 명확히 구분할 수 있게 되면서, 상황에 맞는 적절한 Mock 전략을 선택할 수 있는 능력을 키울 수 있었다. BDD 스타일의 테스트에서는 Given-When-Then 각 단계를 명확히 구분하여 작성할 수 있게 되었다.
4주차 강의 회고
테스트 코드를 작성하면서 그동안 애매하게 느꼈던 부분들이 많이 해소되었다. 기본적인 Mock 사용법부터 Spring REST Docs까지 학습하면서 실무에서 바로 적용할 수 있는 실용적인 지식을 쌓을 수 있었고, 테스트 코드에 대한 막연한 두려움도 자연스럽게 사라졌다.
길다면 길고 짧다면 짧은 4주라는 시간이 빠르게 지나갔지만, 다행히 강의와 미션들을 무사히 완주했다. 클린코드와 테스트코드는 모든 개발자의 기본 소양이라고 생각하는데, 이번 기회를 통해 그 기반을 탄탄히 다지게 되어 앞으로 더 발전할 수 있는 든든한 토대를 마련한 느낌이다.
4주차 학습 내용 요약
테스트 더블
테스트를 진행하기 어려운 경우 이를 대신해 테스트를 진행할 수 있도록 만들어주는 객체
Mokito 주요 애노테이션
✅@InjectMocks
주의사항
@InjectMocks
은 Mockito 테스트 영역에서 사용되며,@Mock
이나@Spy
로 생성된 Mockito 객체만 주입 대상으로 인식한다.따라서
@InjectMocks
은 스프링 컨텍스트와는 무관하게 동작하며,@MockBean
이나@SpyBean
같은 스프링 테스트 애노테이션으로 생성된 객체를 주입받을 수 없다.@InjectMocks
이 적용된 객체는 필드 주입, 생성자 주입, setter 주입 등을 통해@Mock
또는@Spy
객체가 자동으로 주입된다.
✅@SpyBean
주의사항
@SpyBean
은 스프링 컨텍스트에 실제로 등록된 빈을 감싸는 프록시 객체를 생성하여 사용한다.따라서
@SpyBean
의 대상이 인터페이스인 경우에는 스프링 컨텍스트에 해당 인터페이스를 구현한 실제 구현체 빈이 반드시 존재해야 한다.
BDDMockito
// Mockito
Mockito.when(mailSendClient.sendEmail(anyString(), anyString(), anyString(), anyString()))
.thenReturn(true);
// BDDMockito
BDDMockito.given(mailSendClient.sendEmail(anyString(), anyString(), anyString(), anyString()))
.willReturn(true);
BDD 스타일로 Mockito를 사용할 수 있도록 지원해주는 라이브러리이다.
실제 Mockito를 상속해서 구현한 객체이므로 모든 기능은 Mockito와 동일하다.
더 나은 테스트 작성법
✅하나의 테스트에서 하나의 검증만 수행하기(한 문단에 한 주제)
for (ProductType productType : productTypes) {
if (productType == ProductType.HANDMADE) {
...
}
if (productType == ProductType.BAKERY) {
...
}
}
void containsStockType() {
ProductType givenType = ProductType.HANDMADE;
boolean result = ProductType.containsStock(givenType);
assertThat(result).isFalse();
}
void containsStockType2() {
ProductType givenType = ProductType.BAKERY;
boolean result = ProductType.containsStock(givenType);
assertThat(result).isTrue();
}
분기문, 반복문 등의 논리 구조를 지양해야 한다.
DisplayName을 한 문장으로 구성할 수 있는지 판단해보자.
✅제어 가능한 테스트가 가능하도록 코드 작성하기
public Order createOrder() {
LocalDateTime currentDateTime = LocalDateTime.now();
LocalDate currentDate = currentDateTime.toLocalDate();
if (currentTime.isBefore(...)) throw new IllegalArumentException(...)
}
public Order createOrder(LocalDateTime currentDateTime) {
LocalDate currentDate = currentDateTime.toLocalDate();
if (currentTime.isBefore(...)) throw new IllegalArumentException(...)
}
✅테스트 간 독립성 보장하기
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class SharedStateTest {
private int count = 0;
@Test
void testIncrementOnce() {
count++;
assertThat(count).isEqualTo(1); // ✅ 통과
}
@Test
void testIncrementTwice() {
count++;
count++;
assertThat(count).isEqualTo(2); // ❌ 실패 (이전 테스트에서 count 증가됨)
}
}
테스트가 외부 조건, 순서, 공유 상태 등에 영향을 받지 않고 항상 동일한 조건에서 수행되어야 한다.
✅한 눈에 들어오는 Test Fixture
@DisplayName("신규 상품을 등록한다. 상품번호는 가장 최근 상품의 상품번호에서 1 증가한 값이다.")
@Test
void createProduct() {
// Given
Product product = createProduct("001", HANDMADE, SELLING, "아메리카노", 4000);
productRepository.save(product);
// When
ProductResponse productResponse = productService.createProduct(request);
// Then
...
}
// 픽스처 생성 메서드
// **createProduct 테스트에서 상품번호 외의 값은 고려할 대상이 아니다.**
private Product createProduct(String productNumber) {
return Product.builder()
.productNumber(productNumber)
.type(HANDMADE)
.sellingStatus(SELLING)
.name("아메리카노")
.price(4000)
.build();
}
beforeAll, setUp (@beforeEach) 사용 지양하기 (사용하고 싶다면 아래 2가지 질문해보기)
각 테스트 입장에서 봤을 때, 아예 몰라도 테스트 내용을 이해하는 데에 문제가 없는가?
수정해도 모든 테스트에 영향을 주지 않는가?
data.sql과 같은 공통 로직으로 테스트 픽스처 구성하지 않기
픽스처 생성 로직은 같은 테스트 클래스에서 관리하기
픽스처 생성 메서드에서는 정말 필요한 값만 파라미터로 받기
테스트는 하나의 이해하기 쉬운 문서처럼 작성되어야 하며,
given
절에서 테스트의 픽스처를 구성하는 것이 가독성과 유지보수 측면에서 유리하다.
✅Spring Data JPA 사용 환경에서 Test Fixture 클렌징 시 주의사항
✅@DaynamicTest
class OrderStatusDynamicTest {
@TestFactory
Collection<DynamicTest> testOrderStatusTransitionsWithDifferentLogic() {
return List.of(
DynamicTest.dynamicTest("CREATED → PAID: 결제 처리 및 영수증 생성",
() -> {
// Given
Order order = new Order(OrderStatus.CREATED);
// When
order.processPayment(); // 내부에서 status 변경 + 영수증 생성
// Then
assertEquals(OrderStatus.PAID, order.getStatus());
assertNotNull(order.getReceipt());
}),
DynamicTest.dynamicTest("PAID → SHIPPED: 송장 발급 및 상태 변경",
() -> {
// Given
Order order = new Order(OrderStatus.PAID);
// When
order.prepareShipment(); // 송장 생성 + 상태 변경
// Then
assertEquals(OrderStatus.SHIPPED, order.getStatus());
assertNotNull(order.getInvoiceNumber());
}),
DynamicTest.dynamicTest("SHIPPED → DELIVERED: 배송 완료 처리",
() -> {
// Given
Order order = new Order(OrderStatus.SHIPPED);
// When
order.markAsDelivered();
// Then
assertEquals(OrderStatus.DELIVERED, order.getStatus());
assertTrue(order.isDeliveredTimeRecorded());
})
);
}
}
전체 테스트 수행 시간 줄이기
✅스프링 컨텍스트가 다시 로딩되는 케이스
스프링 테스트 프레임워크는 컨텍스트 로딩 비용을 줄이기 위해 컨텍스트 캐시를 사용한다.
동일한 설정(Class, 프로파일, MockBean 등)이면 컨텍스트를 재사용한다.
하지만 위와 같은 조건이 달라지면 캐시가 무효화되고 컨텍스트 재로딩이 발생한다.
✅해결 방법
Spring REST Docs
테스트 코드를 통한 API 문서 자동화 도구이다.
API 명세를 문서로 만들고 외부에 제공함으로써 협업을 원활하게 한다.
기본적으로 AsciiDoc을 사용하여 문서를 작성한다.
테스트 코드를 통과해야 문서가 만들어진다.
프로덕션 코드에 비침투적이다. (Swagger는 침투적이다.)
✅빌드 설정
plugins {
id "org.asciidoctor.jvm.convert" version "3.3.2"
}
configurations {
asciidoctorExt
}
dependencies {
asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor:{project-version}' (3)
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc:{project-version}' (4)
}
// 테스트 실행 시 생성되는 REST Docs 스니펫 파일들이 저장될 디렉토리를 정의
ext {
snippetsDir = file('build/generated-snippets')
}
// 테스트 태스크가 snippetsDir에 출력 파일을 생성한다고 Gradle에 알림
test {
outputs.dir snippetsDir
}
asciidoctor {
// 스니펫 파일들을 입력으로 사용
inputs.dir snippetsDir
// AsciiDoc 확장 기능 활성화
configurations 'asciidoctorExt'
// 모든 하위 폴더의 index.adoc 파일만 처리
sources {
include("**/index.adoc")
}
// 소스 파일의 위치를 기준으로 상대 경로 해석
baseDirFollowsSourceDir()
// AsciiDoc 생성 전에 반드시 테스트 실행
dependsOn test
}
// Spring Boot JAR 파일에 생성된 문서를 포함
bootJar {
dependsOn asciidoctor
// 생성된 HTML 문서 파일들(현재 예시에서는 index.adoc)을 가져옴
from ("${asciidoctor.outputDir}") {
into 'static/docs'
}
}
test → 스니펫 파일 생성 (
build/generated-snippets
)asciidoctor → AsciiDoc을 HTML로 변환
bootJar → 생성된 HTML을 JAR에 포함
✅스니펫 조합 템플릿 작성
ifndef::snippets[]
:snippets: ../../build/generated-snippets
endif::[]
= CafeKiosk REST API 문서
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 2
:sectlinks:
[[Product-API]]
== Product API
// 유지보수 편의를 위해 각각의 adoc 파일을 분리하여 index.adoc 파일에 합칠 수 있다.
include::api/product/product.adoc[]
기본 경로 : src/docs/asciidoc/index.adoc
generated-snippets에 생성된 코드 조각들을 하나의 완성된 문서로 조합하는 템플릿 역할을 한다.
AsciiDoctor는
index.adoc
을 처리하여 완성된 HTML 문서를 생성한다.여러 스니펫(파일 조각)을 어떤 형식으로 배치할지 결정한다.
✅커스텀 스니펫 템플릿 작성
src/test/resources/org/springframework/restdocs/templates/asciidoctor/
├── request-fields.snippet
├── response-fields.snippet
├── path-parameters.snippet
├── request-parameters.snippet
├── http-request.snippet
├── http-response.snippet
├── curl-request.snippet
└── ...
기본 경로 : src/test/resources/org/springframework/restdocs/templates/asciidoctor/
==== Request Fields
|===
|Path|Type|Optional|Description
{{#fields}}
|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}}
|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}}
|{{#tableCellContent}}{{#optional}}O{{/optional}}{{/tableCellContent}}
|{{#tableCellContent}}{{description}}{{/tableCellContent}}
{{/fields}}
|===
request-fields.snippet 오바라이드 예시
Spring REST Docs는 기본 스니펫 템플릿이 내장되어 있다.
기본 경로에 파일을 생성하면 기본 스니펫 템플릿을 오버라이드할 수 있다.
각각의 스니펫을 어떤 형식으로 보여줄지 결정한다.
✅사용 예시
mockMvc.perform(post("/api/v1/products/new")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print())
.andExpect(status().isOk())
// restdoc config
.andDo(document("product-create",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
// request
requestFields(
fieldWithPath("type").type(JsonFieldType.STRING).description("상품 타입"),
fieldWithPath("sellingStatus").type(JsonFieldType.STRING).description("상품 판매상태").optional(),
fieldWithPath("name").type(JsonFieldType.STRING).description("상품 이름"),
fieldWithPath("price").type(JsonFieldType.NUMBER).description("상품 가격")
),
// response
responseFields(
fieldWithPath("code").type(JsonFieldType.NUMBER).description("코드"),
fieldWithPath("status").type(JsonFieldType.STRING).description("상태"),
fieldWithPath("message").type(JsonFieldType.STRING).description("메시지"),
fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"),
fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("상품 ID"),
fieldWithPath("data.productNumber").type(JsonFieldType.STRING).description("상품 번호"),
fieldWithPath("data.type").type(JsonFieldType.STRING).description("상품 타입"),
fieldWithPath("data.sellingStatus").type(JsonFieldType.STRING).description("상품 판매상태"),
fieldWithPath("data.name").type(JsonFieldType.STRING).description("상품 이름"),
fieldWithPath("data.price").type(JsonFieldType.NUMBER).description("상품 가격")
)
)
);
request
Response
댓글을 작성해보세요.