워밍업 클럽 3기 BE 클린코드&테스트 - 4주차 발자국

워밍업 클럽 3기 BE 클린코드&테스트 - 4주차 발자국

회고

짧지만 긴 한달 간의 워밍업 클럽도 이제 막을 내립니다. 시작하기 직전까지만 해도 저는 워밍업 클럽 3기가 이렇게 혹독할거라곤 생각하지 못했습니다. 그저 산책 같을 거라 지레짐작했던 그 실체는 혹독한 마라톤이었습니다. 하루에 대략 하나의 섹션, 평균적으론 두시간 정도인 수업과 미션들에 반나절을 할애할 줄 누가 알았을까요.

한눈 팔 새도 없이 진도 따라 수업을 따라가다보니 어느덧 끝이 왔습니다. 무척 어렵고, 낯선 내용들이라 배울 때는 머리 속에 애써 넣어도 다 튕겨나가는 기분이라 아쉽기만 한 시간이었네요. 그래도 아쉽지만은 않습니다. 이렇게 좋은 강의를 알게 됐다는 것만 해도 큰 소득이라고 생각합니다. n회차 앞에서는 그 어떤 어려운 강의도 만만할 거라는 믿음을 재차 떠올려봅니다.

 

강의 내용 요약

Test Double

  • 영어로는 Stunt Double 이라고도 한다

  • Test Double의 다섯가지 종류

    • Dummy

      • 아무 것도 하지 않는 깡통 객체

    • Fake

      • 단순한 형태로 동일한 기능은 수행하나, 프로덕션에서 쓰기에는 부족한 객체

      • ex. FakeRepository(실제 db와 연결되지 않고 내부 메모리만을 사용하는 임시 리포지토리)

    • Stub

      • 테스트에서 요청한 것에 대해 미리 준비한 결과를 제공하는 객체

      • 정의된 내용 외에는 응답하지 않는다.

    • Spy

      • Stub이면서 호출된 내용을 기록하여 보여줄 수 있는 객체

      • 일부는 실제 객체처럼 동작시키고 일부만 Stubbing 할 수 있다

    • Mock

      • 행위에 대한 기대를 명세하고, 그에 따라 동작하도록 만들어진 객체

    • Sutb 과 Mock의 차이

      • Stub

        • 상태 검증

        • Stateful

      • Mock

        • 행위검증

        • Stateless

 

더 나은 테스트를 작성하기 위한 구체적 조언

한 문단에 한 주제

  • 글을 쓸 때, 한 문단은 한 주제만을 가져야 한다

  • 테스트는 문서로서의 기능을 한다

  • 테스트 코드는 글쓰기의 관점에서 하나하나의 테스트가 한 문단이다

 

완벽하게 제어하기

  • 테스트 환경을 조성할 때, 모든 조건을 완벽하게 제어할 수 있어야 한다

 

테스트 환경의 독립성을 보장하자

  • 테스트하고자 하는 대상 함수 외에 다른 함수를 같이 사용할 경우 테스트의 성패가 다른 함수에 기인할 수 있다

  • 생성자, builder와 같은 함수 외에는 다른 함수를 사용하면 안된다

    • 정적 팩토리 메소드 또한 지양 대상 (정적 팩토리 메소드 자체에 의미가 부여되어있을 경우)

테스트 간 독립성을 보장하자

  • 한 테스트는 다른 테스트에 영향을 주지 않아야 한다

  • 공유 자원을 사용해선 안된다

한눈에 들어오는 Test Fixture 구성하기

  • Test Fixture

    • 고정물, 고정되어 있는 물체

    • given 절에서 생성하는 모든 물체들을 의미

    • 테스트를 위해 원하는 상태로 고정시킨 일련의 객체

  • Test Fixture는 각 테스트마다 명시함으로써 한 눈에 들어오는 테스트를 작성하자

  • @BeforeEach 나 @BeforeAll 도 가급적 지양한다

    • 그렇다면 언제 쓸 수 있는가? : 테스트를 한 눈에 보기에 어려움이 없는 간접적인 요소의 중복 제거

Test Fixture 클렌징

  • tearDown() 함수를 만들 경우 deleteAll() 보다는 deleteAllInBatch()를 사용함으로써 비용을 줄일 수 있다

  • deleteAllInBatch)

    • 쿼리를 한번만 날린다

  • deleteAll()

    • 테이블 내 모든 로우를 조회 후 각 로우마다 delete 쿼리를 날린다

@ParameterizedTest

  • @CsvSource, @MethodSource 등과 같이 사용한다

  • 하나의 테스트 케이스지만 여러개의 값을 대입해보고 싶을 경우에 사용할 수 있다

@DynamicTest

  • 시나리오를 기반으로 테스트를 할 때 사용한다

  • 하나의 테스트에서 상태를 공유하면서 내부적으로 다양한 하위 테스트를 진행할 수 있다

환경 통합하기

  • 테스트 수행도 비용이기 때문에 최대한 줄여야 한다

  • 매 테스트시마다 스프링 부트를 초기화하는데 드는 비용을 최소화 하는 것이 가능하다

  • @WebMvcTest, @DataJpaTest, @SpringBootTest와 같은, 스프링부트를 초기화하는 어노테이션을 쓸 경우

    • 부모 클래스 (ex. IntegrationTestSupport)에 어노테이션을 넣어서 상속하는 방식을 사용한다

    • mockBean의 구성이 다를 경우에도 새롭게 초기화하기 때문에 부모 클래스에 담는다

      • 이때 protected를 사용하여 상속받는 클래스에서도 사용이 가능하게끔 설정한다

  • 서비스, 리포지토리 레이어는 통합, 컨트롤러는 단위 테스트로 진행할 경우

    • WebMvcTest 따로, @DataJpaTest, @SpringBootTest 따로 총 두번의 초기화가 되게끔 작성하는 방식을 사용

private 메서드에 테스트하기

  • private 메서드는 테스트를 하면 안된다

    • private 메서드는 보통 public 함수 테스트 시에 같이 테스트가 된다

  • 만약 그럼에도 불구하고 테스트가 필요하다고 느낄 경우

    • 해당 클래스가 너무 많은 역할을 가졌다는 신호

    • private 메서드를 따로 분리하여 새로운 클래스에 역할을 위임한 후 테스트한다

테스트에서만 사용하는 메서드

  • 사용 가능하지만 가급적 지양

  • 보수적인 측면에서 접근해야 한다

학습 테스트

  • 잘 모르는 기능, 라이브러리, 프레임워크를 학습하기 위해 작성하는 테스트

  • 여러 테스트 케이스를 스스로 정의하고 검증하는 과정을 통해 보다 구체적인 동작과 기능을 학습할 수 있다

  • 관련 문서만 읽는 것보다 훨씬 재미있게 학습할 수 있다

Spring REST Docs

  • 테스트 코드를 통한 API 문서 자동화 도구

  • API 명세를 문서로 만들고 외부에 제공함으로써 협업을 원활하게 한다

  • 기본적으로 AsciiDoc을 사용하여 문서를 작성한다

  • REST Docs vs Swagger

    • REST Docs

      • 장점

        • 테스트를 통과해야 문서가 만들어진다

          • 신뢰도가 높다

        • 프로덕션 코드에 비침투적이다

      • 단점

      • 코드 양이 많다

      • 설정이 어렵다

    • Swagger

      • 장점

        • 적용이 쉽다

        • 문서에서 바로 API 호출을 수행해볼 수 있다

      • 단점

        • 프로덕션 코드에 침투적이다

        • 테스트와 무관하기 때문에 신뢰도가 떨어질 수 있다.

 

미션

Day 16

미션 설명

Layered Architecture 구조의 레이어별 테스트 작성법을 알아보았습니다.
레이어별로 1) 어떤 특징이 있고, 2) 어떻게 테스트를 하면 좋을지, 자기만의 언어로 다시 한번 정리해 볼까요?

 

나의 답

Persistence Layer

  • 특징

    • DB 데이터에 직접 접근하는 역할을 가진다

    • 데이터 제공 외의 역할을 해선 안된다 (비즈니스 가공 로직은 포함해선 안된다)

    • 데이터에 대한 CRUD에 집중한 레이어다

  • 테스트 코드 작성법

    • DB 접근을 통해 수행하는 CRUD 작업이 정상적으로 수행됐는지 검증한다

    • DataJpaTest 어노테이션을 활용하여 좀 더 가벼운(더 빠른) 테스트 코드 작성이 가능하다

      • Persistence Layer와 관련된 빈들만을 스프링 컨테이너에 올린다

    • 테스트 시 사용하는 DB는 실제 배포 환경에서 사용하는 DB를 일치시켜야 한다

      • 두 환경의 DB를 다르게 할 경우, 코드의 안정성을 보장할 수 없다

      • @ActiveProfiles("test") 어노테이션 application.yml 파일에 설정해둔 테스트 환경 전용 설정을 사용할 수 있다.

코드 예시

@ActiveProfiles("test")
@DataJpaTest
class ProductRepositoryTest {

    @Autowired
    private ProductRepository productRepository;

    @DisplayName("원하는 판매상태를 가진 상품들을 조회한다.")
    @Test
    void findAllBySellingStatusIn() {
        // given
        Product product1 = createProduct("001", HANDMADE, SELLING, "아메리카노", 4000);
        Product product2 = createProduct("002", HANDMADE, HOLD, "카페라떼", 4500);
        Product product3 = createProduct("003", HANDMADE, STOP_SELLING, "팥빙수", 7000);
        productRepository.saveAll(List.of(product1, product2, product3));

        // when
        List<Product> products = productRepository.findAllBySellingStatusIn(List.of(SELLING, HOLD));

        // then
        assertThat(products).hasSize(2)
                .extracting("productNumber", "name", "sellingStatus")
                .containsExactlyInAnyOrder(
                        tuple("001", "아메리카노", SELLING),
                        tuple("002", "카페라떼", HOLD)
                );
    }

    private Product createProduct(String productNumber, ProductType type, ProductSellingStatus sellingStatus, String name, int price) {
        return Product.builder()
                .productNumber(productNumber)
                .type(type)
                .sellingStatus(sellingStatus)
                .name(name)
                .price(price)
                .build();
    }
}

 

application.yml 파일 예시

spring:
  application:
    name: cafekiosk

  profiles:
    default: local

  datasource:
    url: jdbc:h2:mem:~/cafeKioskApplication
    driver-class-name: org.h2.Driver
    username: sa
    password:

  jpa:
    hibernate:
      ddl-auto: none

---
spring:
  config:
    activate:
      on-profile: local

  jpa:
    hibernate:
      ddl-auto: create
    show-sql: true
    properties:
      hibernate:
        format_sql: true
    defer-datasource-initialization: true # (2.5~) Hibernate 초기화 이후 data.sql 실행

  h2:
    console:
      enabled: true

---
spring:
  config:
    activate:
      on-profile: test

  jpa:
    hibernate:
      ddl-auto: create
    show-sql: true
    properties:
      hibernate:
        format_sql: true

  sql:
    init:
      mode: NEVER

Business Layer

  • 특징

    • 비즈니스 로직을 구현하는 역할을 가진다

    • Persistence Layer와의 상호 작용을 통해 비즈니스 로직을 전개한다

    • 트랜잭션을 보장해야 한다

  • 테스트 코드 작성법

    • 비즈니스 로직의 정상 동작을 테스트한다.

    • 엣지 케이스 위주로 작성한다

      • 테스트에 성공하는 특정 값의 범위가 있을 경우, 해당 범위의 극점을 사용함으로써 나머지 값들의 성공을 어느정도 보장할 수 있다

    • 해피 케이스 외의 눈에 보이는 예외와 눈에 보이지 않는 예외 케이스에 대한 테스트에 집착해야 한다.

    • Persistence Layer와 함께 통합 테스트로 진행한다

tearDown() vs @Transactional

각 테스트는 독립성이 보장되어야 한다. 이를 위해 테스트를 수행할때마다 초기화 작업이 진행되어야 하며 이때 두가지 방식을 사용할 수 있다.

  • tearDown()

    • @AfterEach 메소드를 붙인 tearDown() 함수를 설정하여 직접 초기화 로직을 작성한다

    • tearDown이라는 명칭은 단순 관례에 불과하며, 이는 테스트 코드 수행 후 정리하는 함수라는 의미를 가진다

  • @Transactional

    • 테스트 클래스 혹은 테스트 메소드에 어노테이션 형식으로 사용할 수 있다

    • 테스트가 끝날때마다 트랜잭션의 롤백 기능을 활용하여 초기화 작업을 수행한다

    • 실제 코드의 안정성을 보장할 수 없기 때문에 조심스럽게 활용해야 한다

      • 실제 코드의 @Transactional 누락 여부와 관계 없이 @Transactional 어노테이션이 작동한다.

      • 테스트가 실제 코드의 @Transactional 누락을 잡아낼 수 없다!

코드 예시

@ActiveProfiles("test")
//@Transactional
@SpringBootTest
class OrderServiceTest {

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private OrderRepository orderRepository;

    @Autowired
    private OrderProductRepository orderProductRepository;

    @Autowired
    private StockRepository stockRepository;

    @Autowired
    private OrderService orderService;

    @AfterEach
    void tearDown() {
        orderProductRepository.deleteAllInBatch();
        productRepository.deleteAllInBatch();
        orderRepository.deleteAllInBatch();
        stockRepository.deleteAllInBatch();
    }

    @DisplayName("주문번호 리스트를 받아 주문을 생성한다.")
    @Test
    void createOrder() {
        // given
        LocalDateTime registeredDateTime = LocalDateTime.now();

        Product product1 = createProduct(HANDMADE, "001", 1000);
        Product product2 = createProduct(HANDMADE, "002", 3000);
        Product product3 = createProduct(HANDMADE, "003", 5000);
        productRepository.saveAll(List.of(product1, product2, product3));

        OrderCreateServiceRequest request = OrderCreateServiceRequest.builder()
                .productNumbers(List.of("001", "002"))
                .build();

        // when
        OrderResponse orderResponse = orderService.createOrder(request, registeredDateTime);

        // then
        assertThat(orderResponse.getId()).isNotNull();
        assertThat(orderResponse)
                .extracting("registeredDateTime", "totalPrice")
                .contains(registeredDateTime, 4000);
        assertThat(orderResponse.getProducts()).hasSize(2)
                .extracting("productNumber", "price")
                .containsExactlyInAnyOrder(
                        tuple("001", 1000),
                        tuple("002", 3000)
                );
    }

    @DisplayName("재고가 부족한 상품으로 주문을 생성하려는 경우 예외가 발생한다.")
    @Test
    void createOrderWithNoStock() {
        // given
        LocalDateTime registeredDateTime = LocalDateTime.now();

        Product product1 = createProduct(BOTTLE, "001", 1000);
        Product product2 = createProduct(BAKERY, "002", 3000);
        Product product3 = createProduct(HANDMADE, "003", 5000);
        productRepository.saveAll(List.of(product1, product2, product3));

        Stock stock1 = Stock.create("001", 1);
        Stock stock2 = Stock.create("002", 2);
        stockRepository.saveAll(List.of(stock1, stock2));

        OrderCreateServiceRequest request = OrderCreateServiceRequest.builder()
                .productNumbers(List.of("001", "001", "002", "003"))
                .build();

        // when // then
        assertThatThrownBy(() -> orderService.createOrder(request, registeredDateTime))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("재고가 부족한 상품이 있습니다.");
    }

    private Product createProduct(ProductType type, String productNumber, int price) {
        return Product.builder()
                .type(type)
                .productNumber(productNumber)
                .price(price)
                .sellingStatus(SELLING)
                .name("메뉴 이름")
                .build();
    }
}

Presentation Layer

  • 특징

    • 외부 세계의 요청을 가장 먼저 받는 계층이다

    • 파라미터에 대한 최소한의 유효성 검사를 수행한다

  • 테스트 코드 작성법

    • 비즈니스 로직을 제외한, 외부 요청에 대한 유효성 검사와 응답에 대한 테스트를 진행한다

      • Business Layer 호출은 Mock 객체를 통해 이루어진다

    • MockMvc 인스턴스를 통한 모의 요청을 사용한다

      • 이때 json 형식의 body가 필요할 경우 ObjectMapper를 활용한다.

코드 예시

@WebMvcTest(controllers = OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockitoBean
    private OrderService orderService;

    @DisplayName("신규 주문을 등록한다.")
    @Test
    void createOrder() throws Exception {
        // given
        OrderCreateRequest request = OrderCreateRequest.builder()
                .productNumbers(List.of("001"))
                .build();

        // when // then
        mockMvc.perform(
                        MockMvcRequestBuilders.post("/api/v1/orders/new")
                                .content(objectMapper.writeValueAsString(request))
                                .contentType(MediaType.APPLICATION_JSON)
                )
                .andDo(MockMvcResultHandlers.print())
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(jsonPath("$$.code").value("200"))
                .andExpect(jsonPath("$$.status").value("OK"))
                .andExpect(jsonPath("$$.message").value("OK"));
    }

    @DisplayName("신규 주문을 등록할 때 상품번호는 1개 이상이어야 한다.")
    @Test
    void createOrderWithEmptyProductNumbers() throws Exception {
        // given
        OrderCreateRequest request = OrderCreateRequest.builder()
                .productNumbers(List.of())
                .build();

        // when // then
        mockMvc.perform(
                        MockMvcRequestBuilders.post("/api/v1/orders/new")
                                .content(objectMapper.writeValueAsString(request))
                                .contentType(MediaType.APPLICATION_JSON)
                )
                .andDo(MockMvcResultHandlers.print())
                .andExpect(MockMvcResultMatchers.status().isBadRequest())
                .andExpect(jsonPath("$$.code").value("400"))
                .andExpect(jsonPath("$$.status").value("BAD_REQUEST"))
                .andExpect(jsonPath("$$.message").value("상품 번호 리스트는 필수입니다."));
    }
} // $$ 는 실제 코드에서 $ 로 작성됩니다.

 

Day 18

미션 설명 1

@Mock, @MockBean, @Spy, @SpyBean, @InjectMocks 의 차이를 한번 정리해 봅시다.

 

나의 답

@Mock

  • 행위에 대한 기대를 명세하고, 그에 따라 동작하는 객체를 생성합니다

  • 테스트하기 어려운 외부 의존성을 대체하기 위해 사용된다

@MockBean

  • @Mock과 같은 기능을 하지만 스프링 컨테이너 아래에서 동작한다

  • 대상 객체의 Mock 객체를 대신 컨테이너에 올린다

@Spy

  • Stub 이면서 호출된 내용을 기록하여 보여줄 수 있는 객체

  • 실제 객체처럼 동작하고 일부만 Stubbing 할 수 있다

@SpyBean

  • @Spy와 같은 기능을 하지만 스프링 컨테이너 아래에서 동작한다

  • 대상 객체의 Spy 객체를 대신 컨테이너에 올린다

@InjectMocks

  • @Mock 과 @Spy 로 생성한 Stub 객체를 의존성으로 주입한다

  • 스프링이 자동으로 의존성을 주입해주는 @MockBean, @SpyBean 과는 달리 @Mock, @Spy를 사용할 경우 @InjectMocks 를 통해 의존성을 주입해줘야 한다

미션 설명 2

  1. 아래 3개의 테스트를 살펴보고, 각 항목을 @BeforeEach, given절, when절에 배치한다면 어떻게 배치하고 싶으신가요?

@BeforeEach 
void setUp() {
    ❓
} 

@DisplayName("사용자가 댓글을 작성할 수 있다.")
@Test
void writeComment() {
    1-1. 사용자 생성에 필요한 내용 준비
    1-2. 사용자 생성
    1-3. 게시물 생성에 필요한 내용 준비
    1-4. 게시물 생성
    1-5. 댓글 생성에 필요한 내용 준비
    1-6. 댓글 생성

    // given
    ❓

    // when
    ❓

    // then
    검증
}

@DisplayName("사용자가 댓글을 수정할 수 있다.")
@Test
void updateComment() {
    2-1. 사용자 생성에 필요한 내용 준비
    2-2. 사용자 생성
    2-3. 게시물 생성에 필요한 내용 준비
    2-4. 게시물 생성
    2-5. 댓글 생성에 필요한 내용 준비
    2-6. 댓글 생성
    2-7. 댓글 수정

    // given
    ❓

    // when
    ❓

    // then
    검증
}

@DisplayName("자신이 작성한 댓글이 아니면 수정할 수 없다.")
@Test
void cannotUpdateCommentWhenUserIsNotWriter() {
    3-1. 사용자1 생성에 필요한 내용 준비
    3-2. 사용자1 생성
    3-3. 사용자2 생성에 필요한 내용 준비
    3-4. 사용자2 생성
    3-5. 사용자1의 게시물 생성에 필요한 내용 준비
    3-6. 사용자1의 게시물 생성
    3-7. 사용자1의 댓글 생성에 필요한 내용 준비
    3-8. 사용자1의 댓글 생성
    3-9. 사용자2가 사용자1의 댓글 수정 시도

    // given
    ❓

    // when
    ❓

    // then
    검증        
}

나의 답

@BeforeEach 
void setUp() {
} 

@DisplayName("사용자가 댓글을 작성할 수 있다.")
@Test
void writeComment() {

    // given
    1-1. 사용자 생성에 필요한 내용 준비
    1-2. 사용자 생성
    1-3. 게시물 생성에 필요한 내용 준비
    1-4. 게시물 생성
    1-5. 댓글 생성에 필요한 내용 준비

    // when
    1-6. 댓글 생성

    // then
    검증
}

@DisplayName("사용자가 댓글을 수정할 수 있다.")
@Test
void updateComment() {

    // given
    2-1. 사용자 생성에 필요한 내용 준비
    2-2. 사용자 생성
    2-3. 게시물 생성에 필요한 내용 준비
    2-4. 게시물 생성
    2-5. 댓글 생성에 필요한 내용 준비
    2-6. 댓글 생성

    // when
    2-7. 댓글 수정

    // then
    검증
}

@DisplayName("자신이 작성한 댓글이 아니면 수정할 수 없다.")
@Test
void cannotUpdateCommentWhenUserIsNotWriter() {

    // given
    3-1. 사용자1 생성에 필요한 내용 준비
    3-2. 사용자1 생성
    3-3. 사용자2 생성에 필요한 내용 준비
    3-4. 사용자2 생성
    3-5. 사용자1의 게시물 생성에 필요한 내용 준비
    3-6. 사용자1의 게시물 생성
    3-7. 사용자1의 댓글 생성에 필요한 내용 준비
    3-8. 사용자1의 댓글 생성

    // when
    3-9. 사용자2가 사용자1의 댓글 수정 시도

    // then
    검증
}

의도

  • 테스트하고자 하는 행위 외의 모든 준비 로직을 given에 두었습니다

  • 각 테스트의 독립성과 한 눈에 확인할 수 있는 가독성을 확보하기 위해 @BeforeEach는 활용하지 않았습니다

댓글을 작성해보세요.

채널톡 아이콘