JUnit5과 Spring boot 테스트 코드 작성법-단위 테스트를 해야하는 이유(단위 테스트 책)와 JUnit5 AssertJ 활용방법(1)

taehee kim·2023년 2월 28일
3

0.좋은 테스트란?

  • 리팩토링 내성, 회귀방지, 빠른 피드백, 유지보수성이 일정 수준 이상으로 유지되는 테스트라고 생각합니다 .
  • 리팩토링 내성, 회귀방지, 빠른 피드백은 배타적관계에 있기 때문에 trade off를 고려하여 작성되어야 합니다.
    • 예를 들어 빠른 Spring Context를 로딩하지 않고 의존하는 객체를 Mocking하여 순수한 Java환경에서 테스트하면 빠른 피드백으로 이어질 수 있습니다.
    • 하지만 이 경우 Mocking한 객체의 인터페이스가 변경 될 경우 TestCode에서 그 부분이 모두 수정되어야 하기 때문에 리팩토링 내성측면에서 손해를 보게 됩니다.
  • 좋은 테스트는 이러한 TradeOff를 고려하여 어플리케이션 생산의 효율성을 극대화하는 테스트 입니다.
  • 또한 추가적으로 테스트 코드 작성을 통해 프로덕션 코드의 안정성을 확보 할 수 있습니다.
    • 코드 수정 및 리팩토링 시 문제가 생기는지 미리 확인
  • 좋은 테스트 코드는 좋은 코드로 이어집니다.
    • 테스트 대상 코드가 잘 설계되어있고 의존성이 잘 분리 되었는지를 테스트 코드를 작성하다보면 확인할 수 있음.

1. 단위 테스트와 통합 테스트

1-1. 단위 테스트란?

  • 특정 단위의 함수, 모듈, 객체를 독립적으로 빠르게 자동화 하여 테스트하는 것을 말함.
  • 위 테스트에서는 독립적인 테스트를 위해 Mock 객체인 Stub을 활용하여 테스트한다.

1-2. 통합 테스트란?

  • 각 모듈 간에 메시지를 주고 받으며 올바르게 연계되어 동작하는지 검증한다.
  • 캐시 메세지 큐등 외부 리소스를 실제로 모두실행해야 테스트해야한다.
  • 테스트 시간이 오래걸린다.

1-3. 단위 테스트의 필요성

  • 통합 테스트만으로도 기능을 검증하는것에는 문제가 없을 수 있는데 왜 단위테스트를 수행해야할까?
  • 테스트 코드의 목적과 올바른 테스트 코드는 통합 테스트로는 이룰 수 없기 때문이다.
    • 테스트 코드의 목적은 해당 기능을 빠르게 검증 받고 이를 반복적으로 검증하여 빠르게 수정할 수 있는것에 있지만 통합 테스트는 이에 부적합하다.

1-4. 올바르게 단위 테스트를 작성하는 방법

  1. 한개의 테스트 함수는 하나의 개념만을 테스트한다.
  2. 한개의 테스트 함수에서 assert는 불필요하게 많이 사용하지 않는다.
  3. 의존하는 객체에 대해서 Mock 객체를 생성하여 테스트한다.
  4. 메서드 명을 보고 어떤 내용을 테스트 하는지 파악할 수 있도록 한다. (저는 테스트하려는 메서드명_given_when_then의 형태로 메서드 명을 작성합니다.)
    (단, 메서드 명이 너무 세부적인 검증 로직에 집중한다면 리팩토링 내성이 감소할 수 있으므로 주의한다.)
    출처:
    https://dzone.com/articles/7-popular-unit-test-naming
  5. 테스트 코드도 프로덕션 코드와 마찬가지로 읽고 어떤 내용을 테스트하려는지 잘 파악할 수 있어야한다.

1-4-1. FIRST 원칙

  1. Fast: 테스트는 빠르게 동작하여 자주 돌릴 수 있어야한다.(단위 테스트로 작성.)
  2. Independent: 테스트는 독립적이며 서로 의존해서는 안된다.
  3. Repetable: 반복하여 테스트가 가능해야한다.
  4. Self-Validating: 테스트는 성공 또는 실패로 bool값으로 결과를 내어 자체적으로 검증되어야한다.(이후에 추가적인 조치를 취해야 검증이 가능하다면 잘못 작성된 테스트 코드)
  5. Timely: 테스트는 적시에 즉, 테스트하려는 실제 코드를 구현하기 직전에 구현해야 한다.

1-5. 단위테스트 책 일부를 읽고 테스트 과정에서 고려해야하는 부분을 요약한 내용.

http://www.yes24.com/Product/Goods/104084175

  • 1-4까지의 내용은 테스트의 개념적인 내용이라면 1-5에서 소개하는 책의 내용의 일부를 보고 실제로 단위 테스트를 작성할 때 어떤 지점을 고민하여 작성하여야할지 인지 할 수 있었습니다.

1-5-1. 단위 테스트의 목표

  • 이 책의 내용을 한마디로 요약하자면 단위 테스트는 어플리케이션 생산 주기 전체에서 효율성을 끌어올리는 것이 주요 목적이라는 것이다.
  • 단위 테스트가 잘 작성되었는지 나타내는 지표들이 있는데 이 지표들이 배타적인 성격을 띄는 경우 트레이드 오프를 고려하여 우선적인 지표를 먼저 충족시키는 것이 중요하고 이를 통해 효율적으로 어플리케이션을 생산할 수 있다.
  • 보통 테스트는 에러를 검출하고 오류가 없는 어플리케이션을 만들기 위한 목적때문에 수행한다고 생각하지만, 단위 테스트는 단순히 이런 목적 외에도 생산 측면의 효율성을 목표로 하며 에러 검출을 줄이기 위해 너무 많고 꼼꼼한 테스트를 작성하는 것은 정도가 지나치면 오히려 생산성을 줄이는 배타적인 관계에 놓이기 때문에 테스트 작성자가 이를 고려하여 적절하게 전략을 선택해야한다는 것을 책의 핵심 요지로 받아들였다.

1-5-2. 알아야할 사전 개념

  • 코드는 점점 나빠지는 경향이있다.
    • 이때 지속적으로 단위 테스트를 작성해주지 않으면 복리처럼 안좋은 코드가 불어나고 어플리케이션 생산비용이 크게 증가한다.
  • 회귀(Regression)
    • 기능을 개발한 이후에 기능이 제대로 동작하지 않는 것.
  • 테스트 커버리지
    • 전체 코드에서 몇퍼센트의 코드가 테스트 되었는지, 혹은 전체 분기에서 통과한 분기가 몇퍼센트인지
    • 통상적으로 60%정도를 넘기면 괜찮고 너무 낮으면 확정적으로 문제가 되지만 높다고 해서 좋지않음.

1-5-3. Mock

  • Mock 객체는 테스트 시 의존성 때문에 정상적인 테스트를 수행할 수 없는 경우 다른 객체에 의존하지 않고 테스트 하기 위해 임시로
    생성하는 가상의 객체이다.
  • 더미, 스텁, 페이크 객체등의 종류가 있다.
  • 의존성은 있지만 테스트 케이스에서 Mock으로 생성할 필요없는 경우 생성하지 않는것이 좋으며 하나의 단위 테스트를 위해 너무 많은 Mock객체가 필요한 경우 프로덕션 코드의 의존성이 강해 설계가 좋지 못한 경우이므로 프로덕션 코드가 잘 작성되고 있는지 확인해야한다.

1-5-4. 좋은 단위 테스트의 4대 요소

1-5-4-1.리팩토링 내성(단위 테스트시 가장 우선적으로 추구해야할 목표)

  • 테스트를 수정하지 않고도 기존 코드를 리팩토링 할 수 있는지를 말한다.
  • 개발을 하다보면 기존 코드의 큰 흐름에서의 기능은 변하지 않지만 세부 구현이나 구조를 변경하는 경우가 있는데 이 경우에도 기존 테스트 코드는 프로덕션 코드의 변경과 상관없이 테스트를 잘 수행할 수 있어야 한다.
  • 거짓 양성: 실제로는 기능이 정상 동작하는데 테스트가 실패하는 경우이다.
  • 리팩토링 내성이 떨어질 경우 본 코드의 기능을 그대로 둔 상태로 리팩토링 했을 시 테스트 코드를 매번 변경해야할 수 있다.
  • 이를 방지하고 리팩토링 내성을 올리기 위해서는 최종 결과를 목표로 테스트를 작성하고 세부적인 구현을 최대한 테스트에서 배제하는 것이 필요하다.

1-5-4-2. 빠른 피드백

  • 테스트 코드수행이 빨라야한다.

1-5-4-3. 회귀 방지

  • 기능대로 동작하지 않는 회귀가 발생하지 않도록 해야한다.즉, 예상치 못한 버그가 발생하지 않도록 테스트를 정교하게 해야하는 것을 의미한다.
  • 거짓 음성: 기능이 동작하지 않는데 테스트가 통과하는 경우
    • 테스트 케이스가 부족하거나 검증이 부족하면 발생할 수 있다.

1-5-4-4. 유지 보수성

  • 테스트 가독성이 높고 실행성이 쉬워야한다.

1-5-4-5. 이상적인 테스트?

  • 위에 있는 4요소의 곱으로 계산되며 하나라도 0이라면 테스트 점수는 0이된다.
  • 유지 보수성을 제외하면 상호 배타적이므로 트레이드 오프를 고려하여 적절하게 선택해야한다.
  • **리팩토링 내성을 최대한 많이 갖는 것을 최 우선 목표로 해야한다.
  • 그 다음 회귀 방지 -> 빠른 피드백 순이 일반적으로 바람직한 우선순위이다.

1-5-4-6. 테스트 종류에 따른 성격 비교

  1. E2E테스트
  • 회귀방지도 훌륭하고 리팩터링 내성도 우수하지만 느린 속도가 문제이다.
  • 모든 의존성을 함께 관리해줘야 하기 때문에 유지보수 비용도 많이 들어가는 단점이 있다.
  • 그래도 필요한 테스트이다.
  1. 간단한 테스트
  • 고장이 없을 것 같은 단순 getter, setter 코드를 테스트하는 것은 회귀 방지에 의미가 없다.
  • 이 경우 테스트를 수행하지 않는 것이 바람직하다고 생각한다.
  1. 깨지기 쉬운 테스트
  • 거짓 양성이 많은 테스트를 Brittle test(깨지기 쉬운 테스트)라고 한다.
  • 회귀 내성이 매우 떨어지고 리팩토링 내성이 좋다고 볼 수도 없기 때문에 좋지 못한 테스트이다.

1-5-5. 단위 테스트 안티 패턴

  • 비공개 메서드 단위 테스트
    • 하지 않아야 한다.
    • 비공개 메서드가 엄청 복잡해서 공개 메서드로 테스트가 불가능하다면 죽은 코드이거나, 추상화가 덜 되어 있는 것이다.
    • 간접 테스트한다.
  • 비공개 상태 노출
  • 테스트로 유출된 도메인 지식
    • sum 을 테스트 하는데, [1, 2] 를 전달하고 기대값으로 1+2 를 하는 것이다. 이는 구현이 노출된 것이다.

2. JUnit5와 AssertJ

2-0. Junit5란

  • JUnit5은 Java 기반 코드를 테스트를 할 수 있도록 하는 라이브러리이며 JUnit Platform + JUnit Jupiter + JUnit Vintage으로 구성되어있다.

  • JUnit Platform은 JVM에서 테스트 프레임워크를 실행하는데 기초를 제공한다. 또한 TestEngine API를 제공해 테스트 프레임워크를 개발할 수 있다.

  • JUnit Jupiter는 JUnit 5에서 테스트를 작성하고 확장을 하기 위한 새로운 프로그래밍 모델과 확장 모델의 조합이다.

  • JUnit Vintage는 하위 호환성을 위해 JUnit3과 JUnt4를 기반으로 돌아가는 플랫폼에 테스트 엔진을 제공해준다.

2-0-1. JUnit5 활용법.

@Test

  • 테스트 하려는 메서드에 @Test을 사용하면 테스트 메서드로 실행할 수 있다.
    @Test
      void findAllFetchJoinMatchMemberId() {

@BeforeAll

  • @BeforeAll이 적용된 메서드는 테스트 클래스의 테스트가 실행되기 전에 단 한번만 실행된다.
  • 여러 테스트 메서드에서 일정하게 유지되어야 하는 조건이 있을 때 활용 될 수 있지만 앞서 실행도니 테스트 메서드에 의해서 @BeforeAll이 실행된 결과가 변경 될 수 있는 경우에는 활용하면 안된다.
  • static method에 적용한다.

@BeforeEach

  • @BeforeAll은 테스트 클래스에서 각가의 모든 테스트 메서드가 실행되기 전에 먼저 실행되는 메서드이다.
  • @BeforeAll은 instance method에만 붙일 수 있다.
  • 각각의 테스트 메서드 실행 이후에 초기화 되기 때문에 앞선 테스트 메서드에 영향을 받지 않을 수 있다.

@BeforeAll @BeforeEach 차이점

  • BeforeAll의 경우 static method이기 때문에 AOP로 구현되는 @Transactional annotation을 활용해도 트랜잭션이 적용되지 않는다.
  • 따라서 트랜잭션이 필요한 경우 BeforeAll은 사용하지 않는다.
  • 반면 @BeforeEach의 경우 각각의 테스트 메서드와 @BeforeEach 메서드는 하나의 트랜잭션으로 묶이기 때문에 같은 영속성 컨텍스트에 속할 수 있고 메서드 호출이 종료될 때 Rollback이 이루어진다.
  • @BeforeEach의 경우 매 테스트 메서드마다 같은 로직을 반복해서 수행하기 때문에 테스트 속도가 저하될 수 있다.

@AfterEach, @AfterAll

  • @BeforeAll @BeforeEach와 달리 테스트 메서드의 로직이 실행되고 난 이후에 실행된다는 점 이외에 다른 특징은 동일하다.

@DisplayName

  • 다음과 같이 활용하면 테스트 메서드 실행후 표시될 테스트명을 지정할 수 있다.
  @Test
  @DisplayName("테스트")
    void findAllFetchJoinMatchMemberId() {

@ParameterizedTest

  • 인자를 가독성이 정의하여 테스트 할 수 있다.
@ParameterizedTest
@ValueSource(ints = {1, 3, 5, -3, 15, Integer.MAX_VALUE}) // six numbers
void isOdd_ShouldReturnTrueForOddNumbers(int number) {
    assertTrue(Numbers.isOdd(number));
}

JUnit5의 Assertions 메서드

  • AssertJ의 assert기능 관련 메서드를 활용하면 메서드 체이닝의 형태로 테스트 코드를 작성하여 가독성에 도움을 주기 때문에 JUnit5의 Assertions 메서드 보다는 AssertJ메서드를 사용하자.

2-1. AssertJ란

  • AssertJ는 Junit5와 같이 사용하면 좋은 외부 라이브러리로 메서드 체이닝의 형태로 테스트 코드를 작성할 수 있도록 도움을 주는 라이브러리이다.
  • 실제로 테스트 할 때는 JUnit5의 assertion 보다는 AssertJ를 활용하는 경우가 많다.

2-1-1. 일반적인 테스트 흐름

  • 보통 테스트를 작성할 때는 given-when-then의 구조로 작성한다.
    • given은 테스트 데이터등을 세팅한다.
    • when은 테스트 하려는 동작을 수행한다.
    • then에서는 given-when절을 통해 나온 결과가 원하는 결과와 부합하는 지 Assertion을 통해 검증한다.
    • AssertJ는 then에서 결과검증시 활용된다.

2-2. AssertJ 사용법

기본적인 AssertJ 문법의 구조

  • assertThat(검증하려는 대상).검증메서드(원하는 결과)
assertThat(actual).isEqualTo(expected);

문자열 검증

@Test 
void a_few_simple_assertions() {
    assertThat("The Lord of the Rings").isNotNull()
                                        .startsWith("The")
                                        .contains("Lord")
                                        .endsWith("Rings");
}

테스트 실패 메시지 지정(as)

@Test
void test() throws Exception {
    String str = "name";
    assertThat(str).as("값을 확인해주세요. 현재 값: %s", str)
            .isEqualTo("name2");
}

해당 값이 객체에(주로 컬렉션이나 String)존재하는지 검증 contains, containsOnly, containsExactly

  • contains: 인자의 내용들은 적어도 모두 포함해야함.
void containsTest() {
    List<Integer> list = Arrays.asList(1, 2, 3);
 
    // Success: 모든 원소를 입력하지 않아도 성공
    assertThat(list).contains(1, 2);
 
    // Success: 중복된 값이 있어도 포함만 되어 있으면 성공
    assertThat(list).contains(1, 2, 2);
 
    // Success: 순서가 바뀌어도 값만 맞으면 성공
    assertThat(list).contains(3, 2);
 
    // Fail: List 에 없는 값을 입력하면 실패
    assertThat(list).contains(1, 2, 3, 4);
}
  • containsOnly: 인자의 내용이 내용물과 같아야함.
@Test
void containsOnlyTest() {
    List<Integer> list = Arrays.asList(1, 2, 3);
 
    assertThat(list).containsOnly(1, 2, 3);
    assertThat(list).containsOnly(3, 2, 1); // 순서는 고려하지 않는다.
    assertThat(list).containsOnly(1, 2, 3, 3); // 중복은 고려하지 않는다. (1,2,3) 으로 인식
    // (1,2) 나 (1,2,3,4)는 검증에 실패한다.
}
  • containsExactly: 인자의 내용이 모두 같으면서 순서도 같아야함.
@Test
void containsExactlyTest() {
    List<Integer> list = Arrays.asList(1, 2, 3);
 
    assertThat(list).containsExactly(1, 2, 3);
    // (1,2) (3,2,1) (1,2,3,3) 같은 건 검증 실패
}

extractin

  • 컬렉션에 포함된 요소 객체의 특정 필드를 추출하여 검증.
@Test
void extracting_more() throws Exception {
    // 1
    assertThat(members)
            .extracting("name", String.class)
            .contains("dexter", "james", "park", "lee");
 
    // 2
    assertThat(members) // 검증 필드가 여러개라면 tuple을 사용하자.
            .extracting("name", "age")
            .contains(
                    tuple("dexter", 12),
                    tuple("james", 30),
                    tuple("park", 23),
                    tuple("lee", 33)
            );
}

Soft Assrtion

  • 하나의 테스트 메서드 내에서 앞에 있는 assert가 실패해도 계속 진행한 후 최종 결과를 반환.
  • 여러 방식으로 수행할 수 있는데 SoftAssertions.assertSoftly 유틸 메서드를 활용하는 것이 가장 깔끔하고 편리하다고 느꼈습니다.
@Test
void assertSoftly_example() {
  SoftAssertions.assertSoftly(softly -> {
    softly.assertThat("George Martin").as("great authors").isEqualTo("JRR Tolkien");
    softly.assertThat(42).as("response to Everything").isGreaterThan(100);
    softly.assertThat("Gandalf").isEqualTo("Sauron");
    // 이 역시 assertAll() 를 호출할 필요가 없다. assertSoftly 가 알아서 해줌.
  });
}

예외 검증

  • assertThatThrownBy(람다식).isInstanceOf(예상되는 예외 클래스);
@Test
void exception() throws Exception {
    assertThatThrownBy(() -> throwException())
            .isInstanceOf(Exception.class)
            .hasMessage("예외 던지기!") // Exception 객체가 가지고있는 메시지 검증
            .hasMessageEndingWith("Size: 2")
            .hasStackTraceContaining("Exception");
}

isEqualTo 비교

  • isEqualTo: equals method를 활용하여 같은지 비교 기본적으로 equals메서드를 활용.

  • usingComparator(Comparator): 비교 comparator를 정의하여 비교할 수 있다.

assertThat(item1)
            .usingComparator((a, b) -> a.getName().compareTo(b.getName()))
            .isEqualTo(item2);

필드 비교 usingRecursiveComparison(객체 비교시 자주 사용함.)

  • usingComparator를 활용하여 비교하여도 되지만 매번 이를 작성하기 힘들기 때문에 필드비교를 원할 경우 usingRecursiveComparison를 사용할 수 있다.
  • usingRecursiveComparison는 재귀적으로 필드를 찾아가면서 비교하고 오버라이딩된 equals메서드로 비교를 수행하지 않는다.(최신버전(3.17.0이상)에서는 비교할 때 사용자가 재정의한 Equals 메서드를 사용하지 않습니다. (그냥 필드비교함)

하지만 이전버전에서는 Equals를 사용하는게 기본이라서, 버전에 따라 다르게 동작할 수 있어요.)

  • 따라서 다음과 같이 명시적으로 equals를 사용하지 않도록 메서드를 활용해주면 가독성도 올라가고 버전에 상관없이 테스트가 가능하다.
assertThat(sherlock).usingRecursiveComparison()
                    .ignoringOverriddenEquals() // 명시적으로 Equals를 무시함!
                    .ignoringFields("uniqueId", "otherId") // objActual에 해당 필드는 무시합니다.
                    .isEqualTo(sherlock2);
  • ignoringFields를 사용하면 비교하지않을 필드를 제외할 수 있다.
  • ignoringActualNullFields 검증 대상(assertThat에 인자)에 null field가 있는 경우 비교자체를 수행하지 않는다.
    • 테스트 객체는 null field를 주어 대충 만드는 경우가 많기 때문에 사용할 일이 많다.
@Test
void myTest() throws Exception {
    Person noName = new Person(null, 1.80);
    noName.home.address.street = null;
    noName.home.address.number = 221;
 
    Person sherlock = new Person("Sherlock", 1.80);
    sherlock.home.address.street = "Baker Street";
    sherlock.home.address.number = 221;
 
    // 테스트 성공. name 과 home.address.street 필드가 무시됨.
    assertThat(noName).usingRecursiveComparison()
        .ignoringActualNullFields()
        .isEqualTo(sherlock);
 
    // 테스트 실패. 검증대상에는 null 필드가 없어서, 제외할 필드가 없음.
    assertThat(sherlock).usingRecursiveComparison()
        .ignoringActualNullFields()
        .isEqualTo(noName); // AssertionError
}
profile
Fail Fast

0개의 댓글