inflearn logo
강의

강의

N
챌린지

챌린지

멘토링

멘토링

N
클립

클립

로드맵

로드맵

지식공유

워밍업 클럽 4기 BE - 4주차 발자국

민수킴
1

4주차 미션 회고

레이어 아키텍처에서 각 계층별로 테스트 코드를 작성하는 실용적인 방법을 체계적으로 익힐 수 있었다. 특히 Controller, Service, Repository 계층에서 어떤 부분을 중점적으로 테스트해야 하는지, 그리고 각 계층 간의 의존성을 어떻게 효과적으로 격리할 수 있는지에 대한 명확한 가이드라인을 얻었다. 또한 @Mock, @MockBean, @Spy 등 여러 Mock 애노테이션들의 차이점과 사용 시점을 명확히 구분할 수 있게 되면서, 상황에 맞는 적절한 Mock 전략을 선택할 수 있는 능력을 키울 수 있었다. BDD 스타일의 테스트에서는 Given-When-Then 각 단계를 명확히 구분하여 작성할 수 있게 되었다.

 

4주차 강의 회고

테스트 코드를 작성하면서 그동안 애매하게 느꼈던 부분들이 많이 해소되었다. 기본적인 Mock 사용법부터 Spring REST Docs까지 학습하면서 실무에서 바로 적용할 수 있는 실용적인 지식을 쌓을 수 있었고, 테스트 코드에 대한 막연한 두려움도 자연스럽게 사라졌다.

길다면 길고 짧다면 짧은 4주라는 시간이 빠르게 지나갔지만, 다행히 강의와 미션들을 무사히 완주했다. 클린코드와 테스트코드는 모든 개발자의 기본 소양이라고 생각하는데, 이번 기회를 통해 그 기반을 탄탄히 다지게 되어 앞으로 더 발전할 수 있는 든든한 토대를 마련한 느낌이다.

 

4주차 학습 내용 요약

테스트 더블


image

테스트를 진행하기 어려운 경우 이를 대신해 테스트를 진행할 수 있도록 만들어주는 객체

Mokito 주요 애노테이션


image

@InjectMocks 주의사항

@SpyBean 주의사항

BDDMockito


// Mockito
Mockito.when(mailSendClient.sendEmail(anyString(), anyString(), anyString(), anyString()))
        .thenReturn(true);
        
// BDDMockito
BDDMockito.given(mailSendClient.sendEmail(anyString(), anyString(), anyString(), anyString()))
        .willReturn(true);

더 나은 테스트 작성법


하나의 테스트에서 하나의 검증만 수행하기(한 문단에 한 주제)

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();
}

 

제어 가능한 테스트가 가능하도록 코드 작성하기

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();
}

테스트는 하나의 이해하기 쉬운 문서처럼 작성되어야 하며, given 절에서 테스트의 픽스처를 구성하는 것이 가독성과 유지보수 측면에서 유리하다.

 

Spring Data JPA 사용 환경에서 Test Fixture 클렌징 시 주의사항

image

@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());
                })
        );
    }
}

전체 테스트 수행 시간 줄이기


스프링 컨텍스트가 다시 로딩되는 케이스

image

 

해결 방법

image

Spring REST Docs


 

빌드 설정


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'
    }
}
  1. test → 스니펫 파일 생성 (build/generated-snippets)

  2. asciidoctor → AsciiDoc을 HTML로 변환

  3. 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

 

커스텀 스니펫 템플릿 작성

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 오바라이드 예시

 

사용 예시

 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

image

Response

image

답변 0