✅ 테스트 결과의 일관성
UserDaoTest
의 addAndGet()
메소드를 테스트 하기 전에는 Primary key인 id가 겹치지 않게 테이블의 데이터를 삭제하거나, id의 값을 변경해주어야 했다. DB의 상태에 따라 테스트의 결과가 바뀐다는 점이 아쉽다. 코드에 변경사항이 없다면 테스트 코드는 외부에 영향을 받지 않고 언제나 일관된 결과를 내야한다. 즉, DB에 남아 있는 데이터와 같은 외부 환경에 영향을 받지 말아야 하는 것은 물론이고, 테스트를 실행하는 순서를 바꿔도 동일한 결과가 보장되도록 만들어야 한다.
deleteAll()
, getCount()
메소드를 추가하여 테스트가 끝나면 테이블의 데이터를 삭제하도록 하며, 테이블의 데이터의 수가 얼마나 있는지 확인할 수 있는 테스트 코드를 짜보자.
- UserDao
//...생략 //== 테이블 정보 삭제 ==// public void deleteAll() throws SQLException { Connection con = dataSource.getConnection(); String sql = "DELETE FROM users"; PreparedStatement pst = con.prepareStatement(sql); pst.executeUpdate(); pst.close(); con.close(); } //== 테이블 정보 개수 조회 ==// public int getCount() throws SQLException { Connection con = dataSource.getConnection(); String sql = "SELECT COUNT(*) FROM users"; PreparedStatement pst = con.prepareStatement(sql); ResultSet rs = pst.executeQuery(); rs.next(); int result = rs.getInt(1); rs.close(); pst.close(); con.close(); return result; }
user 테이블에 모든 레코드를 삭제하는 코드와 레코드의 총 개수를 반환하는 메소드를 추가했다.
addAndGet()
메소드 테스트를 수행할 때deleteAll()
메소드를 통해서는 DB에 값이 있더라도, 그 값을 삭제함으로써 일관된 결과를 얻을 수 있게 하고,getCount()
메소드는 DB의 총 레코드의 개수를 조회함으로써 DB 트랜잭션이 제대로 이루어지는지 확인할 수 있다.deleteAll()
,getCount()
메소드는 각각 테스트 코드가 필요하지만, 이 코드는 테스트를 수행하고 난 뒤 사람이 직접 DB를 확인해야하는 번거로움이 있기 때문에addAndGet()
메소드의 테스트 코드 기능을 확장하여 테스트할 수 있다.
- UserDaoTest
package com.jhcode.spring.ch2.dao; import static org.junit.jupiter.api.Assertions.assertEquals; import java.sql.SQLException; import org.junit.jupiter.api.Test; import org.springframework.context.ApplicationContext; import org.springframework.context.support.GenericXmlApplicationContext; import com.jhcode.spring.ch1.domain.User; public class UserDaoTest { @Test public void addAndGet() throws ClassNotFoundException, SQLException { ApplicationContext context = new GenericXmlApplicationContext("applicationContext.xml"); UserDao dao = context.getBean("userDao", UserDao.class); dao.deleteAll(); assertEquals(dao.getCount(), 0); User user = new User(); user.setId("JUnit"); user.setName("jhcode"); user.setPassword("mariaDB"); dao.add(user); assertEquals(dao.getCount(), 1); User user2 = dao.get(user.getId()); assertEquals(user2.getName(), user.getName()); assertEquals(user2.getPassword(), user.getPassword()); } }
getCount()
메소드를 통해서deleteAll()
메소드의 의해 DB의 레코드가 삭제되었는지 판단할 수 있다. 삭제가 되었다면 0이,add()
메소드로 1개의 레코드가 추가되고 난 뒤에는 1이 반환되어야 한다.assertEquals()
메소드를 통해서 값을 비교했다. 이전의 코드와는 다르게deleteAll()
메소드 덕분에 반복해도 실행해도 동일한 결과가 나온다.
✅ 포괄적인 테스트
테스트 메소드는 한 번에 한 가지 검증 목적에만 충실한 것이 좋다. 현재는 deleteAll()
, getCount()
가 addAndGet()
테스트 메소드 안에 들어가 있다. getCount()
가 앞 선 테스트에서 0과 1의 값을 반환함으로 제대로 동작한다고 예상할 수도 있지만, 1부터 계속 증가한다고 확신할 수 있을까? 미처 생각지도 못한 문제가 있을 수도 있으니 더 구체적인 테스트를 해보는 것이 바람직하다.
Junit은 하나의 클래스 안에 여러 개의 테스트 메소드가 들어가는 것을 허용한다.
- UserDao
//== 기본 생성자 ==// public User() {} //== 테스트를 쉽게 하기 위해 파라미터가 있는 생성자 ==// public User(String id, String name, String password) { this.id = id; this.name = name; this.password = password; }
여러 개의 User 객체를 생성하고 DB에 저장해야하기 때문에 User 객체를 쉽게 만들기 위해 파라미터가 있는 생성자를 만들었다. 명시적으로 기본생성자(인자가 없는 생성자) 외에 다른 생성자를 만들었을 때는 기본 생성자 코드를 작성해주어야, 기본 생성자가 만들어진다.
- count() 테스트
//...생략 @Test public void count() throws ClassNotFoundException, SQLException { ApplicationContext context = new AnnotationConfigApplicationContext(DaoFactory.class); UserDao dao = context.getBean("userDao", UserDao.class); User user1 = new User("user1", "one", "1111"); User user2 = new User("user2", "two", "2222"); User user3 = new User("user3", "three", "3333"); dao.deleteAll(); assertEquals(dao.getCount(), 0); dao.add(user1); assertEquals(dao.getCount(), 1); dao.add(user2); assertEquals(dao.getCount(), 2); dao.add(user3); assertEquals(dao.getCount(), 3); }
주의해야할 점은 JUnit은 특정한 테스트 메소드의 실행 순서를 알 수 없다. 테스트의 결과가 테스트 실행 순서에 영향을 받는다면 테스트를 잘 못 만든 것이다. 모든 테스트는 실행 순서에 상관없이 독립적으로 항상 동일한 결과를 낼 수 있도록 해야한다.
- addAndGet() 테스트 보완
@Test public void addAndGet() throws ClassNotFoundException, SQLException { ApplicationContext context = new GenericXmlApplicationContext("applicationContext.xml"); UserDao dao = context.getBean("userDao", UserDao.class); dao.deleteAll(); assertEquals(dao.getCount(), 0); User user1 = new User("user1", "one", "1111"); User user2 = new User("user2", "two", "2222"); dao.deleteAll(); assertEquals(dao.getCount(), 0); dao.add(user1); dao.add(user2); assertEquals(dao.getCount(), 2); User userget1 = dao.get(user1.getId()); assertEquals(userget1.getName(), user1.getName()); assertEquals(userget1.getPassword(), user1.getPassword()); User userget2 = dao.get(user2.getId()); assertEquals(userget2.getName(), user2.getName()); assertEquals(userget2.getPassword(), user2.getPassword()); }
add() 메소드는 충분히 검증되었지만 get() 메소드가 정확한 id 값을 가진 DB의 레코드 정보를 가지고 오는지 확인할 필요가 있기에 테스트를 보완하였다.
항상 네거티브 테스트를 먼저 만들라!!! - 로드 존슨 -
“내 PC에서는 잘 되는데”라는 변명을 하지 말자!
- addAndGet() 테스트 보완
✅ 예외 조건에 대한 테스트
- JUnit5의 assertThrows를 사용하기 위해 해당 블로그를 참고했다.
get()
메서드에 전달된id
값에 해당하는 사용자 정보가 없다면 null과 같은 특별한 값을 리턴하거나, 예외를 던지게 된다. 스프링이 미리 정의해놓은 예외인EmptyResultDataAccessException
을 통해서 특정 예외가 발생되는 것에 대한 테스트를 진행해보자.
- UserDaoTest
//...생략 @Test public void getUserFailure() throws SQLException{ ApplicationContext context = new AnnotationConfigApplicationContext(DaoFactory.class); UserDao dao = context.getBean("userDao", UserDao.class); dao.deleteAll(); assertEquals(dao.getCount(), 0); assertThrows(EmptyResultDataAccessException.class, () -> {dao.get("unknown_id");}); }
assertThrows()
: JUnit 프레임워크에서 제공하는 메서드로, 특정 예외가 발생하는지를 테스트하는데 사용된다. 첫 번째 매개변수로 예상되는 예외 클래스를 받고, 두 번째 매개변수로 실행할 코드 블록(람다 표현식)을 받는다.코드 블록이 실행되는 동안 첫 번째 인자로 받은 예상되는 예외가 발생하면 테스트가 성공하고, 다른 예외가 발생하거나 어떤 예외도 발생하지 않으면 테스트는 실패로 표시한다.
👉테스트를 실행시키면 당연히 실패하는데,
get()
메서드에서 DB의 id 값 중에 “unknown_id”를 가진 레코드를 조회하고 가져와야하는데 가져올 레코드가 하나도 존재하지 때문에 SQL이 실행조차 될 수 없어서SQLException
을 발생시키기 때문이다. 지금은 특정 id 값을 조회할 때, 해당 레코드의 값만 가져올 수 없는EmptyResultDataAccessException
에 대해서 테스트하고 싶기 때문에UserDao
의 코드를 수정해야 한다.
- UserDao
import org.springframework.dao.EmptyResultDataAccessException; public class UserDao { //...생략 public User get(String id) throws ClassNotFoundException, SQLException { Connection con = dataSource.getConnection(); String sql = "SELECT * FROM users WHERE id=?"; PreparedStatement pst = con.prepareStatement(sql); pst.setString(1, id); ResultSet rs = pst.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); rs.close(); pst.close(); con.close(); return user; } //...생략
테스트를 할 때는 기존에 만들어둔 테스트도 함께 실행한다. 만약
get()
메서드에 예외를 발생시키는 기능을 추가하다가 기존 코드를 잘못 건드렸을 경우addAndGet()
테스트가 실패할테니, 이를 확인하고 다시get()
메서드의 오류를 잡아주면 된다.user이 null일 경우
EmptyResultDataAccessException
예외를 발생시킨다. Test 클래스에서assertThrows()
를 통해get()
메소드가 실행될 때 해당 예외가 발생하면 테스트가 성공하게 된다.
✅ 테스트가 이끄는 개발전략 TDD
get()
메서드의 예외 테스트를 만드는 과정을 생각해보자. 작업한 순서를 보면, “존재하지 않는 id로 get()
메소드를 실행하면 특정한 예외가 던져져야 한다”는 기능 테스트를 먼저 만들어 테스트가 실패하는 것을 보고 나서 UserDao
의 코드에 손을 대기 시작했다.
단계 | 내용 | 코드 | |
---|---|---|---|
조건 | 어떤 조건을 가지고 | 가져올 사용자 정보가 존재하지 않는 경우 | dao.deleteAll(); assertThat(dao.getCount(), 0); |
행위 | 무엇을 할 때 | 존재하지 않는 id로 get()을 실행하면 | get(”unknown_id”); |
결과 | 어떤 결과가 나온다 | 특정한 예외가 던져진다 | assertThrows(EmptyResultDataAccessException.class, () -> {dao.get("unknown_id");}); |
이런 식으로 추가하고 싶은 기능을 일반 언어가 아닌 테스트 코드로 표현해서, 마치 코드로 된 설계문서처럼 만들어 개발 흐름의 기능 설계에 해당하는 부분을 이 테스트 코드가 일부 담당할 수 있다.
이후 실제 기능을 가진 애플리케이션 코드를 만들고 나면, 바로 이 테스트를 실행해서 설계한 대로 코드가 동작하는지를 빠르게 검증할 수 있다.
만약 테스트가 실패하면 설계한 대로 코드가 만들어지지 않았음을 바로 알 수 있고 문제가 되는 부분이 무엇인지에 대한 정보도 테스트 결과를 통해 얻을 수 있다. 다시 코드를 수정해서 테스트가 성공한다면, 코드 구현과 테스트라는 두 가지 작업이 동시에 끝나게 된다.
이렇게 테스트 코드를 먼저 만들고, 테스트를 성공하게 해주는 코드를 작성하는 방식의 개발 방법을 테스트 주도 개발 TDD(Test Driven Development) 또는 테스트 우선 개발(Test First Development)이라고 한다. "실패한 테스트를 성공시키기 위한 목적이 아닌 코드는 만들지 않는다"는 것이 TDD의 기본 원칙이다.
🔹TDD의 장점
- 테스트를 빼먹지 않고 꼼꼼하게 만들어낼 수 있다. TDD는 아예 테스트를 먼저 만들고 그 테스트가 성공하도록 하는 코드만 만드는 식으로 진행하기 때문이다.
- 테스트를 작성하는 시간과 애플리케이션 코드를 작성하는 시간의 간격이 짧아지고, 그 덕분에 코드에 대한 피드백을 매우 빠르게 받을 수 있게 된다. 이미 테스트를 만들어뒀기 때문에 코드를 작성하면 바로바로 테스트를 실행해볼 수 있기 때문이다. 테스트 없이 오랜 시간 동안 코드를 만들고 나서 테스트를 하면, 오류가 발생했을 때 원인을 찾기 쉽지 않다.
- 매번 테스트가 성공하는 것을 보면서 작성한 코드에 대한 확신을 가질 수 있다.
✅ 테스트 코드 개선
UserDaoTest
에서 다음과 같은 코드가 반복된다.
ApplicationContext context = new AnnotationConfigApplicationContext(DaoFactory.class);
UserDao dao = context.getBean("userDao", UserDao.class);
- 스프링의 애플리케이션 컨텍스트를 만드는 부분
- 컨텍스트에서 UserDao Bean 객체를 주입받는 부분
JUnit 프레임워크는 테스트를 실행할 때마다 반복되는 준비 작업을 별도의 메소드로 만들고 이를 테스트 전에 실행시켜주는 @BeforeEach 어노테이션이 존재한다. 이 어노테이션을 사용해서 중복된 코드를 제거해보자.
- UserDaoTest
import org.junit.jupiter.api.BeforeEach; import org.springframework.context.support.GenericXmlApplicationContext; public class UserDaoTest { private UserDao dao; @BeforeEach public void setUp() { ApplicationContext context = new GenericXmlApplicationContext("applicationContext.xml"); this.dao = context.getBean("userDao", UserDao.class); } @Test public void addAndGet() throws ClassNotFoundException, SQLException { //context, dao 생성 부분 제거 dao.deleteAll(); assertEquals(dao.getCount(), 0); //...생략
공통된 부분을 제거하고
UserDao
를 주입받아 전역 변수에 담음으로써 모든 Test 메소드에서 사용할 수 있도록 하였다.프레임워크는 스스로 제어권을 가지고 주도적으로 동작하고, 개발자가 만든 코드는 프레임워크에 의해 수동적으로 실행된다. JUnit Test 프레임워크는 어떻게 동작할까?
- JUnit이 하나의 테스트 클래스를 가져와 테스트를 수행하는 방식
1. 테스트 클래스에서
@Test
가void
형이며 파라미터가 없는 테스트 메서드를 모두 찾는다. private 외에 다른 접근제어자도 사용할 수 있다.2. 테스트 클래스의 오브젝트를 하나 만든다.
3.
@BeforeEach
가 붙은 메서드가 있으면 실행한다.4.
@Test
가 붙은 메서드를 하나 호출하고 테스트 결과를 저장해둔다.5.
@AfterEach
가 붙은 메서드가 있으면 실행한다.6. 나머지 테스트 메서드에 대해 2~5번을 반복한다.
7. 모든 테스트의 결과를 종합해서 돌려준다.
👉실제로는 이보다 더 복잡한데, 간단히 정리하면 위의 7단계를 거쳐서 진행된다고 볼 수 있다.
- JUnit Test의 특징
- 공통적인 코드 처리
공통적인 준비 작업과 정리 작업을
@BeforeEach
,@AfterEach
가 붙은 메서드에 넣어두면 JUnit이 자동으로 메서드를 실행해주니 매우 편리하다. 직접setUp()
과 같은 메서드를 호출할 필요도 없다. 대신 테스트 메서드에서 직접 호출하지 않기 때문에 주고 받는 정보나 객체는 인스턴스 변수를 이용해야 한다.
- 테스트의 독립적인 실행
각 테스트 메서드를 실행할 때마다 테스트 클래스의 객체를 새로 만든다. 한번 만들어진 테스트 클래스의 오브젝트는 하나의 테스트 메서드를 사용하고 나면 버려진다. 각 테스트가 서로 영향을 주지 않고 독립적으로 실행됨을 확실히 보장해주기 위해서이기 때문이다.
- 픽스처
테스트를 수행히는 데 펼요한 정보나 객체를 픽스처(fixture)라고 한다. 일반적으로 픽스처는 여러 테스트에서 반복적으로 사용되기 때문에
@BeforeEach
메소드를 이용해 생성해두면 편리하다.UserDaoTest
에서라면dao
가 대표적인 픽스처다. 테스트 중에add()
메소드에 전달히는 User 객체들(user1, user2, user3)도 픽스처라고 볼 수 었다.
- 공통적인 코드 처리
프로젝트 환경
- IDE : STS3 - 3.9.18.RELEASE
- SpringFramework : 5.3.20
- Java : 11
- Maven
📖토비 스프링 3.1 -p161~183
- JUnit이 하나의 테스트 클래스를 가져와 테스트를 수행하는 방식
Uploaded by N2T