JUnit5로 단위 테스트하기

2021년 11월 27일

TOC

Unit Test란?

Unit test는 프로그래밍을 할 때 소스코드의 특정 모듈(메서드)이 의도된 대로 정확히 작동하는지 검증하는 절차입니다. 다시 말해 작성한 모든 메서드들에 대해서 테스트케이스를 작성하는 것을 의미합니다.

Unit Test의 장점

Unit Test를 진행하게 된다면 하나의 기능을 독립적으로 테스트를 하며 코드 변경으로 인해 문제가 발생하여도 짧은 시간안에 해당 문제를 파악할 수 있습니다.

  • 새로운 기능 추가 시 수시로 빠르게 테스트 할 수 있다.
  • 리팩토링 시에 안정성을 확보할 수 있다.
  • 테스팅에 대한 시간과 비용을 절감할 수 있다.
  • 코드에 대한 문서가 될 수 있다.

이러한 장점들이 있어 실무에서는 Unit Test를 선호하고 있다고 합니다.

좋은 Unit Test를 하는 방법

  • 1개의 테스트 함수에 대해서는 assert를 최소화해야한다.

    • 그래야지 어느 assert에서 오류가 발생했는지 확인할 수 있다.
  • 1개의 테스트 함수에는 1가지 개념만을 테스트하여야 한다. (최소한의 단위로 테스트를 진행!!!)

좋고 깨끗한 테스트 코드는 FIRST라는 5가지 규칙을 따라야 한다.

  • Fast: 테스트는 빠르게 동작하여 자주 돌릴 수 있어야 한다.
  • Independent: 각각의 테스트는 독립적이며 서로 의존해서는 안된다.
  • Repeatable: 어느 환경에서도 반복 가능해야 한다.
  • Self-Validating: 테스트는 성공 또는 실패로 bool 값으로 결과를 내어 자체적으로 검증되어야 한다.
  • Timely: 테스트는 적시에 즉, 테스트하려는 실제 코드를 구현하기 직전에 구현해야 한다.

테스코드를 작성하며 고민될 내용

테스트 코드를 작성하기 위해 production code의 접근 제어자를 변경하는게 좋을까!?

  • A. 테스트만을 위해 접근 제어자를 바꾸는 것은 좋지 않다.
  • A. 본인이 판단에 따라 접근 제어자를 변경할 정도로 해당 메서드의 테스트를 작성해야하는지 생각해봐야한다.
  • A. 리플렉션을 활용해서 private메서드를 테스트 하는 것도 그리 좋은 방법은 아니다.

출처: https://mangkyu.tistory.com/143 [MangKyu's Diary]

Java Unit Test

  • Java의 Unit Test는 최근에는 JUnit5와 AssertJ 라이브러리를 많이 사용한다고 합니다.

    • JUnit5 : 자바의 단위 테스트를 위한 테스팅 프레임워크
    • AssertJ : 자바의 단위 테스트를 돕기 위해 다양한 문법을 지원하는 라이브러리
  • 요즘 Unit test는 주로 한개의 단위를 3가지로 나눠서 처리하는 given-when-then패턴을 사용하는 추세라 한다.

    • given(준비) : 어떠한 데이터가 준비되었을 때
    • when(실행) : 어떠한 함수를 실행하면! (조건을 지정한다고 생각하면 된다.)
    • then(검증) : 어떠한 결과가 나와한다.
    • verify : 메서드가 호출된 횟수, 타임아웃 시간 체크를 검사할 때 사용 (부가적)

JUnit5

구조

JUnit 구조

사진 속에 있는 Jupiter, Vintage, JUnit Platform은 모두 JUnit5의 세부 모듈 입니다. 하나씩 설명하면 다음과 같습니다.

  • JUnit Platform : JUnit Platform은 JVM에서 테스트 프레임워크를 실행하는 기초를 제공하며 TestEngine API를 제공해 테스트 프레임워크를 개발할 수 있다.
  • Jupiter : JUnit5의 구현체로 JUnit Platform이 제공하는 TestEngine Api의 구현체이다.
  • Vintage : Jupiter와 같이 JUnit Platform이 제공하는 TestEngine Api의 구현체로 하위 호환성을 위한 JUnit3, JUnit4의 구현체이다.

기본 어노테이션

@Test

해당 어노테이션이 붙으면 해당 메서드가 Unit test라는 것을 명시해주는 어노테이션입니다.

@BeforeEach, @AfterEach, @BeforeAll, @AfterAll

  • @BeforeEach : 각각 테스트 메소드가 실행되기전에 실행되어야 하는 메소드를 명시해준다. @Test , @RepeatedTest , @ParameterizedTest , @TestFactory 가 붙은 테스트 메소드가 실행하기 전에 실행된다. JUnit4의 @Before 와 같은 역할을 한다. 주로 테스트 하기전에 필요한 목업 데이터를 미리 세팅해주기 위해 주로 사용한다.
  • @AfterEach : @Test , @RepeatedTest , @ParameterizedTest , @TestFactory 가 붙은 테스트 메소드가 실행되고 난 후 실행된다. JUnit4의 @After 어노테이션과 같은 역할을 한다.
  • @BeforeAll : 테스트가 시작하기전 딱 한번만 실행된다.
  • @AfterAll : 테스트가 완전히 끝난 후 딱 한번만 실행된다.

주의사항
@BeforeAll, @AfterAll 을 사용할 때는 해당 메서드는 return타입이 있으면 안되며 private키워드도 안된다.(default는 가능) -> 가능하면 static void를 사용하자.

@DisplayName

  • 테스트를 실행 하였을 때 나오는 테스트 이름이 바뀌게 됩니다.

JUnit에서는 메서드 명을 한글로 설정하여도 된다.
하지만 한글로 메서드 명을 작성할 경우 Ascii가 아니라고 warning경고들이 뜨게 됩니다. 이러한 경고들은 warning으로 계속 Log가 쌓이게 됩니다.
실제 서비스를 하다보면 이러한 warning들은 모두 체크해줘야하기에 불필요한 warning들은 제거를 해줘야합니다.

여기서 발생하는 warning을 제거하는 방법은 2가지가 있습니다.

  1. 매서드 명을 영어로 적고 @DisplayName을 통해 출력되는 테스트 메서드 이름을 변경한다.
  2. @SuppressWarnings("NonAsciiCharacters")를 통해 해당 warning을 제거한다.

Test 진행하기

테스트 클래스 생성

test를 진행하기 위해서는 test디렉터리를 생성해줘야합니다. 과거에는 Main에서 테스트를 하는 main test를 진행하였으나 여러 production code와 test code가 한 곳에 존재, 실제 배포시 test code도 같이 배포됨, 테스트를 사람이 수동으로 호출하여 확인하는 등의 여러 문제점이 있어 요즘은 test 디렉터리를 생성하고 해당 디렉터리에 테스트 코드들을 작성하여 관리를 합니다.

위의 사진과 같이 크게 main, test 디렉터리를 생성하여 main에는 production code, test에는 test code를 작성하여 관리를 합니다. 그리고 일반적으로 main과 test디렉터리의 하위 파일 구조를 같게 생성하여 production code내에 존재하는 메서드에 대한 test code의 위치를 알기 쉽게 관리합니다.

💡 팁 💡
TDD 개발론법이 아닌 production code를 먼저 작성하고 test코드를 작성하는 경우 인텔리제이를 사용하시면 command+shift+T 단축어를 이용하면 자동으로 production code와 같은 디렉터리 구조로 test클래스가 생성됩니다.

테스트 코드 작성

예상값이 맞는지 확인하기

assertThat().isEqualTo()

  • 옳바른 입력값 테스트

    @Test
    @DisplayName("자동차가 앞으로 전진하는지 확인한다")
    void goForward() {
    // given
    int exPosition = car.getPosition();
    
    // when
    car.goForward(4);
    
    // then
    assertThat(exPosition + 1).isEqualTo(car.getPosition());
    }

assertThat().isTrue()

@Test
@DisplayName("자동차가 우승자가 맞는지 확인한다")
void isWinner() {
    // given
    car.goForward(4);

    // when
    boolean result = car.isSamePosition(1);

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

이 외에도 AssertionsForClassTypes.class에서 제공하는 매서드들은 많이 있습니다. 개발자가 테스트하고자 하는 내용에 따라 옳바른 api를 호출하여 사용하면 됩니다.

예외 테스트

assertDoesNotThrow() assertDoesNotThrow()는 테스트 코드가 Exceptin이 없이 무사히 수행되는지를 체크하는 매서드입니다.

@DisplayName("옳바른 형식이면 통과한다.")
void checkRightName() {
    String names = "Rex, Skull";
    assertDoesNotThrow(() -> validation.validateName(names));
}

assertThatExceptionType() 테스트 코드에서 특정 메서드를 호출하여을 떄 옳바른 Exception이 발생하는지 체크하는 매서드입니다. 옳바른 exception이 발생하는지 확인 뿐만 아니라 옳바른 에러 메시지가 발생하는지까지도 체크 가능합니다.

@DisplayName("5자리 이상의 문자열이 주어지면 exception이 발생한다.")
void checkWrongName1() {
    String names = "pobi,crong,honuxxx";

    assertThatExceptionOfType(RuntimeException.class)
            .isThrownBy(() -> validation.validateName(names))
            .withMessageMatching("이름은 5자리 이하만 가능합니다.");
}

assertThatThrownBy() assertThatThrownBy()assertThatExceptionType()와 같이 exception을 확인인하는 매서드입니다.

@DisplayName("5자리 이상의 문자열이 주어지면 exception이 발생한다.")
void checkWrongName2(final String names) {
    String names = "pobi,crong,honuxxx";

    assertThatThrownBy(() -> {
        validation.validateName(names);
    }).isInstanceOf(RuntimeException.class)
            .hasMessageContaining("이름은 5자리 이하만 가능합니다.");
}

자주 발생하는 exception들의 경우 개발자의 편의를 위해 assertThatIllegalArgumentException(), assertThatIOException()와 같이 별도의 메서드를 제공하고 있습니다.

여러 테스트 케이스를 한번에 테스트

테스트 코드를 작성하다보면 경계값을 포함한 여러 값에 대해 테스트하고 싶은 경우가 존재할 것입니다. 이러한 경우 내부의 값을 변경하며 중복된 test코드를 여러번 작성하는 방법도 존재하지만 이런 테스트 코드 작성법은 너무 비효율적입니다.

JUnit은 이런 중복된 테스트 코드 작성의 문제를 해결해줄 수 있는 어노테이션이 존재합니다. 그것은 바로 @ParameterizedTest, @ValueSource입니다.

@DisplayName("5자리 이상의 문자열이 주어지면 exception이 발생한다.")
@ParameterizedTest(name = "{index} {displayName} names={0}")
@ValueSource(strings = {"pobi,crong,honuxxx", "pobixxxx"})
void checkWrongName(final String names) {
  assertThatExceptionOfType(RuntimeException.class)
  .isThrownBy(() -> validation.validateName(names))
  .withMessageMatching("이름은 5자리 이하만 가능합니다.");
  }

사용법은 위와 같이 @ValueSource안에 테스트를 하고 싶은 값들을 넣어주고 해당 테스트 메서드의 파라미터에 인자를 하나 넣어주면 됩니다. 그러면 해당 인자를 이용하여 production code를 호출하면 여러 테스트 케이스들을 하나의 테스트 코드로 테스트 할 수 있습니다.

@ParameterizedTest의 name값을 변경해주면 콘솔에 뜨는 테스트 이름을 변경할 수 있습니다.

만약 넘겨줘야하는 parameter가 한개가 아니고 여러개인 경우 @ValueSource대신 @CsvSource를 사용해야합니다.

@DisplayName("잘못된 입력값 길이 테스트 (2자리 수)")
@Test
void invalidInputLengthTest1(){
    // given
    final InputUtil inputUtil=new InputUtil();
    System.setIn(new ByteArrayInputStream("96".getBytes()));

    // when
    final RuntimeException exception=assertThrows(
    RuntimeException.class,()->inputUtil.getPlayerAnswer());

    // then
    assertThat(exception.getMessage()).isEqualTo(INPUT_ERROR_LENGTH);
}

Stub, Mock의 개념

프로그래밍을 하게되면 대부분의 객체는 메서드를 시행할 때 다른 객체들과 메시지를 주고받으면서 작업을 하는 것을 알 수 있습니다. 하지만 Unit test는 하나의 모듈에 대한 독립적인 테스트이기 때문에 다른 객체와 메시지를 주고 받는 경우 문제가 발생합니다. 이를 해결하기 위해 메시지를 주고받는 다른 객체 대신에 Mock Object(가짜 객체)를 생성하여 테스트합니다. 또 생성한 Mock Object를 주입하고 어떤 결과를 반환하라고 정해진 결과값을 준비하는데 이것을 Stub이라고 합니다.

Mock : Unit Test를 할 모듈과 메시지를 주고받는 객체를 대신할 가짜 객체이다. Stub : 실제 코드나 아직 준비되지 못한 코드를 호출하여 수행할 때 호출된 요청에 대해 미리 준비해둔 결과를 제공하는 테스트 메커니즘이다.

Mockito란?

Mockito는 개발자가 동작을 직접 제어할 수 있는 가짜(Mock) 객체를 지원하는 테스트 프레임워크입니다. 앞서 말했듯이 개발을 하다보면 객체들간의 의존성이 존재하여 테스트의 어려움이 있습니다. 이러한 문제를 해결하기 위해 Mock Object를 만들어 주입시켜주는데 이를 지원해주는 라이브러리가 Mockito입니다.

※ Mockito는 과거에는 static메서드를 지원하지 않는 단점이 있어 PowerMock을 대안으로 사용했으나 Mockito 3.4.0부터는 static method도 지원하고 있습니다.

Mockito사용법

1. Mock 객체의 의존성 주입

Mockito에서 Mock object에 의존성을 주입하기 위해서는 크게 3가지의 어노테이션을 사용합니다.

  • @Mock : Mock 객체를 만들어 반환해주는 어노테이션

@Mock을 사용하지 않고 mock()메서드를 통해서도 mock객체를 만들 수 있다. example)

Hint hint=mock(Hint.class);
  • @Spy: Stub하지 않은 메소드들을 원본 메소드 그대로 사용하는 어노테이션
  • @InjectMocks: @Mock 또는 @Spy로 생성된 가짜 객체를 자동으로 주입시켜주는 어노테이션

2. Stub

Mockito에서는 아래의 stub메서드들을 지원하고 있습니다. doReturn(): Mock 객체가 특정한 값을 반환해야 하는 경우 사용

doReturn(3)
  .when(p)
  .add(anyInt(),anyInt());

doNothing(): Mock 객체가 아무 것도 반환하지 않는 경우 사용(void)

@Test
public void example(){
    Person p=mock(Person.class);
    doNothing().when(p).setAge(anyInt());
    p.setAge(20);
    verify(p).setAge(anyInt());
}

doThrow(): Mock 객체가 예외를 발생시키는 경우 사용

@Test(expected = IllegalArgumentException.class)
public void example(){
    Person p=mock(Person.class);
    doThrow(new IllegalArgumentException()).when(p).setName(eq("JDM"));
    String name="JDM";
    p.setName(name);
}

Stub의 doReturn, thenReturn의 차이

Mockito를 사용하다보면 코드들에 doThrow, doReturn등의 형식이 아닌 thenReturn(), thenThrow()등의 형식도 볼 수 있을 것이다. 둘의 차이점은 다음과 같습니다.

doReturn

  • 실제로 메서드를 호출하고 리턴값을 임의로 정의할 수 있다.
  • 메서드를 실제로 수행하여 메서드 작업이 오래걸릴 경우 끝날 때 까지 기다려야한다.
  • 실제 메서드를 호출하기 때문에 대상 메서드에 문제점이 있을 경우 발견할 수 있다.
doReturn(6).when(cal).add(2,4);

thenReturn

  • 메서드를 실제로 호출하지 않으며 리턴값을 임의로 정의할 수 있다.
  • 실제 메서드를 호출하지 않기 때문에 대상 메서드에 문제점이 있어도 알 수 없다.
when(cal.add(2,4)).thenReturn(6);

Reference

Buy me a coffeeBuy me a coffee
Written by

@Seongwon

기술공유를 통해 새로운 가치 창조을 추구하는 백엔드 개발자 오성원입니다.
©SeongwonOh