계층 구조의 테스트 코드 작성하기

라모스·2023년 4월 18일
0

나는 주로 Given-When-Then 패턴으로 테스트 코드를 작성하는데, JUnit5의 @Nested 애노테이션을 기반으로 계층 구조의 테스트 코드를 짜는 방법을 학습해보고자 정리해본다.

Describe-Context-It 패턴

이 패턴은 코드의 행동을 설명하는 테스트 코드를 작성한다.

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 함수는,

  • 입력으로 3과 2가 주어지면,
    • 5을 리턴한다.
  • 입력으로 -3과 2가 주어지면,
    • -1을 리턴한다.

JUnit5의 @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 메소드 사용

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(계산된_요금);
                    }
                }
            }
        }
		//...
    }
    
    //...

References

profile
Step by step goes a long way.

0개의 댓글