인프런 워밍업 클럽 백엔드 4주차 후기

후기

드디어 인프런 워밍업 클럽의 마지막 발자국을 쓰게 되었다.

길다면 길고 짧다면 짧은 4주동안 두 개의 강의를 들으면서 많은 것을 느낄 수 있던 시간이었다.

Readable Code에서는 추상을 통해서 메시지를 잘 만들고, 책임과 역할을 잘 분배하여 읽기 좋은 코드를 만들 수 있도록 노력해야 겠음을 느꼈고, Practical Test에서는 좋은 테스트 코드를 만들어서 사람이 노가다 하는 비중을 최대한 줄이면서, 안정적인 코드를

만들어야겠음을 느꼈다.

개발이라는 것은 나혼자만이 아닌 팀 단위로 움직이는 것이기 때문에 이를 팀 레벨로 이끌고 싶은 욕구가 생겼다. 그러기 위해선 내가 그를 증명할 수 있는 탄탄하고 좋은 실력을 갖추도록 노력해야겠다. 이번 인프러너의 좋은 강의들을 재반복하면서 나의 지식으로 습득하고, 이를 다른 사람들에게 전수할 수 있는 그런 사람이 되도록 해야겠다.

즐거운 기회를 마련해준 우빈님과 인프런에게 감사를 표하며 글을 마치겠다! ㅎㅎ

 

내용 정리

Presentation Layer 테스트(1)

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

  • 파라미터에 대한 최소한의 검증을 수행한다

  • Mock (가짜, 대역) 객체

    • 테스트 시 의존관계 주입이 방해될 때, 가짜를 집어넣어 처리함

  • MockMvc

    • Mock(가짜) 객체를 사용해 스프링 MVC 동작을 재현할 수 있는 테스트 프레임워크

       

  • @Transactional(readOnly = true)

    • CRUD에서 CUD가 동작 안 함

    • 오직 R만 작동

    • JPA는 기본적으로 1차 캐시에 스냅샷을 저장하고, 트랜잭션 커밋 플러시 하는 시점에 변경 감지가 작동하여 업데이트 쿼리를 발생시킴

    • 근데 reaOnly = true를 열면 CUD 스냅샷 저장, 변경감지를 안 하여 성능 향상 효과가 있음

    • CQRS

      • Command / Query

      • Read 작업이 거의 80%정도 주를 이룰 때가 많음

      • 그래서 command와 query 작업을 분리하여 서로 연관되지 않게끔 분리하는 CQRS 패턴을 활용

      • 조회 하는 서비스만 만들면 @Transactional(readOnly=true)를 써서 관리할 수 있음

        • DB 엔드포인트도 구분하여 쓰기는 마스터, 읽기는 슬레이브로 보내버릴 수 있음

        • 어노테이션 보고 마스터나 슬레이브로 구분해줄 수 잇다함

    • 클래스 단위에은 readOnly를, 변경 메서드엔 @Transactional을 달자

      • 아님 객체 단위로 나눠서 관리

Presentation Layer 테스트(2)

  • Validation을 활욜

    • String

      • @NotBlank: 전부 다 허용 안됨

      • @NotNull: “”, “ “는 통과됨

      • @NotEmpty: “ “ 공백은 통과 “” 빈문자열 실패

    • 정책을 validation에서 거르는게 맞을까? 라는 고민을 해야함

      • EX) 상품 이름은 20자 → 이런건 서비스 레이어나 프러턱트 코드 같은 안쪽에서 해도 됨

  • 상위 레이어는 하위 레이어를 알아도 되지만

  • 하위 레이어는 상위 레이어를 모르게 하는게 좋음

  • Layered Architecture 단점

    • DB랑 연결하기 위해 JPA 도메인 객체를 만들었는데, 너무 강결합 되는 구조가 됨

    • 깊어질 수록 바꾸기가 어려워짐

       

  • Hexagonal Architecture

    • 포트와 어댑터 형태

    • 포트를 통해 외부와 통신을 함

    • 도메인 정책이 가장 안 쪽에 있음

    • 모노레포라면 레이어드 아키텍쳐도 괜찮지만

    • 점점 커질 거 같으면 헥사고날을 고려해보자

  • QueryDSL

    • JPA랑 함께 많이 쓰이는 동적 쿼리 빌더

    • 타입체크를 지원해주서 컴파일단에서 체크해 안전해짐

       

섹션7: Mock을 마주하는 자세

Mockito로 Stubbing 하기

  • 이메일 등 외부 네트워크에 전송하는 서비스 로직에는 Transactional 안 걸어두는게 좋음

    • 트랜잭션으로 DB 조회할때 커넥션을 가지고 있는데, 외부 소통 하면서 갖고 있으면 다른데서 못 가져감

Test Double

  • Stunt Double: 스턴트 배우를 쓰는 것을 차용 → 대역을 사용

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

  • Fake: 단순한 형태로 동일한 기능을 수행하나, 프로덕션에서 쓰기에는 부족한 객체 (ex. FakeRepository - 메모리 Map)

  • Stub: 테스트에서 요청한 것에 대해 미리 준비한 결과를 제공하는 객체. 그 외에는 응답하지 안흠

  • Spy: Stub 이면서 호출된 내용을 기록하여 보여줄 수 있는 객체. 일부는 실제 객체처럼 동작시키고 일부만 Stubbing할 수 있다.

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

  • Stub과 Mock이 좀 헷갈림

    • Stub은 상태 검증 (State Verfication)

    • Mock은 행위 검증(Behavior Verication): 메서드가 무엇을 했을 때(행위) 어떤 값을 돌려주는 느낌~

Mocks Aren't Stubs

public interface MailService {
  void send(Message msg);
}

public class MailServiceStub implements MailService {
	private List<Message> messages = new ArrayList<>();
	public void send(Message msg) {
		messages.add(msg);
	}
	public int numberSent() {
		return messages.size();
	}
}

// Stub 검증
class OrderStateTest {
	@Test
	void testOrderSendsMailIfUnfilled() {
		Order order = new Order(TALISKER, 51);
		MailServiceStub mailer = new MailServiceStub();
		order.setMailer(mailer);
		order.fill(wraehouse);
		assertEquals(1, mailer.numberSent()); // 상태에 대한 검증
	}
}

// Mock 검증
class OrderInteractionTester {
	@Test
	void testOrderSendsMailIfUnfilled() {
		Order order = new Order(TALISKER, 51);
		Mock warehouse = mock(Warehouse.class);
		Mock mailer = mock(MailService.class);
		order.setMailer((MailService) mailer.proxy());
		
		mailer.expects(once()).method("send"); // 메서드가 한 번 불림. 행위 확인
		warehouse.expects(once()).method("hasInventory")
			.withAnyArguments()
			.will(returnValue(false));
			
		order.fill((Warehouse) warehouse.proxy());
	}
}

@Mock, @Spy, @InjectMocks

  • 순수 mockito로 검증하기

BDDMockito

Classicist VS Mockist

  • Mockist: 모든 걸 Mocking 처리해서 하자

  • Classicist: 모킹만 해서 실 프로덕션 환경을 다 커버하긴 어렵다

  • 필요한 경우엔 실제를 쓰다가 필요하면 Mockito를 쓰자

  • 강사님 (Classcisit)

  • Presentaion은 외부에서 오는 값만 검증하고, 하위 레벨은 Mocking

image.png

  • 외부 시스템은 우리에게 제어권이 없기에 이런 경우 (외부 계를 나누자)

image.png

  • 비용이 조금 더 들더라도 실제 객체를 갖고와 테스트를 하는게 안전하지 않을까~

키워드 정리

image.png

  • Bean이 들어가면 스프링 환경에서이니, 단위 테스트로만 Mocking하고 싶으면 Bean이 안 들어간걸 쓰자

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

한 문단에 한 주제!

  • 글쓰기에서 요지는 한 문단엔 한 주제만을 넣음

  • 테스트도 문서로써의 기능을 함

  • 테스트 코드를 글 쓰기의 관점에서 하나의 테스트도 하나의 문단으로 보고, 그에 맞게 처리하자

void containsStockTypeEx() {
  // given
  ProductType[] productTypes = ProductType.values();
  
  for (ProductType productType: productTypes) {
	  if (productType == ProductType.HANNDMADE) {
		  // when
		  boolean result = ProductType.containsStockType(productType);
		  
		  // then
		  assertThat(result).isFalse();
	  }
	  
	  if (productType == ProductType.BAKERY || productType == ProductType.BOTTLE) {
		  // when
		  boolean result = ProductType.containsStockType(productType);
		  
		  // then
		  assertThat(result).isTrue();
	  }
}
  • 위와 같이 if 같은 분기문이 있으면 두 가지 이상의 케이스를 구분하겠단 의미

  • 반복문의 경우에도 테스트 코드를 읽는 사람이 생각을 해야함

  • DisplayName을 한 문장으로 만들게끔 해서 하나의 케이스만 처리하게끔 하자

완벽하게 제어하기

  • 아래와 같이 시간, 랜덤값 같이 내가 제어할 수 없는 값은 외부에서 주입할 수 있도록 해야함

public Order createOrder() {
    LocalDateTime currentDateTime = LocalDateTime.now();
    final LocalTime currentTime = currentDateTime.toLocalTime();
    if (currentTime.isBefore(SHOP_OPEN_TIME) || currentTime.isAfter(SHOP_CLOSE_TIME)) {
        throw new IllegalArgumentException("주문 시간이 아닙니다. 관리자에게 문의하세요.");
    }

    return new Order(LocalDateTime.now(), beverages);
}

// 외부에서 주입
public Order createOrder(LocalDateTime currentDateTime) {
    final LocalTime currentTime = currentDateTime.toLocalTime();
    if (currentTime.isBefore(SHOP_OPEN_TIME) || currentTime.isAfter(SHOP_CLOSE_TIME)) {
        throw new IllegalArgumentException("주문 시간이 아닙니다. 관리자에게 문의하세요.");
    }

    return new Order(LocalDateTime.now(), beverages);
}
  • 그렇다고 LocalDateTime registeredDateTime = LocalDateTime.now() 테스트 코드에서 현재 시각을 주는 메서드를 막 쓰지는 말자 (환경마다 다를 수 있으니)

  • 시각을 LocalDateTime.of() 같은 시각을 지정해줄 수 있도록 원칙을 만드는 것도 좋음

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

@DisplayName("재고가 부족한 상품으로 주문을 생성하려는 경우 예외가 발생한다.")
@Test
void createOrderWithNoStock() {
    // given
    final 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", 2);
    Stock stock2 = Stock.create("002", 2);
    stock1.deductQuantity(1); // todo
    stockRepository.saveAll(List.of(stock1, stock2));

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

    // when // then
    assertThatThrownBy(() -> orderService.createOrder(request.toServiceRequest(), registeredDateTime))
        .isInstanceOf(IllegalArgumentException.class)
        .hasMessage("재고가 부족한 상품이 있습니다.");
}
  • deductQuantity 부분으로 인해 논리적으로 한 번 더 생각해서 테스트를 짜게 된다

  • 테스트는 when, then에 집중해야는데, given도 보면서 혼란스러워짐

  • given은 순수 생성자 기반의 값을 주는게 좋음 or Builder

    • 팩토리 메서드도 지양하는게 좋음 (팩토리 메서드 내에 의도를 가지고 뭔가 로직이 있을 수 있기에)

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

class StockTest {
	private static final Stock stock = Stock.create("001", 1);

	@DisplayName("재고의 수량이 제공된 수량보다 작은지 확인한다.")
	@Test	
	void isQuantityLessThanEx() {
		// given
		int quantity = 2;
		
		// when
		boolean result = stock.isQuantityLessThan(quantity);
		
		// then
		assertThat(result).isTrue();
	}
	
	@DisplayName("재고를 주어진 개수만큼 차감할 수 있다.")
	@Test	
	void deductQuantityEx() {
		// given
		int quantity = 1;
		
		// when
		stock.deductQuanaity(quantity);
		
		// then
		assertThat(stock.getQuanaity()).isZero();
	}
}
  • 두 가지 테스트가 static 변수 같은 공유 자원을 활용하고 있음

  • 공유 자원의 값이 변경되면 다른 테스트 결과에 영향이 생김

  • 테스트 수행 순서는 랜덤하기에 독립적으로 항상 올바를 테스트가 되도록 해줘야함

    • DynamicTest를 쓰면 지정해줄 수 있나봄

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

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

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

  • 활용 어노테이션

    • @BeforeAll

    • @BeforeEach

    • @AfterAll

    • @AfterEach

  • 픽스처를 통해 공통의 테스트 객체를 만들면 하나의 테스트를 바꿀 때 모두 영향이 끼치기에 지양하는 것이 좋음

  • 테스트클래스가 엄청 길어진 경우 given 절을 보는데 문맥을 기억하기 어려워짐 (→ 문서 파악이 어려워짐)

  • 사용해도 괜찮을 때

    • 각 테스트 입장에서 봤을 때 : 아예 몰라도 테스트 내용을 이해하는 데에 문제가 없는 경우에

    • 수정해도 모든 테스트에 영향을 주지 않는가

  • data.sql에서 given 데이터를 만들 수 있겠지만, 데이터 파편화가 될 가능성이 큼 (비추)

    • 테이블도 많아지고, 사이즈가 커질 수록 관리가 어려워짐

  • 생성 메서드를 만들 때 테스트에 필요한 파라미터만 받아서 쓰자

    • name 필드가 필요없으면 파라미터에서 빼버림

  • 테스트 패키지 전체에서 사용하는 추상 클래스를 만들어 픽스처 빌더들을 모아 쓸 순 있겠지만 NO 추천

    • 파라미터가 엄청 많아질 때 마다 내가 필요한 빌더들이 생기고, 관리가 더 안 될 수도 있음

    • 코틀린을 사용하면 롬복도 필요없고, 빌더도 필요없음. 기본값을 지정해줄 수 있음

Text Fixture 클렌징

  • deleteAll: select 해서 건건이 where id 조회해서 지움

    • 연관 관계 걸려있는 것도 찾아서 같이 지어줌

    • 전체 찾아서 순회해서 지워주고 있음

    • 삭제도 일종의 비용이기 때문에 테스트에서 오래걸리면 좀 그럴 수 있음

@Transactional
public void deleteAll() {
    Iterator var2 = this.findAll().iterator();

    while(var2.hasNext()) {
        T element = (Object)var2.next();
        this.delete(element);
    }

}
  • deleteAllInBatch

    • 전체 삭제 쿼리를 만들어서 지어줌 → 벌크성으로 지워줌

@Transactional
public void deleteAllInBatch() {
    Query query = this.entityManager.createQuery(this.getDeleteAllQueryString());
    this.applyQueryHints(query);
    query.executeUpdate();
}

private String getDeleteAllQueryString() {
    return QueryUtils.getQueryString("delete from %s x", this.entityInformation.getEntityName());
}
  • @Transactional을 쓰면 되는데, 사이드 이펙트를 잘 고려해서 진행해야함

@ParameterizedTest

  • 반복되는 given에 대해서 위 어노테이션을 활용해서 반복해서 할당할 수 있다.

@DisplayName("상품 타입이 재고 관련 타입인지를 체크한다.")
@CsvSource({
        "HANDMADE, false",
        "BOTTLE, true",
        "BAKERY, true"
})
@ParameterizedTest
void containsStockType4(ProductType productType, boolean expected) {
    // when
    boolean result = ProductType.containsStockType(productType);

    // then
    assertThat(result).isEqualTo(expected);
}

private static Stream<Arguments> provideProductTypesForCheckingStockType() {
    return Stream.of(
            Arguments.of(ProductType.HANDMADE, false),
            Arguments.of(ProductType.BOTTLE, true),
            Arguments.of(ProductType.BAKERY, true)
    );
}

@DisplayName("상품 타입이 재고 관련 타입인지를 체크한다.")
@MethodSource("provideProductTypesForCheckingStockType")
@ParameterizedTest
void containsStockType5(ProductType productType, boolean expected) {
    // when
    boolean result = ProductType.containsStockType(productType);

    // then
    assertThat(result).isEqualTo(expected);
}

@DynamicTest

  • 공통의 값으로 단계 별로 테스트를 돌리고 싶으면 사용한다

@DisplayName("")
@TestFactory
Collection<DynamicTest> dynamicTest() {
    
    return List.of(
            DynamicTest.dynamicTest("", () -> {}),
            DynamicTest.dynamicTest("", () -> {})
    );
}

@DisplayName("재고 차감 시나리오")
@TestFactory
Collection<DynamicTest> stockDeductionDynamicTest() {
    // given
    Stock stock = Stock.create("001", 1);

    return List.of(
        DynamicTest.dynamicTest("재고를 주어진 개수만큼 차감할 수 있다.", () -> {
            // given
            int quantity = 1;
            
            // when
            stock.deductQuantity(quantity);
            
            // then
            assertThat(stock.getQuantity()).isZero();
        }),
        DynamicTest.dynamicTest("재고보다 많은 수의 수량으로 차감 시도하는 경우 예외가 발생한다", () -> {
            // given
            int quantity = 1;
            
            // when // then
            assertThatThrownBy(() -> stock.deductQuantity(quantity))
                    .isInstanceOf(IllegalArgumentException.class)
                    .hasMessage("차감할 재고 수량이 없습니다.");
        })
    );
}

테스트 수행도 비용이다. 환경 통합하기

  • 테스트를 작성하는 이유는 사람이 수동으로 돌리는 비용보다 기계에 맡겨서 피드백을 빨리 받게 하기 위함

  • 테스트의 속도가 빨라야 유의미하기에 비용관리를 해야함

  • 서버 띄우는 경우가 많아지면 서버가 오래 걸리게 됨

    • 이런 비용을 관리해야함

  • @MockBean 의 경우에도 서버를 새로 띄어야됨

@WebMvcTest(controllers = {
        OrderController.class,
        ProductController.class
})
public abstract class ControllerTestSupport {

    @Autowired
    protected MockMvc mockMvc;

    @Autowired
    protected ObjectMapper objectMapper;

    @MockBean
    protected OrderService orderService;

    @MockBean // Mock 객체 만들어줌
    protected ProductService productService;
}

@ActiveProfiles("test")
@SpringBootTest
public abstract class IntegrationTestSupport {

    @MockBean
    protected MailSendClient mailSendClient;
}

Q. private 메서드의 테스트는 어떻게 하나요?

  • 하려고 해서도 안 되고 할 필요가 없다.

  • 클라이언트(외부)는 공개된 API만 알면 되기에, private을 알 필요 없다

  • 만약 private 메서드를 단독으로 빼서 테스트하고 싶다면, 객체를 분리할 시점인가를 고민해봐야 한다

  • 필요하다면 새로운 객체를 만들어 책임을 위임하면 된다

Q. 테스트에서만 필요한 메서드가 생겼는데 프로덕션 코드에서는 필요 없다면?

  • 만들어도 됨. 하지만 보수적으로 접근하기

  • 테스트에서 조회성을 위해 만드는 거 정돈 괜찮지만, 테스트에서만 사용되는 메서드를 막 만드는 것은 지양해야함

  • getter, 생성자, 생성자 빌더, 사이즈 등 객체가 마땅히 가져도 되는 행위라 생각되고 미래에 충분히 사용되는 것들은 만들어도 괜찮다~

 

학습 테스트

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

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

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

Spring REST Docs

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

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

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

REST Docs VS SWAGGER

  • REST DOCS

  • 장점

    • 테스트를 통과해야 문서가 만들어진다 (신뢰도가 높음)

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

  • 단점

    • 코드 양이 많다.

    • 설정이 어렵다

  • SWAGGER

  • 장점

    • 적용이 쉬움

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

  • 단점

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

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

댓글을 작성해보세요.

채널톡 아이콘