Framework

[Test] JUnit의 개념과 장점, 문법들 (w/ 테스트 코드 이렇게 쓰지 말자)

쉬지마 이굥진 2024. 5. 4. 00:19

JUnit 이란?

- 자바 프로그래밍 언어용 Unit Test Framework

 

- 테스트를 위한 API로 JUnit Jupiter API를 제공한다.

우리가 테스트코드를 작성할 때 가장 많이 사용하고, 가장 유용하게 사용하는 메서드나 어노테이션들이 담겨있는 API라고 생각하면 된다.

 

-신 버전은 JUnit5이고, JDK-1.8 이상부터 사용 가능하다.

 

- 컴포넌트는 JUnit Platform과 JUnit Jupiter, JUnit Vintage로 구성되어 있다.

JUnit 3, 4와 호환이 될 수 있게 도와주는 기능과, 상단에서 말했던 테스트코드를 작성할 때 필요한 메서드나 어노테이션들을 제공하는 기능 등을 한다.

 

장점

- 테스트 결과는 Test 클래스로 동료 개발자에게 테스트 방법 및 클래스의 History를 공유해 줄 수 있다.

여기서 History란, 초기 클래스 정의부터 현재까지 변해온 일련의 과정을 테스트 코드를 통해서 전달할 수 있다는 뜻이다. 이 History를 통해서 해당 클래스/메서드가 갖는 의미를 알 수 있고,  이게 비즈니스 로직에서 어떻게 사용되고 있는지도 전달할 수 있기 때문에, 단순한 api 정의서보다 더 많은 기능을 할 수 있다.

 

- 단정(Assert) 메서드로 Test Case의 테스트 결과를 손쉽게 판별할 수 있다.

 

-노테이션으로 간결하게 테스트 코드를 작성할 수 있는 기능을 제공한다.

 

👉 현재 스프링부트나 node.js 등등의 기타 프레임워크에서 xUnit이라는 프레임워크로 테스트 코드를 작성할 수 있게끔 했는데, 이것에 대한 시초(?)가 JUnit 이라고 한다. 

👉 따라서 스프링부트를 쓰는 개발자라고 하면, JUnit을 쉽고 간단하게 사용할 수 있는 능력을 갖고 있어야 한다.

 

 

JUnit5 초기 환경 구성

- Spring Boot 2.2.+ 이상 버전부터는 기본적으로 의존성이 추가되어 있기 때문에, 별도로 패키지 관리 도구(ex. gradle)에 추가하지 않아도 된다.

testImplementation 'org.springframework.boot:spring-boot-starter-test'

 

- 만약 프로젝트가 SpringBoot 프레임워크를 이용해서 개발을 하지 않는 경우에는 아래와 같이 Gradle(혹은 Maven, Ant)에 의존성을 추가해 주면 된다.

dependendies {
	testImplementation 'org.junit.jupiter:junit-jupiter-api:5.6.0'
	testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine'
}

 

 

JUnit5의 명명 규칙

JUnit5 정식 가이드 문서에서는 테스트 클래스의 명명 규칙(a.k.a 네이밍 룰)에 대해서 따로 정의하거나 이야기 하고 있지는 않다.

하지만 보통 실무에서는 테스트 클래스의 이름을 지을 때, 일반 클래스와 구분하기 위해서 "Test"라는 접미사를 붙이는 걸 국룰(?)로 정해두고 있다고 한다.

(이건 회사 by 회사로, 회사마다 테스트 컨벤션이 있을 수도 있으니 그것에 따르자)

    @Nested
    @DisplayName("댓글, 대댓글 삭제 테스트")
    @TestMethodOrder(MethodOrderer.OrderAnnotation.class)
    class DeleteTest {
        @Test
        @Order(1)
        @DisplayName("댓글, 대댓글 삭제 성공 테스트")
        void testDeleteComment() {
        
		...
        
		}
	}

 

 

테스트 메서드; @Test

- 테스트 메서드의 기본 문법은 아래와 같다. 권장 사항이 아니라 필수 문법이므로 꼭 지켜줘야 함 🥹

  1. 테스트 메서드에 @Test 어노테이션을 꼭! 붙여준다.
  2. 메서드의 리턴 타입은 무조건 void 로 선언해야 한다.
    @Test
    void basicTest() {
    	System.out.println("Execute test");
        assertEquals(1, 1);	// 예상값(1)과 실제값(1)을 비교
    }

 

 

문법을 보면 의문이 들 수도 있다. 우리는 왜 리턴 타입을 void로 지정해줘야 할까?

 

- JUnit 프레임워크의 테스트 실행 흐름을 보면 된다.

[실행 흐름]

  1. 테스트 메서드 실행
  2. (Fail) 실행 중 AssertionFailedError 예외가 발생하면, Fail 되고 테스트가 종료된다.
    (Success) 실행 중 AssertionFailedError 예외가 발생하지 않으면, Success 로 테스트가 종료된다.

👉이렇게 JUnit의 테스트 메서드는 AssertionFailedError 예외가 발생했냐/안했냐(즉, True/False)를 기준으로 성공/실패를 구분한다.

👉 이러한 방식 때문에 return 값이 필요 없고, @Test 어노테이션을 통해서 테스트 메서드의 리턴 타입을 void로 강제하는 것이다.

 

 

정리: @Test 의 역할

정리해보면, 테스트 메서드 @Test 어노테이션의 역할은!

  • 이게 테스트 메서드야! 라고 알려주는 역할 
  • 해당하는 메서드의 리턴 타입을 void 로 강제하는 역할

이라고 할 수 있겠다.

 

실제로 필자가 작성한 테스트 메서드의 리턴 타입을 boolean으로 정의하니, 컴파일 에러가 뜬다.

Method '메서드이름' annotated with @Test should be of type 'void'


단언(Assert) 메서드란?

테스트 케이스의 실행(or 테스트) 결과를 판별해주는 메서드를 말한다.

 

- 테스트 시나리오 대로 테스트 코드가 실행되는가?

- 실제값과 기댓값이 동일한가?

 

 

주요 단언 메서드

🔹assertEquals(expected, actual);

- 기능: 실제 값(actual)이 기대하는 값(expected)와 같은지 검사한다. 값이 같으면 테스트를 Success로 종료하고, 다르다면 Fail로 종료한다.

 

🔹fail( );

- 기능: 테스트를 강제로 실패 시키고자 할 때 사용된다. (실패 해야만 하는 테스트 시나리오를 테스트할 때 사용된다.)

 

🔹assertThrows( );

- 기능: Exception 발생 유무 검증이 필요한 경우 사용한다. 단순히 발생 유/무 뿐만 아니라, '어떤 Exception'이 발생했는지까지 판단해준다.

 

 

테스트 라이프 사이클

JUnit은 각 테스트 메서드마다 위와 같은 순서로 실행된다.

  • BeforeAll
    모든 테스트 코드가 수행되기 전에 최초로 수행되는 메서드를 만들어 준다.
    주의) static 메서드로 선언해야 한다.
    @BeforeAll
    static void beforeAll() {
        System.out.println("모든 테스트 코드가 실행되기 전에 최초로 수행\n");
    }​


  • BeforeEach
    각각의 테스트 코드가 실행되기 전에 수행되는 메서드를 만들어 준다.
    테스트를 실행하는 데 필요한 준비 작업을 할 때 사용된다고 생각하면 된다. (ex. 임시 파일 생성, 테스트 메서드에서 사용할 객체 생성)

    @BeforeEach
    void setUp() {
        System.out.println("각각의 테스트 코드가 실행되기 전에 수행");
    }​


  • @Test
    실제 테스트가 필요한 메서드 또는 테스트 코드가 여기서 실행되고, JUnit에서 제공하는 단언을 통해서 개발자가 의도했던 테스트 결과가 나오는지 확인하는 단계이다.


  • AfterEach
    각각의 테스트 코드가 실행된 후에 수행되는 메서드를 만들어 준다.
    테스트 종료 후 정리할 것이 있을 때 사용한다고 생각하면 된다. (ex. 사용한 리소스 반환, 임시 사용파일을 삭제하는 기능 등)

    @AfterEach
    void tearDown() {
        System.out.println("각각의 테스트 코드가 실행된 후에 수행\n");
    }​


  • AfterAll
    모든 테스트가 끝나는 시점으로, 모든 테스트 코드가 수행된 후 마지막으로 수행되는 메서드를 만들어 준다.
    테스트 환경에 부가적으로 필요했던 인스턴스의 리소스 반환이나 종료 등을 여기서 진행한다.
    주의) static 메서드로 선언해야 한다.
    @AfterAll
    static void afterAll() {
        System.out.println("모든 테스트 코드가 수행된 후 마지막으로 수행");
    }

좋지 않은 테스트 코드 예제

 

보통 코드에서도 좋은 코드와 그렇지 않은 코드가 있듯, 테스트 코드에서도 좋은 예시와 좋지 않은 예시가 있을 것이다. 아래의 코드는 실제로 도움이 되지 않는 테스트 코드다. 이 코드를 보고 왜 좋지 않은 코드일까? 생각해보자!

 

코드를 자세히 뜯어보면, fileCreationTest 메서드(이하 ①번 메서드)는 어떤 특정한 위치에 파일을 생성하는 코드이고, readFileTest 메서드(이하 ②번 메서드)는 그 생성된 파일을 읽어서 count가 0보다 이상인지 이하인지 판단 후 Success/Fail 여부를 판단하는 코드이다.

 

그렇다보니 ①번에서 먼저 파일을 생성해주지 않고 ②번부터 테스트 코드를 실행시키면 (로직 상 문제는 없지만) 당연히 테스트 코드는 실패하게 되는 것이다.

 

바꿔 말하면 무조건 ①번 코드가 선행되고, 이 테스트에서 생성된 파일을 읽는 ②번 코드가 실행이 되야 해당 테스트 코드가 정상적으로 둘 다 Success 되는 구조라고 말 할 수 있겠다. 

 

이 때 ②번 메서드는 ①번 메서드를 의존하고있다고 한다.

 

정리

위 코드에서 fileCreationTest 가 실행된 후 👉 readFileTest가 실행됐을 땐 성공!하는데, readFileTest가 실행된 후 👉   fileCreationTest 가 실행됐을 때 결과가 뒤바뀐다. 그것은 옳지 않은 테스트 코드이다. 각각의 테스트 코드가 독립적으로 혼자서 잘 실행되어야 하며, 그 결과가 다른 테스트 코드에 영향을 줘서도 안 되고 받아서도 안 된다는 말 !! 

 

테스트 코드는 무조건 독립성을 가져야 한다.

다른 테스트 메서드에 의존해서는 안된다.