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

4주차 미션 회고

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

 

4주차 강의 회고

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

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

 

4주차 학습 내용 요약

테스트 더블


image

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

Mokito 주요 애노테이션


image

@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 클렌징 시 주의사항

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

  • 스프링 테스트 프레임워크는 컨텍스트 로딩 비용을 줄이기 위해 컨텍스트 캐시를 사용한다.

  • 동일한 설정(Class, 프로파일, MockBean 등)이면 컨텍스트를 재사용한다.

  • 하지만 위와 같은 조건이 달라지면 캐시가 무효화되고 컨텍스트 재로딩이 발생한다.

 

해결 방법

image

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'
    }
}
  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

  • 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

image

Response

image

댓글을 작성해보세요.

채널톡 아이콘