이번 포스트는 JUnit 을 사용해 테스트를 작성하는 기초적인 방법에 대한 글이다. 이번 주제는 사실 기획과 서버 개발을 병행했던 경험에서부터 우러나온 개인적인 견해로부터 나왔다. "테스트"란 단어가 요즘 핫한 용어이긴 한데 그럴만한 이유가 있고, 꼭 필요한 지식이자 유용한 기능이라고 생각된다.
우선, 서비스를 기획하고 서버 개발을 하면서 느꼈던 점은 대충 아래와 같다.
- 요구사항이 명확하고 구체적일수록 기능이 구체적이며 신뢰성이 증가한다
- 시스템 통합 과정은 단순하지 않으며 예상치 못한 에러와의 전쟁이다.
- 서비스의 기능적 요소만이 개발의 전부가 아니다, 비기능적(성능, 신뢰성 등)도 동등히 중요하다
이런 과정을 거치면서 프로젝트를 마무리하고 어떻게 하면 기획자로서 또는 개발자로서 프로젝트를 안정적이고 효율적으로 운영할 수 있을까를 고민하고 내린 결론은 "테스트 우선주의"였다.
여기서 중요한 것은 "테스트가 무조건 있어야하고, 테스트를 통과하도록 만들자"가 아니라 인풋에 대한 아웃풋을 미리 명확하고 구체적으로 인지를 해야한다는 것이 가장 중요하다. 이 과정을 통해 요구사항을 좀더 면밀히 분석하고 꼼꼼하고 구체적으로 파악할 수 있고, 개발하는 사람 입장에서는 이를 토대로 명확하고 안정적인 기능을 개발하고 시스템 통합에서도 이전 코드에 대한 신뢰를 기반으로 효율적이고 안정적이게 통합할 수 있다.
그리고 실질적으로는 개발시 인풋과 아웃풋의 예상과 실제값을 비교하여 요구사항을 명확히 수행하였는지 확인할 수 있는 테스트를 진행하고 Java 에서는 JUnit을 대표적으로 사용한다. 그래서 개인적으로 이에 대한 필요성을 느끼고 공부하며 작성한 기초적인 예제이니 가벼운 마음으로 읽으면 좋겠다.
JUnit 에는 여러가지 버전이 있고 현재 5 버전이 최신이고 아래와 같은 구조로 이루어져 있다.
- Platform : JUnit의 TestEngine 인터페이스 보유, 테스트 발견 -> 실행 -> 보고
- Jupiter : JUnit 5 에 해당하는 기능들의 API
- Vintage : 이전 JUnit 버전들의 API
이번 글은 JUnit을 활용해 테스트를 활용하는 방법에 대한 글이기 때문에 JUnit에 대한 자세한 내용은 공식 문서에서 확인하도록 하자.
우선, 테스트를 진행하려면 그 대상이 될 소스 코드, 즉 기능이 있어야 하기 때문에 아래처럼 간단한 연산을 하는 메소드를 정의했다.
public class MathUtils {
public int add(int a, int b) {
return a + b;
}
public double computeCircleArea(double radius) {
return Math.PI * radius * radius;
}
public int subtract(int a, int b){
return a -b;
}
public int multiply(int a, int b){
return a * b;
}
public int divide(int a, int b){
return a / b;
}
}
테스트를 진행하는 코드는 전체 포스트의 가독성을 높이기 위해 따로 글을 작성하지 않고, 소스 코드를 읽기만 해도 이해가 가도록 주석처리 하였다.
package io.shlee7131;
import jdk.jfr.Enabled;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.condition.EnabledOnOs;
import org.junit.jupiter.api.condition.OS;
import static org.junit.jupiter.api.Assertions.*;
// 테스트를 돌리기 위해 MathUtilsTest 객체는 어떻게 생성되고 Run을 하는가?
// JUnit 에서는, 테스트 클래스의 테스트 메소드 당 1개씩의 테스트 객체를 만들어 실행
// 이로 인해, 각 테스트는 개별적으로 진행가능, 즉 테스트 메소드 당 독립적인 테스트 객체가 생성 및 실행
// 여기서 주의할 점은, 테스트 클래스의 멤버 변수가 여러 테스트 메소드에서 사용되며 값이 변경될 수 있는 지의 여부 판단
// 모두 독립적인 객체이기 때문에 테스트 진행 중 로직 상의 에러 발생 가능
// JUnit Life Cycle ( default : 메소드 단위 생명주기 ) + 관련 어노테이션
// 1. 테스트 클래스에서 객체 초기화 => @BeforeAll(객체 생성 전 최초 실행)
// 2. 테스트 메소드마다 시작되면 새로운 테스트 객체 생성, 메소드 실행 => @BeforeEach(각 메소드를 실행하기 전에 실행)
// 3. 테스트 메소드마다 종료되면 테스트 완료된 객체 삭제 => @AfterEach
// 4. 모든 메소드 종료 시 테스트 객체 삭제 => @AfterAll
// 테스트 객체 생성 옵션 조정 => @TestInstance(TestInstance.Lifecycle.PER_CLASS)
// Default 는 TestInstance.Lifecycle.PER_METHOD => 각 메소드마다 객체 생성
// TestInstance.Lifecycle.PER_CLASS => 단 하나의 테스트 객체에서 모든 테스트 메소드 실행
// Assert Message 최적화를 시키는 방법 => Supplier 사용 ( 람다 )
// ex) assertEquals(expected, actual, () -> "실패시에만 String 생성 및 출력");
@DisplayName("When running MathUtils")
class MathUtilsTest {
MathUtils mathUtils;
// 클래스 테스트 객체가 초기화되기 전에 최초 1회 실행
// 주의할 점은, 클래스의 객체가 없이도 실행되어야 한다 => 즉, static 이어야 한다.
// 참고로 각 메소드별 테스트에서는 실행되지 않는다.
@BeforeAll
static void beforeAllInit(){
System.out.println("This needs to run before all");
}
// 각 테스트 메소드가 실행하기 전마다 시작
// TestInfo 와 TestReporter 에 대한 의존성 주입 => 메타 데이터 활용
@BeforeEach
void init(TestInfo testInfo, TestReporter testReporter){
// Map 자료구조로써 테스트 시간과 testInfo 를 활용한 메타 데이터 정보 콘솔 출력 => 로그로 활용 가능
testReporter.publishEntry("This test is : " + testInfo.getDisplayName() + " with a Tag name " + testInfo.getTags());
mathUtils = new MathUtils();
}
@AfterEach
void cleanup() {
System.out.println("Cleaning up...");
}
// @Test 로 테스트 메소드임을 명시 => 이 어노테이션이 있는 메소드만 테스트 진행
// 단위 테스트 내부의 객체는 테스트 시작 시 생성, 종료 시 삭제된다.
// Tag 를 활용해 Test Configuration 에서 Tag 별로 테스트 템플릿 작성 => 별도 테스트 가능
// Tag 이름에 공백 없어야 함
@Test
@Tag("Math")
@DisplayName("Testing Add")
void testAdd() {
int expected = 2;
int actual = mathUtils.add(1,1);
// expected 와 actual 이 같은지 확인 => 다르면 예외 발생, 예외 메세지 커스텀 가능
// assertArrayEquals(expectedArray, actualArray) , assertIterableEquals(expectedArray, actualArray)
assertEquals(expected,actual, "Return value is not a expected one");
}
@Test
@Tag("Complicated")
@DisplayName("Testing Compute Circle Radius")
void testComputeCircleRadius(){
assertEquals(314.1592653589793, mathUtils.computeCircleArea(10),"Should return right circle area");
}
// 테스트 반복 테스트 => 반복 중 하나라도 실패하면 예외 발생
// @Test 대신에 @RepeatedTest 사용
// 메소드 매개로 반복정보 사용가능
@RepeatedTest(3)
@Tag("Math")
@DisplayName("Testing Divide")
void testDivide(RepetitionInfo repetitionInfo){
System.out.println("This is " + repetitionInfo.getCurrentRepetition() + " trial");
// 예외 처리 확인을 위한 테스트 => 실행에 대해 해당 예외를 던지는 것이 맞으면 success
// Fail 에 대한 Message 추가 가능(NullPointerException)
assertThrows(ArithmeticException.class, () -> {
mathUtils.divide(1,0);
}, "Should divide by Zero should throw");
}
// 스킵하기
@Test
@Tag("Failure_Check")
@Disabled
@DisplayName("TDD method. Should not run => Skip this !!")
void testDisabled(){
fail("This test should be disabled");
}
// OS 옵션 확인
@Test
@Tag("Failure_Check")
@EnabledOnOs(OS.LINUX)
@DisplayName("Run on Linux")
void testLinux(){
fail("This test should be disabled");
}
// 다중 테스트, 모두 다 통과하지 못하면 예외 발생
@Test
@DisplayName("Mulitply Method")
void testMultiply(){
assertAll(
()->assertEquals(4,mathUtils.multiply(2,2),"First"),
()->assertEquals(0,mathUtils.multiply(2,0),"Second"),
()->assertEquals(-2,mathUtils.multiply(2,-1),"Third")
);
}
// Group Multiple Test Methods => 모아서 관리 => 가독성 증가 및 테스트 분석 용이
@Nested
@Tag("Math")
@DisplayName("add method,")
class addTest{
@Test
@DisplayName("when adding two positive numbers")
void testAddPositive(){
assertEquals(2,mathUtils.add(1,1),"should return the right sum");
}
@Test
@DisplayName("when adding two negative numbers")
void testAddNegative() {
int expected = -2;
int actual = mathUtils.add(-1, -1);
// supplier(람다)를 사용해서 message 생성 조건을 실패 시로 한정, Default 는 항상 생성
assertEquals(expected, actual, () -> "should return the right sum" + expected + " but returned " + actual);
}
}
}
위 코드들은 이 분의 영상을 보면서 따로 주석을 넣어 보기 좋게 만든 것이니 원본이 궁금하면 여기를 참고하시길 바랍니다.
=> Java Brains의 JUnit 5 Basics 영상 보러가기