JUnit과 친해지기
왜 JUnit을 알아야 할까?
날이 갈수록 소프트웨어에 대한 개발자의 책임이 높아지는 동시에 자동화 테스트에 관한 관심이 높아지면서 이에 따라 다양한 테스트를 지원하는 프레임워크가 등장하고 있다.
특히나 Java는 곧 Spring이라는 말이 나올 정도로 Java 진영에서의 대표적인 프레임워크인 Spring 조차도 JUnit을 이용해 테스트를 만들어가며 개발됐다.
이처럼 JUnit을 통해 스프링을 개발한 것만으로도 JUnit이 자바 진영에서 대표적인 테스트 프레임워크를 입증하고 있고, 이는 자바 개발자가 JUnit을 이해하고 사용해야 하는지 알 수 있는 대목이다.
본 포스팅에선 JUnit의 동작 원리, 픽스처, 애노테이션, 단정문들에 대해 살펴볼 예정이다. 이 글에서 사용한 예제코드는 GitHub에 있으니 참고하면 이 글을 읽는 데 많은 도움이 될 것 같다.
JUnit
JUnit은 xUnit의 Java 버전으로 Java 진영에서의 단위 테스트를 작성할 수 있도록 다양한 애노테이션과 메소드를 지원하는 대표적인 테스트 프레임워크이다.
xUnit은 프로그래밍 언어의 접미사와 뒤에 -Unit이란 접두사가 붙는 여러 단위 테스트 프레임워크의 통칭이다. 이러한 xUnit 프레임워크의 주된 장점은 같은 테스트 코드를 여러 번 작성하지 않게 해주고, 그 결과가 어떠해야 하는지를 기억할 필요가 없게 하는 자동화된 해법을 제공한다는 것이다.
TDD의 아버지인 켄트 백이 SUnit(xUnit의 최초의 프레임워크)을 Smalltalk용으로 개발한 후, 이 프레임워크를 켄트 벡과 에릭 감마가 JUnit으로 포팅했다.
JUnit is a unit testing framework for the Java programming language. JUnit has been important in the development of test-driven development, and is one of a family of unit testing frameworks which is collectively known as xUnit that originated with SUnit. - Wikipedia JUnit
JUnit의 장점
JUnit을 사용하다 보면 여러 장점을 느낄 수 있는데 특히 켄트백에 의해 개발되어 곳곳에 TDD 관점에서 바라본 단위 테스트가 갖춰야 할 여러 중요 점들이 프레임워크에 고스란히 담겨있다. 단편적으로 TDD에서 단위 테스트 작성 시 TDD Cycle이 발생한다. 이 주기가 포함하고 있는 실패(red)단계는 테스트 코드 작성 시 해당 단계를 인지하는지에 대한 여부는 매우 중요하다. JUnit은 단위 테스트 작성 시 빈번하게 이뤄지는 세션 단위의 테스트 결과에 대한 단계(Green or Red)를 GUI를 통해 직관적으로 보여준다.
JUnit GUI
JUnit의 GUI는 테스트 메소드들의 검증 결과의 목록과 테스트 클래스의 내부에 실행된 테스트 메소드 수, 각각의 테스트 메소드의 검증 시간, 테스트가 실패한 원인에 대해 상세히 알 수 있다.
또한, JUnit은 .jar 파일로 설정이 간편하다. 프로젝트에 라이브러리만 추가하면 별도의 추가 세팅 없이 JUnit이 제공하는 기능을 사용할 수 있다. 이뿐만이 아니라 실제 코드에 불필요한 테스트 코드를 작성하지 않아도 JUnit에서 지원하는 애노테이션과 메소드만으로 테스트 결과를 검증할 수 있게 되어 결과적으로 테스트 코드의 관리가 수월해지고 코드 베이스가 깨끗해진다. 무엇보다 사용 방법이 쉬우므로 몇 가지 작성 규칙만 숙지한다면 쉽게 자동화된 단위 테스트를 작성할 수 있다.
- JAR - JUnit4 jar
- Maven - Maven JUnit4
테스트를 수행하는 방식
JUnit은 @Test가 붙은 메소드를 테스트 메소드로 인식하여 이를 추적하여 이를 자동화된 방법으로 검증하고 GUI를 통해 결괏값을 제시한다. 이러한 일련의 과정을 이해하려면 JUnit가 테스트를 수행하는 방식에 대해 알아야 한다.
JUnit이 하나의 테스트 클래스를 가져와 테스트를 수행하는 방식은 다음과 같다.
- 테스트 클래스에서
@Test public void
로 정의한 메소드를 모두 찾는다. - 테스트 클래스의 오브젝트를 하나 만든다.
setUp 단계
: @Before 메소드를 실행한다.@Test 단계
: @Test 메소드를 호출하고 테스트 결과를 저장해둔다.tearDown 단계
: @After 메소드를 실행한다.- 나머지 테스트 메소드에 대해 3~5번을 반복한다.
- 모든 테스트의 결과를 종합해서 돌려준다.
다음 실행순서를 보면 각각의 @Test 메소드가 실행되는 과정에서 setUp @Test tearDown
단계를 거치는 특정 주기가 발생한다. 이 주기를 JUnit의 메소드 단위 생명주기라한다. 일반적으로 이 주기의 내부 프로세스는 다음과 같다.
JUnit Life Cycle
JUnit Life Cycle
먼저 setUp 단계에선 테스트에 필요한 오브젝트 또는 환경을 정의하고 @Test 단계에서 setUp에서 정의한 정보를 전달받아 테스트 결과를 검증한다. 마지막으로 tearDown 단계에서 정보를 정리하거나 테스트 검증 후 취할 동작을 정의한다. 다음 검증할 @Test 메소드 또한 이러한 일련의 프로세스를 거친다.
특히 각각의 @Test 메소드를 호출하기 전에 setUp 단계에선 매번 테스트에 필요한 정보를 새로 정의한다. 이처럼 매번 테스트에 필요한 정보를 새로 정의하는 이유는 @Test 메소드 사이의 상호관계에서 발생하는 부작용을 방지하고 독립성을 보장하기 때문에 각각의 @Test 메소드는 독립적인 객체환경에서 동작할 수 있다.
여기까지 JUnit의 생명주기를 보았다. 실제 JUnit의 생명주기는 이보다 더 복잡하지만 여기까지 이해하고 넘어가도 테스트 코드를 작성하는 데 무리가 없다. 하지만 여기서 짚고 넘어가야 할 중요한 테스트 개념이 있다. 눈치챘을지 모르겠지만 앞서 설명에 픽스처라는 중요한 테스트 개념이 있었다.
Fixture
픽스처란 setUp 단계에서 정의한 정보를 뜻한다. 즉 테스트를 수행하는 데 필요한 정보나 오브젝트를 픽스쳐라 불린다. 테스트에서 픽스처를 정의하는 시점에 따라 테스트를 결과가 달라지기 때문에 픽스처의 정의 시점은 매우 중요하다. 이 때문에 setUp 단계가 각 메소드 단계 전에 실행할 것인지 또는 테스트 클래스가 실행되는 시점에 실행할 것인지에 따라 테스트 결과가 다를 수 있어 이러한 픽스처의 정의하는 시점을 관리하는 것은 매우 중요하다.
Fixture method
JUnit은 픽스처를 관리할 수 있는 테스트 애노테이션을 제공하고 있다. 이러한 테스트 애노테이션이 붙은 메소드를 픽스처 메소드라 부르는데 대표적으로 @Before
, @After
가 붙은 메소드가 이에 속한다.
JUnit Fixture Method
다음 그림의 Fixture
를 주의 깊게 보자. @Rule, @Before, @After 픽스처 메소드들은 테스트 메소드 호출 시점에 맞춰 픽스처를 관리해준다. 이러한 픽스처 메소드를 통해 여러 테스트 메소드들이 필요한 정보를 주입받아 테스트를 검증할 수 있다. 이러한 픽스처 메소드를 사용한 픽스처의 활용 방법은 테스트 코드에 중복된 픽스처를 관리함으로써 테스트 코드 베이스가 간결해진다.
@Rule, @Before, @After 픽스처 메소드들은 테스트 메소드 호출 시점에 맞춰 픽스처를 관리해준다. 그렇다면 테스트 클래스가 실행되는 시점에 픽스처를 관리할 순 없을까?
JUnit Fixture Method
물론 클래스 단위의 픽스처를 관리해주는 테스트 애노테이션이 있다.
@ClassRule, @BeforeClass, @AfterClass가 이에 해당한다. 이 테스트 애노테이션이 붙은 메소드는 테스트 클래스가 처음 실행되고 모든 테스트 메소드가 끝마치는 시점에 호출하여 이때 생성한 픽스처를 테스트 클래스 내부에 있는 모든 테스트 메소드가 이 픽스처의 영향을 받게 된다.
이처럼 JUnit이 제공하는 테스트 애노테이션을 통해 픽스처 시점을 쉽게 관리할 수 있고 이외에 다양한 상황을 고려하여 테스트 코드를 작성할 수 있도록 다양한 테스트 애노테이션을 제공하고 있다.
Annotations
JUnit은 테스트 애노테이션을 통해 다양한 상황을 테스트할 수 있는 환경을 손쉽게 만들어준다. 이 때문에 반드시 각각의 테스트 애노테이션의 목적을 이해하고 상황에 맞게 사용해야 한다.
JUnit Annotations
@Test - 테스트 메소드 정의
@Test 테스트 애노테이션은 메소드를 테스트 메소드로 정의한다. 앞서 테스트를 수행하는 방식
에서 설명했듯이 JUnit이 테스트 메소드를 인식하기 위해선 3가지 규칙을 준수하여 메소드를 작성해야 한다.
- 테스트할 메소드에 @Test 애노테이션을 지정한다.
- 접근제한자는 반드시 public으로 정의한다.
- 리턴 타입은 void로 테스트 메소드는 반환 값이 없다.
@Test public void test(){}
다음 @Test가 붙은 메소드를 JUnit으로 실행하면 테스트 메소드로 인식하고 검증하게 된다. 또한 @Test는 상황에 맞게 테스트를 할 수 있도록 2가지 옵션을 제공한다.
- 시간제한(timeout=long)
- 예외처리(expected=Class<? extends Throwable>)
첫 번째 시간제한 옵션은 테스트가 실행되는 시간을 제한을 둘 수 있다. 이 옵션은 while 같은 무한 루프를 포착하고 종료하는 데 유용하다. 테스트 메소드의 검증 시간을 제한하여 timeout 옵션에 지정한 ms(1000ms = 1초)보다 오래 걸리면 테스트 결과를 실패로 간주한다.
두 번째 예외 검증해야 하는 경우 사용할 수 있다. 테스트 메소드가 정의된 해당 예외를 throw하지 않거나 선언된 예외와 다른 예외를 throw하는 경우 테스트 결과를 실패로 간주한다.
@Test(timeout=100)
public void test1(){}
@Test(expected=IndexOutOfBoundsException.class)
public void test2(){}
@Ignore - 테스트의 비활성
@Ignore 테스트 애노테이션은 JUnit이 테스트 결과를 검증하는 시점에서 이 테스트 애노테이션이 붙은 테스트 메소드 또는 테스트 클래스를 비활성 시킨다.
일반적으로 테스트 케이스가 아직 적용이 되지 않은 경우 또는 테스트의 실행 시간이 너무 길어서 포함될 수 없는 경우에 해당 @Ignore을 활용한다. 테스트 클래스 내부에 비활성 시킨 테스트 메소드가 많을경우 이를 구분하기 위해 비활성화 시킨 이유를 작성할 수 있다.
@Ignore @Test public void test1(){}
@Ignore("기능을 쓰지 않음") @Test public void test2(){}
@BeforeClass, @AfterClass - 픽스처 정의(클래스 주기)
@BeforeClass와 @AfterClass가 붙은 메소드는 테스트 클래스를 실행할 때 제일 처음과 끝에 단 한 번만 호출한다. 이 메소드는 반드시 static 메소드로 정의한다.
일반적으로 @BeforeClass와 @AfterClass는 픽스처를 정의할 때 쓰인다. 테스트 메소드를 호출하는 순간마다 매번 인스턴스를 생성하는 JUnit의 생명주기 때문에 데이터베이스의 리소스를 받기 위해 매번 Connection을 하기엔 테스트 속도가 느려지게 된다.
이 때문에 클래스가 실행되는 최상단에 처음 픽스처를 정의하고 이를 모든 테스트 메소드에서 재정의하지 않고 사용한다.
@BeforeClass
public static void setUp() { db connect ... }
@AfterClass
public static void tearDown() { db disconnection... }
@Before, @After - 픽스처 정의(메소드 주기)
@Before와 @After가 붙은 메소드는 테스트 메소드가 실행되기 전후에 호출한다. 이 메소드는 테스트 메소드의 픽스처를 정의하는 데 사용된다.
일반적으로 @Before는 초깃값 입력, 클래스 초기화에 사용하고 @After는 임시 데이터 삭제, 기본값 복원에 사용한다.
@Before
public void init() { init data... }
@After
public void afterInit() { delete data... }
@ClassRule, @Rule - 공통적인 픽스처 클래스화
테스트 클래스가 많아질수록 매번 공통적인 픽스처 정의를 하기엔 불편하다. JUnit은 공통적인 픽스처를 정의하는 기능을 클래스로 만들어 활용할 수 있도록 도와준다. @ClassRule와 @Rule가 이에 해당한다.
단 이 테스트 애노테이션은 구현하기 위해 몇 가지 규칙을 준수해야 한다.
- 반드시 필드 또는 메소드에 TestRule.class을 구현한 클래스가 존재해야 한다.
- 접근제한자는 반드시 public으로 정의한다.
- 리턴 타입은 void로 테스트 메소드는 반환 값이 없다.
또한, @ClassRule은 @BeforeClass와 @AfterClass 실행 전후에 실행되고 앞서 설명한 클래스 주기에 정의하는 픽스처 메소드와 마찬가지로 반드시 static 메소드여야 된다. @Rule은 @Before와 @After 실행 전후에 실행된다.
JUnit은 DisableOnDebug
, ErrorCollector
, ExpectedException
, TemporaryFolder
등 TestRule.class을 구현한 클래스를 기본적으로 제공하고 있다.
@ClassRule public static DBConnectRule classRule;
@Rule public DBConnectRule rule = new DBConnectRule();
public class DBConnectRule implements TestRule {
db connect...
}
@RunWith, @FixMethodOrder - 클래스 확장과 실행 순서 제어
@RunWith에 명시된 클래스를 확장할 수 있다. JUnit은 각각의 테스트가 서로 영향을 주지 않고 독립적으로 실행한다. 때문에 각각의 @Test마다 오브젝트를 생성한다. 이와 같은 JUnit의 특성으로 인하여 ApplicationContext도 매번 느려지는 단점이 있다. 그러나 @RunWith은 각각의 테스트별로 오브젝트가 생성이 되더라도 싱글톤의 ApplicationContext를 보장하는 역할을 한다.
@FixMethodOrder는 클래스가 포함한 메소드의 실행 순서를 지정한다. 이 테스트 애노테이션은 JUnit 4.11 버전 이후 부터 지원한다. 이 테스트 애노테이션은 3가지 옵션을 지원한다.
- @FixMethodOrder (MethodSorters.Enum)
- DEFAULT : HashCode를 기반으로 순서가 결정되어 실행한다.
- JVM : JVM에서 반환 한 순서대로 실행한다.
- NAME_ASCENDING : 메소드 명을 오름차순으로 정렬한 순서로 실행한다.
@RunWith(SpringJUnit4ClassRunner.class)
@FixMethodOrder(MethodSorters.DEFAULT)
public class MemberControllerTest{
}
여기까지 JUnit의 전반적인 실행순서와 다양한 상황을 테스트할 수 있도록 지원하는 JUnit의 테스트 애노테이션을 살펴봤다. 이제 JUnit의 핵심인 자동화 테스트 검증에 대해 알아보자.
Assertions
JUnit은 Assert 클래스 내부의 메소드들을 통해 테스트를 자동화 검증을 할 수 있도록 지원하고 있다. Assert 메소드들은 다양한 데이터 타입의 검증을 지원하고 있으므로 각각의 데이터 타입에 맞게 사용해야 한다.
대부분 Assert 메소드들은 assert...(에러메시지, 기댓값, 비교 값)
이라는 간단한 규칙을 따르고 있다. 첫 번째 매개변수인 에러메시지는 모든 Assert 메소드들에서 정의할 수 있고 필수 사항이 아니므로 생략할 수 있다.
- org.junit.Assert.assertEquals
- org.junit.Assert.assertArrayEquals
- org.junit.Assert.assertSame
- org.junit.Assert.assertNotSame
- org.junit.Assert.assertTrue
- org.junit.Assert.assertFalse
- org.junit.Assert.assertNull
- org.junit.Assert.assertNotNull
- org.junit.Assert.fail
- org.junit.Assert.assertThat
assertEquals - 객체 검증
assertEquals는 2개의 객체가 같다고 가정한다. assertEquals(기대 값, 비교 값)을 규칙으로 데이터를 검증한다. 단 숫자 자료형 검증은 assertEquals(기댓값, 비교 값, 오차범위)을 규칙으로 한다.
@Test
public void assertEqualsTest(){
Object obj = "value";
String str = "value";
int a = 1;
float b = 1;
// Success
assertEquals("다형성" , obj, str);
assertEquals("실수 정수 값 비교" , a, b, 0); // 숫자 자료형 단정문 assertEquals(기대 값, 비교 값, 오차범위);
// faile
assertEquals("다른 정수 값 비교" , 2, a, 0); // java.lang.AssertionError: 다른 정수 값 비교 expected:<2.0> but was:<1.0>
}
assertArrayEquals - 배열 검증
assertArrayEquals는 2개의 배열이 같다고 가정한다. assertArrayEquals는 배열의 크기와 배열의 데이터를 검증한다.
@Test
public void assertArrayEqualsTest(){
Object[] ObjA = new Object[4];
String[] ObjB = new String[4];
String[] ObjC = new String[4];
String[] ObjSize3 = new String[3];
Object[] ObjValue = new String[]{"a","a","a","a"};
// Success
assertArrayEquals("다형성 ", ObjA, ObjB);
assertArrayEquals("같은 자료형 배열", ObjB, ObjC);
// failure
assertArrayEquals("크기가 다른 배열", ObjB, ObjSize3); // 크기가 다른 배열: array lengths differed, expected.length=4 actual.length=3
assertArrayEquals("다른 값 배열", ObjB, ObjValue); // 다른 값 배열: arrays first differed at element [0]; expected:<null> but was:<a>
}
그 이외의 단정문들
fail은 테스트를 강제 실패시킨다. 일반적으로 조건문을 통해 특정 값이 들어오면 강제 테스트 실패를 해야 할 때 사용한다.
assertThat은 기존의 값과 값을 검증하는 것이 아닌 비교 로직이 담긴 Matcher 통해 기댓값을 유연성 있게 검증할 수 있다. 예를 들어 “ABCD”라는 문자열에 “A”가 포함되어 있는지, 내부에 선언된 모든 매처가 정상일 경우 테스트가 통과된다든지 이처럼 유연성 있게 값을 검증할 수 있다.
assertSame은 앞서 설명한 assertEquals
메소드와 유사하면서도 분명한 차이점이 있다. assertEquals 메소드는 두 객체를 비교하지만, 반면 assertSame는 데이터도 비교하면서 같은 주솟값인지 검증한다.
Matcher는 assertThat는 org.hamcrest.CoreMatchers 클래스에 선언된 메서드를 통해 사용할 수 있다.
참고 JUnit DOC(org.hamcrest.core)
public class AssertMethodsTest {
boolean a, b;
boolean[] arrayA, arrayB;
@Before
public void setUp(){
a = true;
b = false;
arrayA = new boolean[]{true,true,false,false};
}
@Test public void assertTrueFalseTest(){
assertTrue("조건이 true인지 가정한다.", a);
assertFalse("조건이 false인지 가정한다.", b);
}
@Test public void assertSameNotSameTest(){
assertSame("두 객체가 동일한 객체를 참조하는지 가정한다.", a, arrayA[0]);
assertNotSame("두 객체가 동일하지 않는 객체를 참조하는지 가정한다.", a, arrayA[3]);
}
@Test public void assertNullNotNullTest(){
assertNull("객체가 null임을 가정한다.", arrayB);
assertNotNull("객체가 null이 아님을 가정한다.", arrayA);
}
@Test public void assertThatTest(){
assertThat("기대 값과 비교로직이 담긴 Matcher를 동일하다고 가정한다.", a, is(true));
}
@Test public void failTest(){
fail("강제로 테스트를 실패한다.");
}
}
JUnit Life Cycle 테스트 코드
지금까지 설명했던 테스트 애노테이션을 활용한 테스트 코드를 통해 JUnit의 생명주기를 살펴보자.
public class JUnitLifeCycleTest {
@ClassRule public static CustomRule classRule = new CustomRule("@ClassRule");
@Rule public CustomRule rule = new CustomRule("@Rule");
// @BeforeClass 단 한번만 실행된다. 보통 DB연결을 할때 쓰인다.
@BeforeClass public static void beforeClass(){
System.out.println("@BeforeClass");
}
// @Before 테스트 환경 구성 (입력 데이터 초깃값, 클래스 초기화를 사용하는데 사용된다.)
@Before public void before(){
System.out.print(" > @Before ");
}
// 해당 메소드를 Test 메소드로 인식
@Test public void testMethod1(){
System.out.print(" > @Test test1 ");
}
// 해당 메소드를 Test 메소드로 인식
@Test public void testMethod2(){
System.out.print(" > @Test test2 ");
}
// @Ignore, @Ignore("ignore Test") Test 제외 시킨 이유를 정의 할 수 있다.
@Ignore("ignore Test")
@Test public void testMethod3(){
System.out.print(" > @Ignore ");
}
// @After 테스트 환경 정리(임시 데이터 삭제, 기본값 복원, 비싼 메모리 구조를 정리하여 메모리 절약 등)
@After public void after(){
System.out.print(" > @After ");
}
// @AfterClass 단 한번만 실행된다. 정리 작업을 수행하는 데 사용된다.(보통 DB 연결을 끊을때 쓰인다.)
@AfterClass public static void afterClass(){
System.out.println("@AfterClass");
}
}
public class CustomRule implements TestRule {
String annotation;
public CustomRule(String annotation){
this.annotation = annotation;
}
@Override
public Statement apply(final Statement testMethods, Description description) {
return new Statement() {
@Override
public void evaluate() throws Throwable {
System.out.print(startRules(annotation)); //code here executes before test runs
testMethods.evaluate(); // test가 실행되는 지점 (@Test)
System.out.print(endRules(annotation)); //code here executes after test is finished
}
};
}
private String startRules(String annotation){
String str = "";
if(annotation.equals("@Rule")){
str = "> start testMethod : " + annotation;
}else{
str = annotation + "\n";
}
return str;
}
private String endRules(String annotation){
String str = "";
if(annotation.equals("@Rule")){
str = " > " + annotation + " : end \n";
}else{
str = annotation + "\n";
}
return str;
}
}
JUnit Life Cycle 테스트 결과
JUnit 생명주기 테스트 결과
테스트의 결과를 보면 다음과 같다.
@ClassRule
,@BeforeClass
메소드가 호출된다.@Test
메소드가 호출 될때마다@Rule
->@Before
->@Test
->@After
->@Rule
순서로 메소드를 호출한다.@AfterClass
,@ClassRule
메소드가 호출된다.
마무리
마침내 JUnit에 전반적인 흐름과 테스트 애노테이션, 단정문 메소드들에 대해 알아봤다. 앞서 설명한 내용들은 JUnit 버전 4.12 기준으로 작성하였다. JUnit5는 수정된 기능부터 새로 추가된 기능이 많아 사용하기엔 적절치 않다고 판단하여 JUnit4로 선택하게 되었다.
JUnit4의 생명주기는 JUnit5에서도 같고 기능 또한 JUni4의 기존 기능을 개선하거나 새로 도입된 기능이기 때문에 JUnit5를 입문하기 전에 먼저 JUnit4를 이해한다면 도움이 된다.
이 글을 읽는 것만으론 JUnit과 친해졌다고 말할 수 없다. 무엇보다 JUnit은 시작이 중요하다. 앞서 설명한 테스트 애노테이션과 단정문들을 적용하며 테스트 코드를 한 번이라도 작성해보는 것을 추천한다.
참고
JUnit4 doc
JUnit5 doc
https://www.guru99.com/unit-testing-guide.html
https://content.pivotal.io/blog/what-is-a-unit-test-the-answer-might-surprise-you
JUnit Parameterized-tests