TDD Tips (1)

qkdk·2024년 2월 6일
0

TDD

목록 보기
11/12

한 문단에 한 주제

테스트 코드 하나에는 하나의 주제가 있어야 한다.

  • 반복문이나 분기와 같은 논리구조가 나타나지 않아야 한다.
  • ~ 하나의 DisplayName으로 만들수가 있는가?

잘못된 코드

    @DisplayName("상품 타입이 재고 관련 타입인지를 체크한다.")
    @Test
    void containStockType() {
        // given
        ProductType[] productTypes = ProductType.values();

        for (ProductType productType : productTypes) {
            if (productType == ProductType.HANDMADE) {
                //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();
            }
        }
    }
  • for 문과 if문을 활용해 여러가지 상황의 ProductType을 검사하고 있다.

수정된 코드

    @DisplayName("상품 타입이 재고 관련 타입인지를 체크한다.")
    @Test
    void containsStockType() {
        // given
        ProductType givenType = ProductType.HANDMADE;

        // when
        boolean result = ProductType.containsStockType(givenType);

        // then
        assertThat(result).isFalse();
    }

    @DisplayName("상품 타입이 재고 관련 타입인지를 체크한다.")
    @Test
    void containsStockType2() {
        // given
        ProductType givenType = ProductType.BAKERY;

        // when
        boolean result = ProductType.containsStockType(givenType);

        // then
        assertThat(result).isTrue();
    }

완벽하게 제어하기

개발자가 제어하기 힘든 문법을 사용해 테스트코드를 작성하는것을 지양한다.
ex) LocalDateTime.now(), Random <- 개발자가 제어할수 없음

    @DisplayName("주문 생성 시 주문 등록 시간을 기록한다.")
    @Test
    void registeredDateTime() {
        // given
        LocalDateTime registeredDateTime = LocalDateTime.now();
        List<Product> products = List.of(
                createProduct("001", 1000),
                createProduct("002", 2000)
        );

        // when
        Order order = Order.create(products, registeredDateTime);

        // then
        assertThat(order.getRegisteredDateTime()).isEqualTo(registeredDateTime);
    }
  • LocalDateTime.now() 는 개발자가 제어할 수 없다.
    • 위 코드에서 문제는 없지만, 지속적으로 코드가 바뀌는 현업 상황과 협업을 고려할때 사용하지 않는것이 좋다.
    public Order createOrder() {
        LocalDateTime currentDateTime = LocalDateTime.now();
        LocalTime currentTime = currentDateTime.toLocalTime();
        if (currentTime.isBefore(SHOP_OPEN_TIME) || currentTime.isAfter(SHOP_CLOSE_TIME)) {
            throw new IllegalArgumentException("주문 시간이 아닙니다. 관리자에게 문의하세요.");
        }

        return new Order(currentDateTime, beverages);
    }

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

        return new Order(currentDateTime, beverages);
    }
  • 코드내에 제어하기 힘든 문법이 있는 경우 외부요소로 분리한다.

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

테스트 환경에 다른 API, 의존성을 가지는것을 지양

    @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", 2);
        Stock stock2 = Stock.create("002", 2);
        stock1.deductQuantity(1); // todo
        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("재고가 부족한 상품이 있습니다.");
    }
  • 논리적 사고의 과정이 생긴다.
    • stock.deductQuantity(1) 하는 과정에서 두개의 Stock이 생겼고, 그중 하나를 뺐다 라는 과정을 이해해야한다.
  • 테스트가 실패하는 곳은 when, then 부분이어야 한다.
    • stock1.deductQuantity(3)를 할경우 stock부족으로 오류가 발생하나, 발생 시점은 given이다.
    • 추후 테스트중 문제가 발생했을때 유츄하기 힘들다.

해결방법

순수한 생성자, 혹은 빌더를 기반으로 객체를 생성하고 Given절을 구성한다.

  • 정적 팩토리 메서드도 지양하는것이 좋다.
    • Production 코드에서 의도를 가지고 구현한것이기 때문

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

서로다른 테스트코드간 영향을 주어선 안된다.

  • 테스트간 순서가 없어야 한다. 각각 언제 수행되든 같은 결과를 도출해야 한다.
  • 테스트 코드 밖의공유자원 사용을 지양해야한다.

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

Test Fixture

  • 테스트를 위해 원하는 상태로 고정시킨 일련의 객체
  • Fixture: 고정물, 고정되어 있는 물체

    주로 Given절을 다룰때 사용된다.

문제상황

    @DisplayName("재고와 관련된 상품이 포함되어 있는 주문번호 리스트를 받아 주문을 생성한다.")
    @Test
    void createOrderWithStock() {
        // 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));
	...
	}
    
    @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);
	...
    }
  • given절에 동일한 코드가 중복되어 나타난다.

@BeforeAll, @BeforeEach

공유 자원을 사용하지 않고도 중복된 given절을 줄일수 있다.

    @BeforeAll
    static void beforeAll() {

    }

    @BeforeEach
    void setUp() {

    }

유의할점

  1. 테스트 코드는 문서다. 파편화된 코드는 이해하기 힘들어진다.
    • 각 테스트 코드 입장에서 봤을 때, SetUp()의 내용을 몰라도 코드를 이해할 수 있어야 한다.
    • Data.sql처럼 기존 데이터를 외부에서 주입받는 경우도, 코드를 이해하기 힘들어 사용을 지양해야 한다.
      • 관리에도 어렵다.
  2. 수정해도 모든 테스트에 영향을 주지 않아야 한다.

추천하는 방법

  • 메서드 분리를 진행하자
    • 필요한 인자만 받는 메서드를 생성

Test Fixture 클렌징

tearDown을 진행해 repository를 롤백하는 방법은 3개가 있다.
1. @Transactional
2. deleteAll
3. deleteAllInBatch

deleteAllInBatch VS deleteAll

deleteAllInBatch

  • 단일 쿼리로 엔티티정보 전부 삭제
  • deleteAll에 비해서 성능이 좋다.
  • 외레키 제약조건에 의해서 delete시 오류가 발생할 수 있어 순서가 중요하다.

deleteAll

  • select과정을 거쳐서 각각의 엔티티를 하나씩 삭제한다.
  • 엔티티에서 매핑이 되어있다면, 매핑된 엔티티도 삭제한다.
    • deleteAllInBatch보다 순서에 덜 민감한 이유
    • 하지만 테이블끼린 제약조건이 걸려있지만, 엔티티에서 매핑이 되어있지 않는경우 Jpa가 확인할수 없어 오류가 발생한다.
  • deleteAllInBatch에 비해 성능이 좋지 않다.

Jpa에서의 deleteAll 구현 코드

	@Override
	@Transactional
	public void deleteAll() {

		for (T element : findAll()) {
			delete(element);
		}
	}

추천방법

  • 프로젝트 설계, 환경에따라 3가지 방법 모두 사용이 가능
  • 테스트도 비용, 가능하면 빠른 deleteAllInBatch를 사용하는것을 추천
profile
qkdk

0개의 댓글

관련 채용 정보