테스트의 필요성
테스트란 결국 내가 예상하고 의도했던 대로 코드가 정확히 동작하는지를 확인해서, 만든 코드를 확신할 수 있게 해주는 작업이다. 보통 웹 프로그램에는 웹 화면을 통해 값을 입력하고 기능을 수행하고, 결과를 확인하는 식으로 테스트한다. 하지만 이는 DAO에 대한 테스트로서는 단점이 너무 많다.
테스트하고 싶었던 건 UserDao였는데 다른 계층의 코드와 컴포넌트, 서버 설정 상태까지 모두 테스트에 영향을 미치기 때문에 이런 방식으로 테스트를 하게 되면, 정확한 오류 지점을 파악하기 힘들다.
따라서 테스트하고자 하는 대상이 명확하다면 그 대상에만 집중해서 테스트하는 것이 바람직하다. 테스트를 할 때도 관심사의 분리 원리가 적용된다. 작은 단위의 코드에 대해 테스트를 수행하는 것을 단위 테스트라고 한다. 또한 테스트는 자동으로 수행되도록 코드로 만들어지는 것이 중요하다.
UserDaoTest의 문제점
UserDaoTest가 UI까지 동반되는 번거로운 수동 테스트에 비해 장점이 많지만, 몇 가지 문제점이 있다.
- 수동 확인 작업의 번거로움
- → 테스트를 수행하는 과정과 입력 데이터의 준비를 모두 자동으로 하지만, 사람의 눈으로 확인하는 과정이 필요하다. User정보를 DB에 등록하고 이를 get메소드를 이용해 가져왔을 때 입력값과 출력값이 일치하는지를 사람이 직접 확인해야 한다.
- 실행 작업의 번거로움
- 아무리 실행이 간단한 main() 메소드라고 하더라도 매번 그것을 실행하는 것은 번거롭다. 특히 전체 기능을 위해 수백 번 실행해야 한다면, main() 메소드를 이용하는 방법보다 좀 더 편리하고 체계적으로 테스트를 실행하고 그 결과를 확인할 방법이 필요하다.
테스트 결과 검증
UserDaoTest의 첫 번째 문제점인 테스트 결과의 검증 부분을 코드로 만들어보자. 이 테스트를 통해 확인하고 싶은 사항은 add()에 전달한 User 오브젝트에 담긴 사용자 정보와 get()을 통해 DB에서 가져온 User 오브젝트의 정보가 서로 일치하는가이다.
테스트 코드에서 결과를 직접확인하고, 기대한 결과와 다르면 '테스트 실패'라는 메세지를 출력하도록 코드를 수정해보자.
System.out.println(user2.getName());
System.out.println(user2.getPassword());
System.out.println(user2.getId() + " 조회 성공");
다음과 같이 수정한다.
if(!user.getName().equals(user2.getName())) {
System.out.println("테스트 실패 (name)");
} else if (!user.getPassword().equals(user2.getPassword())) {
System.out.println("테스트 실패 (password)");
} else {
System.out.println("조회 테스트 성공");
}
수정한 후에는 '조회 테스트 성공'이라는 메세지가 콘솔 창에 떴는지 확인만 하면 된다. 이제 main() 메소드로 만든 테스트는 테스트로서 필요한 기능은 모두 갖추었다. 하지만 좀 더 편리하게 테스트를 수행하고 결과를 확인하려면 단순한 main() 메소드로는 한계가 있다. 이를 위해 자바에서는 자바 테스팅 프레임워크라고 불리는 JUnit을 지원한다.
JUnit 테스터로 전환
새로 만들 테스트 메소드는 JUnit 프레임워크가 요구하는 두 가지 조건을 충족해야 한다.
- 메소드가 public으로 선언되어야 한다.
- 메소드에 @Test 라는 어노테이션을 붙여주어야 한다.
@Test
public void addAndSet() throws SQLException {
ApplicationContext context = new ClassPathXmlApplicationContext("applicationContext.xml");
UserDao dao = context.getBean("userDao", UserDao.class);
}
테스트의 결과를 검증하는 if/else 문장을 JUnit이 제공하는 방법을 이용해 전환해보자. 이 때 asserThat이라는 스태틱 메소드를 이용하여 다음과 같이 변경할 수 있다. (JUnit5부터 assertEquals)
Assertions.assertEquals(user2.getName(), user.getName());
assertEquals는 파라미터로 넘어온 2개의 값을 비교하여 테스트 실패 또는 성공을 알려준다.
다음 테스트를 위해 UserDao 클래스에 테이블을 초기화 해주는 코드를 추가한다.
public voiddeleteAll()throwsSQLException, ClassNotFoundException {
Connection c = connectionMaker.makeConnection();
PreparedStatement ps = c.prepareStatement("delete from users");
ps.executeUpdate();
ps.close();
c.close();
}
추가 문제점
지금까지 테스트하면서 불편했던 일이 있다. 매번 테스트 실행 하기 전에 DB의 USER 테이블 데이터를 모두 삭제해주는 일이다. 여기서 생각해볼 문제는 테스트가 외부 상태에 따라 성공하기도 하고 실패하기도 한 다는 점이다. 반복적으로 테스트를 했을 때 테스트가 실패하기도 하고 성공하기도 한다면 이는 좋은 테스트라고 할 수가 없다.
UserDaoTest의 문제는 이전 테스트 때문에 DB에 등록된 중복 데이터가 있을 수 있다는 점이다. 가장 좋은 해결책은 addAndGet() 테스트를 마치고 나면 테스트가 등록한 사용자 정보를 삭제해서, 테스트를 수행하기 이전 상태로 만들어주는 것이다.
포괄적인 테스트
getCount() 메소드를 테스트에 적용하긴 했지만 기존의 테스트에서 확인할 수 있었던 것은 deleteAll()을 실행했을 때 테이블이 비어 있는 경우와 add()를 한 번 호출한 뒤의 결과뿐이다.
즉, 1과 0뿐이다. 이렇게 한 가지 결과만 검증하는 것은 상당히 위험하다. 따라서 getCount()에 대한 꼼꼼한 테스트를 만들어보자.
먼저 USER 테이블을 모두 지우고 getCount()로 레코드 수가 0개임을 확인한다. 그리고 3개의 사용자 정보를 하나씩 추가하면서 getCount()의 결과가 하나씩 증가하는지 확인하자.
@Test
public void count() throws SQLException, ClassNotFoundException {
ApplicationContext context = new GenericXmlApplicationContext("applicationContext.xml");
UserDao dao = context.getBean("userDao", UserDao.class);
User user1 = new User("gyumee", "박성철", " springno1");
User user2 = new User("leegw700", "이길원", "springno2");
User user3 = new User("bumjin", "박범진", " 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));
}
테스트를 할 때 두 개의 테스트가 어떤 순서로 실행되는지는 알 수 없다. 만약에 테스트의 결과가 테스트 실행 순서에 영향을 받는다면 테스트를 잘못 만든 것이다. 따라서 모든 테스트는 실행 순서에 상관없이 독립적으로 항상 동일한 결과를 낼 수 있도록 해야한다.
id를 조건으로 해서 사용자를 검색하는 기능을 가진 get()에 대한 테스트는 조금 더 보완할 필요가 있다. get()이 파라미터로 주어진 id에 해당하는 사용자를 가져온 것인지 증명해야 한다.
User userget1 = dao.get(user1.getId());
assertThat(userget1.getName(), is(user1.getName()));
assertThat(userget1.getPassword(), is(user1.getPassword()));
User userget2 = dao.get(user2.getId());
assertThat(userget2.getName(), is(user2.getName()));
assertThat(userget2.getPassword(), is(user2.getPassword()));
get() 메소드에 전달된 id값에 해당하는 사용자 정보가 없다면 어떻게 될까? 이 때 두 가지 방법이 있다. 하나는 null과 같은 특별한 값을 리턴하는 것이고, 다른 하나는 id에 해당하는 정보를 찾을 수 없다고 예외를 던지는 것이다. 여기서는 후자의 방법을 사용해보자. 이 때 스프링이 제공하는 데이터 엑세스 예외 클래스인 EmptyResultDataAccessException 를 이용할 수 있다.
테스트 예외처리
지금까지 테스트 중에 예외가 던져지면 테스트 메소드의 실행은 중단되고 테스트는 실패한다. 하지만 이번에는 반대로 테스트 진행 중에 특정 예외가 던져지면 테스트가 성공한 것이다. 문제는 예외 발생 여부를 메소드를 실행해서 리턴 값을 비교하는 방법으로 확인할 수 없다는 점이다. 이를 위해 JUnit은 assertThat이 아닌 특별한 방법을 제공해준다. @Test 애노테이션의 expected 앨리먼트를 이용한다. expected는 테스트 메소드 실행 중에 발생하리라 기대하는 예외 클래스를 넣어주면 된다.
@Test
public void getUserFailure() throws SQLException, ClassNotFoundException {
dao.deleteAll();
assertEquals(0 ,dao.getCount());
assertThrows(EmptyResultDataAccessException.class, () -> dao.get("unknown_id"));
}
하지만 이 코드를 실행하면 테스트는 실패한다. get() 메소드에서 쿼리 결과의 첫 번째 로우를 가져오게 하는 rs.next()를 실행할 때 가져올 로우가 없다는 SQLException이 발생한다. 이를 위해 get() 메소드 코드를 수정해보자
ResultSet rs = ps.executeQuery();
User user = null;
if(rs.next()) {
user = new User();
user.setId(rs.getString("id"));
user.setName(rs.getString("name"));
user.setPassword(rs.getString("password"));
}
if(user == null) throw new EmptyResultDataAccessException(1);
사람은 긍정적인 모습만 생각하고 코드를 작성하기 때문에 테스트를 작성할 때는 부정적인 케이스를 먼저 만드는 습관을 들이는 게 좋다. get() 메소드의 경우라면, 존재하는 id가 주어졌을 때 해당 레코드를 가져오는지를 테스트하는 것도 중요하지만, id가 존재하지 않을 때 어떻게 반응할지를 결정하고, 이를 확인할 수 있는 테스트를 먼저 만들려고 한다면 예외적인 상황을 빠뜨리지 않는 꼼꼼한 개발이 가능하다.
JUnit 프레임워크 작동 과정
JUnit은 @Test가 붙은 메소드를 실행하기 전과 후에 각각 @Before와 @After가 붙은 메소드를 자동으로 실행한다. 따라서 중복되는 과정들을 메소드로 묶어두고 before와 after 애노테이션을 이용하면 편리하다. 하지만 테스트 메소드에서 before와 after 메소드를 실행하지 않기 때문에 주고받을 정보나 오브젝트가 있다면 인스턴스 변수를 이용해야 한다. 또 한 가지 꼭 기억해야 할 사항은 각 테스트 메소드를 실행할 때 마다 테스트 클래스의 오브젝트를 새로 만든다는 점이다. (JUnit 개발자는 각 테스트가 서로 영향을 주지 않고 독립적으로 실행됨을 확실히 보장해주기 위함)