[박우빈, Practical Testing #7]더 나은 테스트를 작성하기 위한 구체적 조언

dev_lee·2024년 12월 4일
post-thumbnail

한 문단에 한 주제!

테스트는 문서로서의 기능
테스트 코드라는 것을 글쓰기로 비유했을 때 각각 하나하나의 테스트가 한 문단이며, 하나의 주제만 가져야 한다.

  • 한 가지 테스트에서는 한 가지 목적의 검증만 수행한다.
  • 디스플레이 네임을 한 문장으로 구성할 수 있도록 한다.
  • 반복문이나 분기문 같은 논리 구조들이 존재하지 않도록 명확하게 구성한다.

완벽하게 제어하기

제어할 수 없는 값들을 상위 계층으로 분리하여 테스트 가능한 구조로 만드는 것.

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

테스트 환경의 given절을 구성할 때는 최대한 생성자 기반으로 구성해라

given절에 api 메서드 사용 시 테스트 검증의 목적인 when, then절이 아닌 given절에서 예외가 발생해 테스트가 실패한 이유를 유추하기 어렵게 만드는 포인트가 될 수 있다.

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

공유 자원을 사용하지 마라 (ex. static한 변수)

기본적으로 테스트 간에는 순서라는게 없어야 하고 각각 독립적으로 언제 수행되든 항상 같은 결과를 내는것이 올바른 테스트 코드이다.

두 가지 이상의 테스트가 하나의 자원을 공유하게 되면 테스트 간 순서가 생기게 되고 테스트 순서에 따라 테스트의 성공 실패 여부가 갈리게 된다.

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

Test Fixture

테스트를 위해 원하는 상태로 고정시킨 일련의 객체.
given절에서 생성했던 모든 객체들을 의미한다.

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

한 눈에 들어오는 Test Fixture를 구성하는 4가지 방법

1. setUp()에 Test Fixture 작성을 지양하자.

@BeforeEach : 각 테스트 메서드가 실행되기 전에 반드시 실행되어야 하는 작업을 정의할 때 사용하는 어노테이션

@BeforeEach
void setUp() {
	
}

Q. 하나의 클래스에서 여러 테스트를 작성할 때 given 데이터가 중복되는 경우가 다수 발생한다. 이때, setUp() 메서드에 Test Fixture들을 작성해도 될까?

A. setUp()에 작성한 Test Fixture들은 공유 자원과 동일한 효과를 낸다. 테스트 메서드와의 결합이 생기므로 지양하는 것이 좋다.

  • 각 테스트 입장에서 봤을 때 아예 몰라도 테스트 내용을 이해하는 데에 문제가 있는가?
  • 수정해도 모든 테스트에 영향을 주지 않는가?

위 질문을 만족하는 Test Fixture라면 setUp() 메서드 안에 작성 해도 되고, 아니라면 각 테스트 메서드에 given절에 작성하자.

2. data.sql 같은 다른 파일에서 Test Fixture를 셋업하는 것을 지양하자.

데이터 파편화가 발생하므로 권장하지 않는다.

3. 빌더 생성 시 필요한 파라미터만 명시하여 명확하게 하자.

4. 하나의 파일에 Test Fixture 빌더들을 모아놓고 불러와서 사용하는 것을 지양하자.

Q. 하나의 추상 클래스를 만들어서 Test Fixture 빌더들을 모아놓고 불러와서 사용해도 될까?

A. 테스트마다 필요한 빌더 파라미터가 다 다르므로 파라미터 수십 개에 대해서 각기 다른 빌더가 계속 생성되므로 관리가 힘들다.

그러므로 테스트 클래스마다 필요한 파라미터만 뽑아서 빌더를 생성하여 사용하는 것을 권장한다.

Test Fixture 클렌징

deleteAll(), deleteAllInBatch() 차이

deleteAllInBatch()

DELETE 문을 실행하여 해당 테이블의 모든 데이터를 삭제한다.

외래 키 제약 조건이 있는 경우, 외래 키를 가지고 있는 테이블(Child)의 데이터를 먼저 삭제한 후 그 외래 키가 참조하는 테이블(Parent)의 데이터를 삭제해야 한다.

deleteAll()

SELECT 쿼리를 통해 삭제할 엔티티를 조회한 후, 엔티티 단위로 하나씩 삭제한다.

외래 키 제약 조건이 있는 경우
1. Parent 테이블의 엔티티를 하나씩 조회한다.
2. 각 Parent 엔티티에 연결된 Child 테이블 엔티티를 삭제한다.
3. Parent 엔티티를 삭제한다.

삭제할 데이터가 많을수록 성능이 떨어진다는 단점이 있다.

deleteAllInBatch(), deleteAll()중 뭘 사용하는게 좋을까?
테스트 비용 고려 시 순서를 잘 고려하여 deleteAllInBatch()룰 사용하는 것을 권장한다.

+) @Transactional

@Transactional 롤백도 테스트 시 Test Fixture를 클렌징 하는 역할을 하지만 해당 어노테이션 사용의 side effect을 잘 고려해야하며, Spring Batch 통합 테스트 시 여러 트랜잭션의 경계가 참여하므로 이럴 떈 deleteAllInBatch()를 사용하여 수동으로 삭제하는 것을 권장한다.

그때 그때 상황에 맞게 deleteAllInBatch(), @Transactional를 혼용해서 사용하자.

@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);
}
  • @Test 어노테이션 대신 @ParameterizedTest 어노테이션을 선언하기
  • 다양한 데이터 제공자(ex. @ValueSource, @CsvSource, @MethodSource)를 사용해 입력 데이터를 설정하기

자세한 사용법은 junit5 공식 문서 - 2.17. Parameterized Tests 단락에서 확인 가능하다.

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("차감할 재고 수량이 없습니다.");
		})
	);
}
  • 메서드 선언부에 DynamicTest를 반환하는 메서드를 정의할 때 사용하는 @TestFactory 어노테이션을 작성한다.
  • DynamicTest.dynamicTest 메서드를 사용해 개별 테스트를 정의한다.

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

전체 테스트 수행하기
Gradle > 프로젝트 > Tasks > Varification > test

전체 테스트 수행 시 spring boot의 서버가 여러번 뜬다. 서버가 뜨는 횟수가 많아진다면 전체 테스트 수행 시간이 길어지게 된다.

같은 @SpringBoot 어노테이션이 붙은 테스트라도 프로파일 지정 차이라던가 환경이 조금이라도 달라지면 서버가 별도로 뜬다.

환경을 맞춰 동일한 환경에서 테스트들이 모두 수행될 수 있도록 공통적인 환경들을 모아주면 서버가 뜨는 횟수를 단축시킬 수 있다.

  • 테스트 패키지 하위에 환경 통합을 위한 추상 클래스 생성
// 중략.. 
@ActiveProfiles("test")
@SpringBootTest
public abstract class IntegrationTestSupport {
	// 중략.. 
}
  • 테스트 클래스에서 위에서 생성한 추상 클래스를 상속
// 중략..
class ProductServiceTest extends IntegrationTestSupport {
	// 중략..
}

Private 메서드의 테스트는 어떻게 하나요?

할 필요가 없다.

테스트 코드에서 (Public 메서드에서 파생된) Private 메서드를 테스트하지 않아도 Public 메서드의 테스트를 하다보면 자동으로 Private 메서드의 테스트가 수행되고 검증이 된다.

그럼에도 Private 메서드를 테스트하고 싶다면 객체를 분리할 시점인가? 고민을 해봐야 한다.

Public 메서드의 책임, Private 메서드의 책임을 별개로 보고 객체를 분리한다면 Private 메서드의 테스트를 수행할 수 있다.

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

만들어도 된다. 하지만 보수적으로 접근하기!

테스트에서만 사용되는 메서드를 막 만들어내는 것은 당연히 지양하는게 맞지만 무엇을 테스트하고 있는지를 명확하게 인지하고 테스트하는데 꼭 필요한 메서드라면 만들어서 사용하자.


출처 - 박우빈, Practical Testing: 실용적인 테스트 가이드
이 블로그에 포함된 모든 코드와 이미지는 원작자이신 박우빈 강사님의 저작권에 귀속됩니다.

0개의 댓글