Search
Duplicate
🍃

2.3 개발자를 위한 테스팅 프레임워크 JUNIT

태그

2.3.1 JUnit 테스트 실행방법

JUnitCore를 이용해 테스트를 실행하고 콘솔에 출력된 메시지를 보고 결과를 확인하는 방법은 가장 간단하기는 하지만 테스트 수가 많아지면 관리하기가 힘들어진다는 단점이 있다.
가장 좋은 JUnit 테스트 실행 방법은 자바 IDE에 내장된 JUnit 테스트 지원 도구를 이용하는 것이다.

IDE - Eclipse

테스트 수행 방법
1.
@Test가 들어있는 테스트 클래스를 선택
2.
run 메뉴 → Run As → JUnit 테스트 선택
Junit 테스트 뷰에서 확인할 수 있는 정보
1.
테스트의 총 수행시간
2.
실행한 테스트의 수
3.
테스트 에러의 수
4.
테스트 실패의 수
5.
실행한 테스트 클래스의 종류
6.
@Test 가 붙은 메서드의 이름
7.
각 테스트 메서드와 클래스 테스트 수행에 걸린 시간

빌드 툴

프로젝트의 빌드를 위해 ANT나 메이븐(Maven)과 같은 빌드 툴과 스크립트를 사용하고 있다면, 빌드 툴에서 제공하는 JUnit 플러그인이나 태스크를 이용해서 JUnit 테스트를 실행할 수 있다. 테스트 실행 결과는 옵션에 따라 HTML이나 텍스트 파일의 형태로 만들어진다.
개발자 개인별로 테스트를 진행하는 경우에는 IDE에서 JUnit 도구를 활용해 테스트를 실행하는 것이 가장 편리하다. 하지만 여러 개발자가 만든 코드를 모두 통합해서 테스트를 수행하는 경우에는 서버에서 모든 코드를 가져와 통합하고 빌드한 뒤에 테스트를 수행하는 것이 좋다. 이때는 빌드 스크립트를 이용해 JUnit 테스트를 실행하고 그 결과를 메일 등으로 통보받는 방법을 사용하면 된다.

2.3.2 테스트 결과의 일관성

테스트를 수행하는 데 있어서 중요한 점은 테스트가 외부 상태에 따라 성공하기도 하고 실패하는 일이 없어야 한다는 것이다. DB 서버가 다운됐다거나 네트워크에 장애가 생겨서 DB에 접근하지 못하는 예외적인 상황을 제외하고서, 반복적으로 테스트를 수행했을 때 테스트가 실패하기도 하고 성공하기도 한다면 이는 좋은 테스트라고 볼 수 없다. 코드에 변경사항이 없다면 테스트는 항상 동일한 결과를 내야 한다. 가장 좋은 해결책은 테스트를 마치고 나면 테스트가 등록한 사용자 정보를 삭제해서, 테스트를 수행하기 이전 상태로 만들어주는 것이다.
deleteAll()
USER 테이블의 모든 레코드를 삭제해주는 간단한 기능을 가진 메서드이다.
public void deleteAll() throws SQLException{ Connection c = dataSource.getConnection(); PreparedStatement ps = c.prepareStatement("delete from users"); ps.executeUpdate(); ps.close(); c.close(); }
Java
복사
getCount()
USER 테이블의 레코드 개수를 돌려준다.
public int getCount() throws SQLException{ Connection c = dataSource.getConnection(); PreparedStatement ps = c.prepareStatement("select count(*) from users"); ResultSet rs = ps.executeQuery(); rs.next(); int count = rs.getInt(1); rs.close(); ps.close(); c.close(); return count; }
Java
복사

deleteAll()과 getCount()의 테스트

@Test public void addAndGet() throws SQLException{ ... dao.deleteAll(); assertThat(dao.getCount(), is(0)); // deleteAll() 메서드가 제대로 구현되었는지를 검증 User user = new User(); user.setId("lksa4e"); user.setName("김경준"); user.setPassword("sprintno1"); dao.add(user); assertThat(dao.getCount(), is(1)); // getCount() 메서드가 구현되었는지를 검증 User user2 = dao.get(user.getId()); assertThat(user2.getName(), is(user.getName())); assertThat(user2.getPassword(), is(user.getPassword())); }
Java
복사
단순하게 deleteAll() 메서드와 getCount() 메서드를 구현했다고 끝나지 않는다. 두 개의 메서드가 자체적으로 검증이 안되었기 때문에 다른 메서드에 적용할 수는 없다.
deleteAll() 메서드의 검증은 메서드 호출 후 getCount() 메서드를 호출하여 DB 테이블의 레코드 개수가 0개인지를 확인하는 것을 통해 확인할 수 있다. 하지만 이 상황에서 getCount() 메서드의 검증이 아직 끝나지 않기 때문에 deleteAll() 메서드의 검증에 활용할 수 없다.
이 상황에서 getCount()에 대한 검증 작업을 추가함으로써 getCount() 메서드의 검증을 확인할 수 있다. 이미 앞에서 add() 메서드가 정상적으로 DB 테이블에 데이터를 넣는 것을 확인했으니 add()를 수행하고 난 뒤에 getCount()를 호출했을 때 결과값이 0 → 1 이 된다면 getCount()의 기능이 올바르게 작동한다고 볼 수 있다. 이를 적용한 코드는 위와 같다.

동일한 결과를 보장하는 테스트

이전에는 테스트를 하기 전에 매번 직접 DB에서 데이터를 삭제해야 했지만, 이제는 그런 번거로운 과정이 필요 없어졌다. 테스트가 어떤 상황에서 반복적으로 실행된다고 하더라도 동일한 결과가 나올 수 있게 된 것이다.
동일한 테스트 결과를 얻을 수 있는 다른 방법도 존재한다. addAndGet() 테스트를 마치기 직전에 테스트가 변경하거나 추가한 데이터를 모두 원래 상태로 만들어주는 것이다. 하지만 addAndGet() 테스트만 DB를 사용하는 것이 아니라면 이전에 어떤 작업이 수행되었는지 알 수 없다. 따라서 다른 이유로 USER 테이블에 데이터가 들어가 있을 수 있고, 이때는 테스트가 실패할 수도 있다. 따라서 테스트하기 전에 테스트 실행에 문제가 되지 않는 상태를 만들어주는 것이 좋다.
단위 테스트는 항상 일관성 있는 결과가 보장돼야 한다는 점을 잊어서는 안된다. DB에 남아있는 데이터와 같은 외부 환경에 영향을 받지 말아야 하는 것은 물론이고, 테스트를 실행하는 순서를 바꿔도 동일한 결과가 보장되도록 만들어야 한다.

2.3.3 포괄적인 테스트

앞에서 getCount() 메서드를 테스트에 적용하긴 했지만, 기존의 테스트에서 확인할 수 있었던 것은 deleteAll() 을 실행했을 때 테이블이 비어 있는 경우<0>와 add()를 할 번 호출한 뒤의 결과 <1> 뿐이다. 따라서 두 개 이상의 레코드를 add() 했을때의 getCount()의 실행 결과를 제대로 검증할 수가 없다.
테스트를 안 만드는 것도 위험한 일이지만, 성의없이 테스트를 만드는 바람에 문제가 있는 코드인데도 테스트가 성공하게 만드는 건 더 위험하다. 특히 한 가지 결과만 검증하고 마는 것은 상당히 위험하다.

getCount() 테스트

getCount()에 대한 조금 더 꼼꼼한 테스트를 위해 여러 개의 User를 등록해가면서 getCount()의 결과를 매번 확인해본다. 테스트 메서드는 한 번에 한 가지 검증 목적에만 충실한 것이 좋다. 그러므로 getCount()를 위한 새로운 테스트 메서드를 만들어서 테스트를 진행한다.
JUnit은 하나의 클래스 안에 여러 개의 테스트 메서드가 들어가는 것을 허용한다.
조건
1.
@Test 어노테이션 사용
2.
public 접근자를 사용
3.
리턴 값이 void
4.
파라미터가 없어야 한다.
테스트 시나리오
1.
USER 테이블의 데이터를 모두 지우고 getCount()로 레코드의 개수가 0임을 확인
2.
3개의 사용자 정보를 하나씩 추가하면서 매번 getCount()의 결과가 하나씩 증가하는지 확인
@Test public voidcount() throws SQLException{ ApplicationContext context = new GenericXmlApplicationContext( "applicationContext.xml"); UserDao dao = context.getBean("userDao", UserDao.class); User user1 = new User("lksa4e", "김경준", "springno1"); User user2 = new User("leessafy", "이싸피", "springno2"); User user3 = new User("kimssafy", "김싸피", "springno3"); dao.deleteAll(); assertThat(dao.getCount(), is(0)); dao.add(user1); assertThat(dao.getCount(), is(1)); dao.add(user2); assertThat(dao.getCount(), is(2)); dao.add(user3); assertThat(dao.getCount(), is(3));
Java
복사
주의해야 할 점은 두 개의 테스트가 어떤 순서로 실행될지는 알 수 없다는 것이다. JUnit은 특정한 테스트 메서드의 실행 순서를 보장해주지 않는다. 테스트의 결과가 테스트 실행 순서에 영향을 받는다면 테스트를 잘못 만든 것이다.
예를 들어 addAndGet() 메서드에서 등록한 사용자 정보를 count() 테스트에서 활용하는 식으로 테스트를 만들면 안된다. 모든 테스트는 실행 순서에 상관없이 독립적으로 항상 동일한 결과를 낼 수 있도록 해야 한다.
위의 테스트를 통해 add() 후 레코드 개수를 확인했고, get()으로 읽어와서 값도 모두 비교하는 과정을 거치면서 add()의 기능을 충분히 검증했다. 위와 동일한 방식으로 여러 개의 user 객체를 선언하고 get() 하는 테스트를 통해 파라미터로 주어진 id에 해당하는 사용자를 제대로 가져오는지에 대한 get()의 기능도 검증할 수 있다.

get() 예외조건에 대한 테스트

테스트를 진행하면서 예외조건에 대한 고려도 해 주어야 한다. 만약 get() 메서드에 전달된 id 값에 해당하는 사용자 정보가 없다면 어떻게 처리해야 되는지에 대한 고려가 필요하다. 하나는 null과 같은 특별한 값을 리턴하는 것이고, 다른 하나는 id에 해당하는 정보를 찾을 수 없다고 예외를 던지는 것이 있다. 각각은 모두 고유한 장단점이 존재한다. 아래의 예외를 던지는 방법을 활용한 예시이다.
주어진 id에 해당하는 정보가 없다는 의미를 가진 예외 클래스가 하나 필요하다. 예외를 하나 정의하는 방법도 있지만, 스프링에서는 미리 정의해 둔 예외 클래스가 존재한다. 스프링이 정의한 데이터 액세스 예외 클래스들이 많이 존재하고 그 중 하나는 EmptyResultDataAccessException 예외이다.
UserDao의 get() 메서드에서 쿼리를 실행해 결과를 가져왔을 때 아무것도 없으면 예외를 던지도록 구성한다. 하지만 이런 경우에 어떻게 테스트 코드를 만들지를 고민해야 한다.
일반적으로 테스트 중에 예외가 던져지면 테스트 메서드의 실행은 중단되고 테스트는 실패한다. 하지만 이번에는 반대로 테스트 진행 중에 특정 예외가 던져지면 테스트가 성공한 것이고, 던져지지 않고 정상적으로 작업을 마치면 테스트가 실패했다고 판단해야 한다. 문제는 예외 발생 여부는 메서드를 실행해서 리턴 값을 비교하는 방법으로 확인할 수 없다는 점이고, 이런 상황에서 assertThat() 메서드로는 검증이 불가능하다.
JUnit은 이런 경우를 위해 예외조건 테스트를 위한 특별한 방법을 제공해 준다. JUnit 예외 테스트의 기능을 이용하면 아래와 같이 테스트를 만들 수 있다.
@Test(expected=EmptyResultDataAccessException.class) // 테스트 중 발생할 것으로 기대하는 예외 클래스 지정 public void getUserFailure() throws SQLException{ ApplicationContext context = new GenericXmlApplicationContext( "applicationContext.xml"); UserDao dao = context.getBean("userDao", UserDao.class); dao.deleteAll(); assertThat(dao.getCount(), is(0)); dao.get("unknown_id"); // 이 메서드 실행 중에 예외가 발생해야 함 }
Java
복사
@Test Annotaion의 expected 엘리먼트
@Test에 expected를 추가해 놓으면 보통의 테스트와는 반대로, 정상적으로 테스트 메서드를 마치면 테스트가 실패하고, expected에서 지정한 예외가 던져지면 테스트가 성공한다. 예외가 반드시 발생해야 하는 경우를 테스트하고 싶을 때 유용하게 쓸 수 있다.

2.3.4 테스트가 이끄는 개발(TDD)

앞서 테스트를 진행하는 과정에서 순서를 잘 살펴보면 일반적인 개발 순서와 다르게 테스트를 먼저 만들어 테스트가 실패하는 것을 보고 나서 UserDao의 코드를 수정했다. 이런 순서를 따르는 개발 전략이 존재한다.

테스트 주도 개발

TDD(Test Driven Development)란 만들고자 하는 기능의 내용을 담고 있으면서 만들어진 코드를 검증도 해줄 수 있도록 테스트 코드를 먼저 만들고, 테스트를 성공하게 해주는 코드를 작성하는 방식의 개발 방법을 의미한다.
TDD는 아예 테스트를 먼저 만들고 그 테스트가 성공하도록 하는 코드만 만드는 식으로 진행하기 때문에 테스트를 빼먹지 않고 꼼꼼하게 만들어낼 수 있다. 또한 테스트를 작성하는 시간과 애플리케이션 코드를 작성하는 시간의 간격이 짧아진다. (사실은 테스트를 이미 만들었기 때문에 0) 그 덕분에 코드에 대한 피드백을 매우 빠르게 받을 수 있다.
TDD를 하면 자연스럽게 단위 테스트를 만들 수 있다. 빠르게 자동으로 실행할 수 있는 단위 테스트가 아니고서는 이런 식의 개발은 거의 불가능하다. 테스트할 때마다 서버를 띄우고 재배치하는 시간이 필요하다면, 테스트를 자주 하기 귀찮을 테고 테스트는 점점 뒤로 미뤄질 것이다.
TDD의 또다른 장점 중 하나는 코드를 만들어 테스트를 실행하는 그 사이의 간격이 매우 짧기 때문에 개발한 코드의 오류를 빨리 발견할 수 있다. 테스트 없이 오랜 시간 동안 코드를 만들고 나서 테스트를 하면, 오류가 발생했을 때 원인을 찾기가 힘들다. (예외는 A에서 발생했는데 발견은 Z에서 할 수도 있음)
테스트를 만들고 자주 실행하면 개발이 지연되지 않을까 걱정할 수도 있다. 하지만 테스트는 애플리케이션 코드보다 상대적으로 작성하기 쉬운데다 각 테스트가 독립적이기 때문에, 코드의 양에 비해 작성하는 시간은 얼마 걸리지 않는다. 게다가 테스트 덕분에 오류를 빨리 잡아낼 수 있어서 전체적인 개발 속도는 오히려 빨라진다.

2.3.5 테스트 코드 개선

JUnit 프레임워크는 테스트 메서드를 실행할 때 부가적으로 해주는 작업이 몇 가지 존재한다. 그중에서 테스트를 실행할 때마다 반복되는 준비 작업을 별도의 메서드에 넣게 해주고, 이를 매번 테스트 메서드를 실행하기 전에 먼저 실행시켜주는 기능이 존재한다.

JUnit이 테스트를 수행하는 순서

1.
'@Test && public void && no parameters'인 method를 모두 찾는다.
2.
테스트의 대상이 되는 클래스의 Object를 하나 만든다.
3.
@Before 메소드가 있다면 실행
4.
@Test 메소드를 하나 실행하고 결과를 저장
5.
@After 메소드가 있다면 실행
6.
모든 @Test 메소드에 대해 2 ~ 5 Steps 반복
7.
테스트 결과를 취합해 리턴
@Before/@After method에서 메서드를 테스트 메서드에서 직접 호출하지 않기 때문에 서로 주고받을 정보나 오브젝트가 있다면 인스턴스 변수를 이용해야 한다. (UserDaoTest에서는 스프링 컨테이너에서 가져온 UserDao 오브젝트를 인스턴스 변수 dao에 저장해뒀다가, 각 테스트 메서드에서 사용하도록 함)
JUnit에서 제공하는 기능을 활용하여 작성한 코드는 아래와 같다.
import org.junit.Before; ... public class UserDaoTest{ private UserDao dao; // setUp() 메서드에서 만드는 오브젝트를 테스트 메서드에서 // 사용할 수 있도록 인스턴스 변수로 선언 @Before // @Test 메서드가 실행되기 전에 먼저 실행되어야 하는 메서드 정의 public void setUp(){ ApplicationContext context = new GenericXmlApplicationContext( "applicationContext.xml"); this.dao = context.getBean("userDao", UserDao.class); } ... @Test public void addAndGet() throws SQLException{ // 테스트 실행 구문 제일 처음에 @Before의 코드가 수행 ... } @Test public void count() throws SQLException{ ... } @Test(expected=EmptyResultDataAccessException.class) public void getUserFailure() throws SQLException{ ... }
Java
복사

테스트 메서드를 실행할 때마다 새로운 오브젝트를 만드는 이유?

JUnit은 각 테스트가 서로 영향을 주지 않고 독립적으로 실행됨을 확실하게 보장하기 위해 매번 새로운 오브젝트를 만들도록 한다. 어차피 다음 테스트 메서드가 실행될 때에 새로운 오브젝트가 만들어져서 전부 초기화되기 때문에 인스턴스 변수도 부담없이 사용할 수 있다.
하지만 테스트 메서드의 일부에서만 공통적으로 사용되는 코드가 있는 경우에는 @Before를 사용하기보다는, 일반적인 메서드 추출 방법을 써서 메서드를 분리하고 테스트 메서드에서 직접 호출해서 사용하도록 만드는 것이 낫다.

픽스처

테스트를 수행하는 데 필요한 정보나 오브젝트를 픽스처(Fixture)라고 한다. 일반적으로 픽스처는 여러 테스트에서 반복적으로 사용되기 때문에 @Before 메서드를 이용하여 생성해 두면 편리하고 중복을 제거할 수 있다. UserDaoTest 코드에서 dao가 대표적인 픽스처의 예시이다. 또한, 테스트 중에 add() 메서드에 전달하는 User 오브젝트들도 픽스처라고 볼 수 있다.
아래는 픽스처를 적용한 예시이다.
public class UserDaoTest{ private UserDao dao; private User user1; private User user2; private User user3; @Before public void setUp(){ ... this.user1 = new User("lksa4e", "김경준", "springno1"); this.user2 = new User("leessafy", "이싸피", "springno2"); this.user3 = new User("kimssafy", "김싸피", "springno3"); } ... }
Java
복사