나는 주로 Given
-When
-Then
패턴으로 테스트 코드를 작성하는데, JUnit5의 @Nested
애노테이션을 기반으로 계층 구조의 테스트 코드를 짜는 방법을 학습해보고자 정리해본다.
이 패턴은 코드의 행동을 설명하는 테스트 코드를 작성한다.
BDD 테스트 코드 패턴으로 알려진 Given
-When
-Then
과 비슷한 철학을 갖고 있지만 미묘하게 다르다.
Describe
-Context
-It
은 상황을 설명하기보단 테스트 대상을 주인공 삼아 행동을 더 섬세하게 설명하는 데 적합하다.
키워드 | 설명 |
---|---|
Describe | 설명할 테스트 대상을 명시. 테스트 대상이 되는 클래스, 메소드 이름을 명시. |
Context | 테스트의 대상이 놓인 상황을 설명. 테스트할 메소드에 입력할 파라미터를 설명. |
It | 테스트 대상의 행동을 설명. 테스트 대상 메소드가 무엇을 리턴하는지 설명. |
Context
문을 작성할 때는 반드시 with
또는 when
으로 시작하도록 한다.It
구문은 It returns true
, It responses 404
와 같이 심플하게 설명할수록 좋다.이 방식은 다음과 같은 장점이 있다고 한다.
보통 다른 언어의 D-C-I
패턴을 지원하는 BDD 테스트 프레임워크에서는 다음과 같은 형태의 테스트 코드를 작성하게 된다.
Describe("Sum", func() {
Context("With 3 and 2", func() {
It("returns 5", func() {
Expect(Sum(3, 2)).To(Equal(5))
})
})
Context("With -3 and 2", func() {
It("returns -1", func() {
Expect(Sum(-3, 2)).To(Equal(-1))
})
})
})
Sum 함수는,
@Nested
애노테이션@Nested
애노테이션을 사용하면 중첩 클래스에 테스트 메서드를 추가할 수 있다. 전형적인 예시는 다음과 같다.
import org.junit.jupiter.api.Nested;
public class Outer {
@BeforeEach
void outerBefore() {}
@Test
void outer() {}
@AfterEach
void outerAfter() {}
@Nested
class NestedA {
@BeforeEach
void nestedBefore() {}
@Test
void nested() {}
@AfterEach
void nestedAfter() {}
}
}
위 코드를 기준으로 중첩 클래스의 테스트 메서드인 nested()
를 실행하는 순서는 다음과 같다.
Outer
객체 생성NestedA
객체 생성outerBefore()
메서드 실행nestedBefore()
메서드 실행nested()
테스트 실행nestedAfter()
메서드 실행outerAfter()
메서드 실행중첩된 클래스는 내부 클래스이므로 외부 클래스의 멤버에 접근할 수 있다. 이 특징을 활용하면 상황별로 중첩 테스트 클래스를 분리해서 테스트 코드를 구성할 수 있다.
public class UserServiceTest {
private MemoryUserRepository userRepository;
private UserService userService;
@BeforeEach
void init() {
userRepository = new MemoryUserRepository;
userService = new UserService(userRepository);
}
@Nested
class GivenUser {
@BeforeEach
void givenUser {
userRepository.save(new User("user", "name"));
}
@Test
void dupId() {
// ...
}
}
@Nested
class GivenNoDupId {
// ...
}
}
Java는 메소드 내부에 메소드를 곧바로 만들 수 없다. inner class를 사용하면 시각적으로 계층적인 테스트 코드를 작성하는 것은 가능하다.
JUnit4는 inner class로 작성한 테스트 코드를 직접 지원하지 않는다는 문제가 있지만, JUnit5는 @Nested
를 사용해 계층 구조의 테스트 코드를 작성할 수 있다.
다음은 조영호 님의 '오브젝트'라는 책을 공부하며 예제 코드를 테스트 코드로 작성했던 것의 일부이다.
@SuppressWarnings({"InnerClassMayBeStatic", "NonAsciiCharacters"})
@DisplayName("Movie class")
class MovieTest {
//...
@Nested
@DisplayName("changeDiscountPolicy 메소드는")
class Describe_changeDiscountPolicy {
@Nested
@DisplayName("주어진 영화가 '스타워즈'일 때 (할인 조건 없음)")
class Context_with_starwars {
Movie givenMovie() {
return given_스타워즈();
}
@Nested
@DisplayName("'아바타'의 할인 정책이 주어지면")
class Context_with_avatar_discount_policy {
final DiscountPolicy givenDiscountRate = given_아바타_할인정책;
@Test
@DisplayName("주어진 할인 정책으로 할인 정책을 교체하고 void를 리턴한다.")
void it_changes_discount_policy() {
final Movie 스타워즈 = givenMovie();
final Money 기본_요금 = 스타워즈.getFee();
스타워즈.changeDiscountPolicy(givenDiscountRate);
{
/* 변경된 할인 정책으로 할인이 되는지 확인한다. */
final int 아바타_할인_조건_순번 = 1;
final Screening 상영 = new Screening(스타워즈, 아바타_할인_조건_순번, givenSundayAfternoon);
final Money 계산된_요금 = 스타워즈.calculateMovieFee(상영);
assertThat(기본_요금.minus(givenFixedDiscountFee)).as("할인되지 않는 스타워즈의 요금이 아바타의 정책으로 할인된다")
.isEqualTo(계산된_요금);
}
}
}
@Nested
@DisplayName("'타이타닉'의 할인정책이 주어지면")
class Context_with_starwars_policy {
final DiscountPolicy givenDiscountPolicy = given_타이타닉_할인정책;
@Test
@DisplayName("주어진 할인 정책으로 할인정책을 교체하고 void를 리턴한다")
void it_changes_the_discount_policy() {
final Movie movie = givenMovie();
final Money 기본_요금 = movie.getFee();
movie.changeDiscountPolicy(givenDiscountPolicy);
{
/* 변경된 할인 정책으로 할인이 되는지 확인한다. */
final int 타이타닉_할인_조건_순번 = 2;
final Screening 상영 = new Screening(movie, 타이타닉_할인_조건_순번, givenSundayAfternoon);
final Money 계산된_요금 = movie.calculateMovieFee(상영);
assertThat(기본_요금.times(1 - givenDiscountRate)).as("할인되지 않는 스타워즈의 요금이 타이타닉의 정책으로 할인된다")
.isEqualTo(계산된_요금);
}
}
}
}
}
}
subject
메소드는 테스트 대상을 실행하는 코드를 캡슐화 하는 역할을 한다. 이를 통해 테스트 대상이 되는 코드의 시그니처가 변경되었을 때 여러 개의 테스트가 줄줄이 깨져나가는 상황을 쉽게 고칠 수 있다.
특히 테스트 코드가 많이 딸린 복잡한 코드의 테스트에서 빛을 발한다.
또한, 검사해야 할 조건이 많아 Context
나 비슷한 테스트가 지루하게 반복될 경우 공통되는 핵심 부분만 뽑아낸 Context
클래스를 상속하여 사용할 수도 있다.
해당 테스트의 주제와 관련있는 조건부만 재정의하는 방식으로 사용하면 된다.
abstract class TestCalculateMovieFee {
abstract Movie givenMovie();
Money 기본요금() {
return givenMovie().getFee();
}
Money subject(Screening screening) {
return givenMovie().calculateMovieFee(screening);
}
}
@Nested
@DisplayName("calculateMovieFee 메소드는")
class Describe_calculateMovieFee {
@Nested
@DisplayName("주어진 영화가 '아바타'일 때 (할인 조건: 상영 시작 시간, 상영 순번 / 할인 금액: 고정 금액)")
class Context_with_avatar extends TestCalculateMovieFee {
@Override
Movie givenMovie() {
return given_아바타();
}
@Nested
@DisplayName("상영 시작 시간이 할인 조건에 맞는다면")
class Context_with_valid_period {
final List<LocalDateTime> 할인_조건에_맞는_상영_시작_시간들 = List.of(
// edge cases - 월요일
givenMonday.withHour(10).withMinute(0),
givenMonday.withHour(11).withMinute(59),
// inner cases - 월요일
givenMonday.withHour(10).withMinute(1),
givenMonday.withHour(11).withMinute(58),
// edge cases - 목요일
givenThursday.withHour(10).withMinute(0),
givenThursday.withHour(11).withMinute(59),
// inner cases - 목요일
givenThursday.withHour(10).withMinute(1),
givenThursday.withHour(11).withMinute(58)
);
List<Screening> givenScreens() {
return 할인_조건에_맞는_상영_시작_시간들.stream()
.map(상영시간 -> new Screening(givenMovie(), 0, 상영시간))
.collect(Collectors.toList());
}
@Test
@DisplayName("고정할인 금액만큼 할인된 금액을 리턴한다.")
void it_returns_discounted_fee() {
for (Screening 할인되는_시간에_시작하는_상영 : givenScreens()) {
final Money 계산된_요금 = subject(할인되는_시간에_시작하는_상영);
assertThat(기본요금().minus(givenFixedDiscountFee)).isEqualTo(계산된_요금);
}
}
}
@Nested
@DisplayName("상영 시작 시간이 할인 조건에 맞지 않는다면")
class Context_with_invalid_period {
final List<LocalDateTime> 할인_조건에_맞지_않는_상영_시작_시간들 = List.of(
// 월요일
givenMonday.withHour(9).withMinute(59),
givenMonday.withHour(12).withMinute(0),
// 목요일
givenThursday.withHour(9).withMinute(59),
givenThursday.withHour(21).withMinute(0),
// 그 외의 요일
givenTuesday.withHour(10).withMinute(0),
givenTuesday.withHour(10).withMinute(1),
givenTuesday.withHour(10).withMinute(30)
);
List<Screening> givenScreens() {
return 할인_조건에_맞지_않는_상영_시작_시간들.stream()
.map(상영시간 -> new Screening(givenMovie(), -1, 상영시간))
.collect(Collectors.toList());
}
@Test
@DisplayName("할인되지 않은 금액을 리턴한다.")
void it_returns_fee_does_not_discounted() {
for (Screening 할인되는_시간에_시작되는_상영 : givenScreens()) {
final Money 계산된_요금 = subject(할인되는_시간에_시작되는_상영);
assertThat(기본요금()).isEqualTo(계산된_요금);
}
}
}
}
//...
}
//...