inflearn logo
강의

강의

N
챌린지

챌린지

멘토링

멘토링

N
클립

클립

로드맵

로드맵

지식공유

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

홍석주
0

후기

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

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

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

만들어야겠음을 느꼈다.

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

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

 

내용 정리

Presentation Layer 테스트(1)

Presentation Layer 테스트(2)

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

Mockito로 Stubbing 하기

Test Double

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

BDDMockito

Classicist VS Mockist

image.png

image.png

키워드 정리

image.png

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

완벽하게 제어하기

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

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

@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("재고가 부족한 상품이 있습니다.");
}

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

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

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

Text Fixture 클렌징

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

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

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

@ParameterizedTest

@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("차감할 재고 수량이 없습니다.");
        })
    );
}

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

@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 메서드의 테스트는 어떻게 하나요?

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

 

학습 테스트

Spring REST Docs

REST Docs VS SWAGGER

백엔드

답변 0