테스트

스프링은 복잡한 엔터프라이즈 애플리케이션을 효과적으로 개발하기 위한 기술이다. 이런 복잡한 애플리케이션을 개발하는 데 필요한 기술 중 하나는 객체지향 기술이다. 그리고 나머지 하나의 기술은 테스트이다.

스프링으로 개발을 하면서 테스트를 만들지 않는다면 이는 스프링이 지닌 가치의 절반을 포기하는 샘이다.

2장에서는 테스트란 무엇이며, 그 가치와 장점, 활용 전략, 스프링과의 관계를 살펴본다. 그리고 JUnit 을 소개하고 이를 활용한 학습 전략 또한 살펴볼 것이다.

UserDaoTest 다시 보기

테스트의 유용성

User 정보를 가져오는 UserDao가 있다고 가정하자. 이 코드는 main() 함수를 이용하여 userDao의 get() 메서드르 호출하고 결과를 화면에 출력하여 개발자가 눈으로 확인할 수 있게 해 준다. 만약에 이 코드를 책임에 따라 이리저리 분리하고 인터페이스를 도입하고, 오브젝트 팩토리를 통해 생성하게 하는 등의 리펙토링을 거쳤을 때 그것이 처음과 동일한 기능을 한다고 누가 보장할 수 있겠는가?

테스트란 결국 내가 예상하고 의도했던 대로 코드가 정확히 동작하는지를 확인해서, 만든 코드를 확신할 수 있게 해주는 작업이다.

UserDaoTest의 특징

public static void main(String [] args) throws SQLException {
    ApplicationContext context = new GenericXmlApplicationContext("applicationContext.xml");
 
    Userdao dao = context.getBean("userDao", UserDao.class);
 
    User user = new User();
    user.setId("user");
    user.setName("백기선");
    user.setPassword("married");
 
    dao.add(user);
 
    System.out.println(user.getId() + " 등록 성공");
 
    User user2 = dao.get(user.getId());
    System.out.println(user2.getName());
    System.out.println(user2.getPassword());
 
    System.out.println(user2.getId() + " 조회 성공");
}

이 테스트는 다음과 같은 특징이 있다.

  • 가장 손쉽게 시행 가능한 main() 메서드를 이용한다.
  • 테스트의 대상인 UserDao의 오브젝트를 가져와 메서드를 호출한다.
  • 테스트에 사용할 입력 값을 코드에서 직접 만들어 넣어준다.
  • 테스트의 결과를 콘솔에 출력해준다.
  • 각 단계의 작업이 에러 없이 끝나면, 콘솔에 성공 메시지로 출력해준다.

특징은 main() 메서드를 이용하여 쉽게 테스트를 수행하였다는 점과 테스트 대상인 UserDao를 직접 호출하였다는 것이다.

웹 프로그램에서 사용하는 DAO를 테스트하는 방법은 보통 다음과 같다.

  1. 서비스 계층, MVC 프레젠테이션 계층까지 포함한 모든 입출력 기능을 대충이라도 코드로 다 만든다.
  2. 테스트용 웹 어플리케이션을 서버에 배치한 후 웹 화면을 띄워 폼을 열고, 값을 입력한 후 버튼을 누른다.
  3. 결과를 확인한다.

이는 흔히 사용하는 방법이지만, DAO를 테스트하는데는 단점이 너무 많다. 단점은 다음과 같다.

  1. 모든 레이어의 기능을 다 구현한 뒤에야 테스트가 가능하다.
  2. 다른 연결된 부분에서 문제가 발생하였을 확률이 있다.
  • DAO는 정상적으로 동작하지만, 웹에서 데이터를 입력받는 부분에서 문제가 생겼다.
  • SQL 문법이 틀렸다.

이를 피하기 위해, 테스트는 테스트하고자 하는 대상이 명확하다면 그 대상에만 집중하여 테스트하는 것이 바람직하다. 테스트의 관심이 다르다면 테스트할 대상을 분리하고 집중하여 접근해야 한다. 이렇게 작은 단위의 코드에 대해 테스트를 수행한 것을 단위 테스트(unit test)라고 한다.

  • 일반적으로 단위는 작을수록 좋다.
  • 통제할 수 없는 외부의 리소스에 의존하지 않는다.

또한 UserDaoTest는 main() 을 실행시키는 방법으로 테스트를 시행한다. 이는 좋은 방법이지만, 테스트 코드가 애플리케이션의 클래스 안에 있는 것보단 별도로 테스트용 코드를 만들어서 테스트 코드를 넣는게 좋다. 이렇게 테스트를 따로 클래스로 분리한 이후에는 자동으로 여러 번 테스트를 반복할 수 있다.

UserDaoTest의 문제점

  1. 수동 확인 작업의 번거로움
  • 테스트 수행 과정과 입력 데이터 세팅은 모두 자동으로 진행하지만, 여전히 사람의 눈으로 확인하는 과정이 필요하다.
  1. 실행 작업의 번거로움

main() 함수가 수백개가 되고 main 메서드도 많이 만들어지면 실행후 결과를 종합하여 눈으로 확인하는 과정이 어려워진다.

UserDaoTest 개선

테스트 검증의 자동화

테스트 결과의 검증부분을 자동화할 수 있다. 먼저, 테스트의 결과에 대해 생각해보면 성공 / 실패 두 가지가 있다. 실패는 또한 테스트가 진행되는 동안에 에러가 발생하는 것과 결과가 예상했던 값과 다른 경우가 있다. 이를 고려하여 테스트 검증 부분을 추가하자.

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() 메서드로 만든 테스트는 필요한 기능은 모두 갖추었다. 그러나 좀 더 편리하게 테스트를 수행하고 결과를 확인하려면 테스트 프레임워크를 사용하는 것이 좋다. 자바에서는 JUnit 이라는 테스트 지원 도구를 활용한다.

Junit 테스트로 전환

JUnit은 프레임워크다. 프레임워크는 개발자가 만든 클래스에 대한 제어 권한을 넘겨받아 주도적으로 애플리케이션의 흐름을 제어한다. 따라서 테스트로 전환하려면 먼저 main 메서드의 테스트 코드를 일반 메서드로 옮겨야 한다. 새로 만든 테스트 코드는 두 가지를 따라야 한다. 하나는 public 으로 선언되어야 하고 하나는 @Test 애노테이션이 붙어야 한다.

public class UserDaoTest {
    @Test
    public void addAndGet() throws SQLException {
        ApplicationContext context = new ClassPathXmlApplicationContext("applicationContext.xml");
 
        UserDao dao = context.getBean("userDao", UserDao.class);
 
        // implementation
    }
}

원래 검증 코드는 다음과 같았다.

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("조회 테스트 성공");
}

JUnit 에서 사용하는 도구로 다음과 같이 변경할 수 있다.

assertThat(user2.getPassword(), is(user.getPassword()));

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

JUnit은 자바의 표준 테스팅 프레임워크라고 이야기 할 만큼 폭넓게 사용되고 있다. 스프링의 테스팅 모듈도 JUnit을 활용하고 있다. JUnit은 단순하기 때문에 빠르게 작성할 수 있고 IDE 단에서 테스트를 손쉽게 실행할 수 있는 여러 부기 기능도 제공한다.

또한 빌드 툴(Maven, ANT) 에서도 플러그인이나 테스크를 이용하여 손쉽게 JUnit 테스트를 실행할 수 있다.

테스트 결과의 일관성

지금까지 JUnit을 활용하여 깔끔한 테스트 코드를 만들었고 편리하게 실행할 수 있는 방법도 확인하였다. 그러나 다음과 같은 문제가 아직 남아있다. 위의 테스트는 DB내에 어떤 값이 존재하냐에 따라 결과가 다르게 나올 수 있다는 점이다.

앞에서 단위 테스트의 조건을 살펴보았다. 단위 테스트는 다음과 같은 조건을 만족하여야 한다.

  • 일반적으로 단위는 작을수록 좋다.
  • 통제할 수 없는 외부의 리소스에 의존하지 않는다.

DB라는 리소스에 의존하여 테스트 결과가 다르게 나오면 안 된다.

코드의 변경사항이 없다면 테스트는 항상 동일한 결과를 내야 한다.

UserDaoTest는 이전 테스트 때문에 DB에 등록된 중복 데이터가 있다는 문제가 있다. 가장 좋은 방법은 addAndGet() 테스트를 마치고 나면 사용자 정보를 삭제하여 테스트를 수행하기 이전 상태로 만들어주는 것이다.

deleteAll()과 getCount() 추가

위의 조건을 만족하기 위해 처음으로 해야 할 일은 deleteAll() 메서드를 UserDAO에 추가하는 것이다. deleteAll() 은 USER 테이블의 모든 레코드를 삭제하는 기능이 있다.

public void deleteAll() throws SQLException {
    Connection c = dataSource.getConnection();
 
    PreparedStatement ps = c.prepareStatement("delete from users");
 
    ps.executeUpdate();
 
    ps.close();
    c.close();
}

다음은 getCount() 메서드를 추가한다. 이 메서드는 USER 테이블의 레코드 개수를 돌려준다.

public int getCount() throws SQLEception {
    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;
}

deleteAll()과 getCount()의 테스트

새로운 기능을 추가하였으니 테스트를 만들어야 한다. 그러나 두 기능은 테스트를 만들기 애매하다. 굳이 테스트를 하자면 USER 테이블에 데이터를 넣고 삭제하는 테스트를 만들어야 하는데, 이는 개발자가 직접 수행해야 하는 테스트라서 번거롭다.

그래서, 새로운 테스트를 만들기 보다는 기존에 만든 addAndGet() 테스트를 확장하는 방법을 사용하는 편이 더 낫다. addAndGet() 테스트의 불편한 점은 실행 전에 수동으로 USER 테이블의 모든 데이터를 삭제해야 한다는 점이니, 이 메소드를 테스트 시작 전에 호출하여 데이터를 모두 삭제해주면 된다.

그러나 deleteAll() 메서드 자체도 검증되지 않았다는 문제점이 있다. 그래서 getCount()를 함께 적용한다. deleteAll()이 정상적으로 적용되었다면, 메서드 호출 후에 getCount()를 호출하면 0을 반환하여야 한다. 그러면 getCount() 는 어떻게 올바르게 동작한다고 증명할 것인가? 이는 getCount() 만의 테스트 코드를 작성하면 된다.

정리하면 다음과 같다.

  1. addAndGet()
  • deleteAll()
  • getCount()
  • 잔여 테스트 진행
  1. getCount()
  • add 후에 1이 나오는지 확인

테스트 코드는 다음과 같다

@Test
public void addAndGet() throws SQLException() {
    dao.deleteAll(); // 전체 삭제
    assertThat(dao.getCount(), is(0));
 
    User user = new User();
    user.setId("gyumee");
    user.setName("박성철");
    user.setPassword("springno1");
 
    dao.add(user);
    assertThat(dao.getCount(), is(1));
}

위와 같이 테스트를 작성하면, 어떤 상황에서 반복적으로 실행된다고 하더라도 동일한 결과가 나온다.

단위 테스트는 항상 일관성 있는 결과가 보장돼야 한다는 점을 잊어선 안 된다. DB에 남아 있는 데이터와 같은 외부 환경에 영향을 받지 말아야 하는 것은 물론이고, 테스트를 실행하는 순서를 바꿔도 동일한 결과가 보장되도록 만들어야 한다.

포괄적인 테스트

위에서는 getCount()에 대해 테스트를 작성하지 않았으니, getCount() 에 대해 좀 더 꼼꼼하게 테스트를 만들어 보자. 시나리오는 다음과 같다.

  1. USER 테이블의 데이터를 모두 지운다.
  2. getCount() 가 0 임을 확인한다.
  3. user를 insert 한다
  4. getCount() 가 증가함을 확인한다.
dao.deleteAl();
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));

주의해야 할 점은 JUnit이 테스트의 실행 순서를 보장해주지 않는다는 것이다. 테스트의 결과가 테스트 실행 순서에 영향을 받는다면 테스트를 잘못 만든 것이다.

addAndGet() 테스트 보완

현재 테스트는 add() 후에 레코드 개수도 확인하고, get으로 읽어와서 값도 모두 비교하고 있다. 그러나 id를 조건으로 하여 사용자를 검색하는 테스트는 충분히 검증된 거 같지 않다. 그래서 get() 메서드에 대한 테스트 기능을 보완하려고 한다.

  1. User를 하나 더 추가하여 두 개의 User를 add() 하고, 각 User의 id를 파라미터로 전달하여 get을 실행시킨다.
  2. 주어진 id 에 해당하는 정확한 User 정보를 가져오는지 확인한다.
@Test
public void addAndGet() throws SQLException {
 
    UserDao dao = context.getBean("userDao", UserDao.class);
    User user1 = new User("gyumee", "백성철", "springno1");
    User user2 = new User("leegw700", "이길원", "springno2");
 
    dao.deleteAll();
    assertThat(dao.getCount(), is(0));
 
    dao.add(user1);
    dao.add(user2);
    assertThat(dao.getCount(), is(2));
 
    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() 메서드의 동작을 검증할 수 있다.

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

get 메서드에 전달한 id 에 해당하는 사용자 정보가 없는 경우에는 어떻게 될까? 이 경우는 고려해보지 않았다. 두 가지 방법이 있을 것이다.

  1. null과 같은 특별한 값을 리턴한다.
  2. id에 해당하는 정보를 찾을 수 없다고 예외를 던진다.

여기서는 후자의 방법을 사용한다.

@Test(expected=EmptyResultDataAccessException.class)
public void getUserFailure() throws SQLException {
    // ...
 
    UserDao dao = context.getBean("userDao", UserDao.class);
 
    dao.deleteAll();
    assertThat(dao.getCount(), is(0));
 
    dao.get("unknown_id"); // 이 메서드 실행 중에 예외가 발생한다.
}

JUnit은 다음과 같은 방법으로 예외 발생 테스트를 할 수 있다. @Test에 expected를 지정해 두면 정상적으로 테스트를 종료한 경우 테스트가 실패하고, 지정한 예외가 던져지면 테스트가 성공한다.

포괄적인 테스트

실제로 테스트를 만들다 보면, 개발자는 다음과 같은 실수를 많이 한다.

개발자가 테스트를 직접 만들 때 자주 하는 실수가 하나 있다. 바로 성공하는 테스트만 골라서 만드는 것이다.

스프링의 창시자인 로드 존슨은 “항상 실패하는 테스트를 먼저 만들라”는 조언을 했다.

테스트를 작성할 때는 부정적인 케이스를 먼저 만드는 습관을 들이는 게 좋다.

테스트 코드 개선

지금까지 만든 테스트 코드를 리펙토링 해 보자. 테스트 코드 또한 필요하다면 내부 구조와 설계를 개선하여 좀 더 깔끔하고 이해하기 쉬우며 변경이 용이한 코드로 만들 필요가 있다.

다음의 코드는 테스트에서 기계적으로 반복된다.

ApplicationContext context = new ClassPathXmlApplicationContext("applicationContext.xml");
UserDao dao = context.getBean("userDao", UserDao.class);

중복된 코드는 별도의 메소드로 extract 하면 좋다. JUnit에서는 이런 코드를 추출하기 위해 @Before라는 애노테이션을 제공한다. JUnit은 다음과 같은 프로세스로 테스트 케이스를 수행한다.

  1. 테스트 클래스에서 @Test가 붙은 public이고 void 형이며 파라미터가 없는 테스트 메소드를 찾는다.
  2. 테스트 클래스의 오브젝트를 하나 만든다.
  3. @Before가 붙은 메소드가 있다면 실행시킨다.
  4. @Test가 붙은 메소드를 하나 호출하고 테스트 결과를 저장해둔다.
  5. @After가 붙은 메소드가 있다면 실행한다.
  6. 나머지 테스트 메소드에 대해 2~5번을 반복한다.
  7. 모든 테스트의 결과를 종합하여 돌려준다.

그렇기 때문에, @Before 어노테이션에 위의 코드를 넣고 context와 dao를 인스턴스 변수로 분리하면 모든 테스트 메서드에서 동일하게 활용할 수 있다.

public class UserDaoTest {
    private UserDao dao;
 
    @Before
    public void setUp() {
        ApplicationContext context = new GenericXmlApplicationContext("applicationContext.xml");
        this.dao = context.getBean("userDao", UserDao.class);
    }
 
    // ...
}

또 한가지 기억해야 할 사항은, 테스트 메소드를 실행할 때마다 테스트 클래스와 오브젝트를 새로 만든다는 점이다. 각각 테스트가 서로 영향을 미치지 않고 독립적으로 동작하게 하기 위함이다.

픽스쳐

테스트를 수행하는 데 필요한 정보나 오브젝트를 픽스쳐라고 한다. UserDaoTest에서는 dao가 대표적인 픽스쳐일 것이고, add 메소드에 전달하는 User 객체도 픽스쳐라고 할 수 있다.

이 부분도 중복이 있으므로 @Before로 추출해 보자.

public class UserDaoTest {
    private UserDao dao;
    private User user1;
    private User user2;
    private User user3;
 
    @Before
    public void setUp() {
        this.user1 = new User("gyumee", "박성철", "springno1");
        this.user2 = new User("leegw700", "이길원", "springno2");
        this.user2 = new User("bumjin", "박범진", "springno3");
    }
}

스프링 테스트 적용

어느 정도 테스트 케이스를 정리하였지만, 한가지 찝찝한 점이 있다. @Before 메소드가 테스트 메소드의 횟수만큼 실행되기 때문에, 애플리케이션 컨텍스트가 총 세번 만들어진다. 애플리케이션 컨텍스트가 복잡해지고 커지게 되면 다음과 같은 문제가 발생할 수 있다.

  1. 애플리케이션 컨텍스트를 생성할 때 내부의 모든 빈을 초기화하기 때문에 성능이슈가 발생할 수 있다.
  2. 어떤 빈은 독자적으로 많은 리소스를 할당하거나 독립적 스레드를 사용하기 때문에, 계속 새로운 애플리케이션 컨텍스트 생성시 이슈가 발생할 수 있다.

위의 문제를 해결하기 위해 테스트 컨텍스트를 생성할 수 있다.

테스트를 위한 애플리케이션 컨텍스트 관리

스프링 테스트 컨텍스트 프레임워크 적용

@RunWith(SrpingJUnit4ClassRunner.class) // 스프링의 테스트 컨텍스트 프레임워크 확장기능
@ContextConfiguration(locations="/applicationContext.xml") // 테스트 컨텍스트가 자동으로 만들어줄 애플리케이션 컨텍스트 위치 지정
public class UserDaoTest {
    @Autowired
    private ApplicationContext context;
 
    @Before
    public void setUp() {
        this.dao = this.context.getBean("userDao", UserDao.class);
    }
}

@RunWith는 JUnit 프레임워크 테스트 실행 방법을 확장할 때 사용하는 애노테이션이다. 위의 코드에서는 SpringJUnit4ClassRunner라는 JUnit 용 테스트 컨텍스트 프레임워크 확장 클래스를 지정하여, JUnit이 테스트를 진행할 때 테스트가 사용할 애플리케이션 컨텍스트를 생성 / 관리하는 작업을 해 준다. @ContextConfiguration은 자동으로 만들어 줄 애플리케이션 컨텍스트 설정 파일 위치를 지정해주는 애노테이션이다.

위와 같이 애플리케이션 컨텍스트를 생성하면, 같은 애노테이션이 붙은 테스트 클래스들 사이에서도 애플리케이션 컨텍스트를 공유할 수 있다.

테스트 코드에 의한 DI

애플리케이션 사용할 applicationContext.xml 에 정의된 DataSource 빈이 리얼 서비스를 바라보고 있다고 가정하자. 테스트할 때 이 DataSource 를 바라보고 데이터를 삭제하면 리얼 데이터가 모두 날아갈 것이다. 이런 상황을 막기 위해, 애플리케이션 컨텍스트에서 가져온 컨텍스트의 빈을 변경하는 작업을 해 보자

@DirtiesContext // 테스트 메소드에서 애플리케이션 컨텍스트 구성, 상태를 변경할 것이다.
public class UserDaoText {
    @Autowired
    UserDao dao;
 
    @Before
    public void setUp() {
        DataSource dataSource = new SimpleConnectionDataSource("jdbc:mysql://localhost/testdb", "spring", "book", true);
        dao.setDataSource(dataSource); // 코드에 의한 수동 DI
    }
}

위와 같이 작업할 때는 @DirtiesContext 애노테이션을 꼭 붙여 주어야 하는데, 그렇게 하지 않는다면 변경한 애플리케이션 컨텍스트를 테스트 클래스 전체에서 공유할 수 있기 때문이다.

테스트를 위한 별도의 DI 설정

아무래도 위의 방식은 직접 애플리케이션 컨텍스트를 변경하기 때문에 단점이 많다. 코드의 양도 증가하고 매번 새롭게 애플리케이션 컨텍스트를 만들어야 하기 때문이다. 위의 방법보다 DataSource를 두 개 등록하는 방법을 활용하려고 한다.

하나는 서버에서 운영용으로 사용하는 DataSource이고, 하나는 테스트에 적합하게 준비된 DB를 사용하는 DataSource 이다.

다음 프로세스로 작업을 진행한다.

  1. 기존의 applicatonContext.xml을 복사하여 test-applicatonContext.xml 을 만든다.
  2. 다른 빈은 그대로 두고, dataSource 빈의 설정을 테스트용으로 변경해준다.
  3. @ContextConfiguration 애노테이션에 있는 locations 엘리먼트의 값을 test-applicationContext.xml로 변경해 준다.

학습 테스트로 배우는 스프링

때로는 자신이 만들지 않은 프레임워크나 다른 개발팀에서 제공한 라이브러리에 대해서도 테스트를 작성해야 한다. 이런 테스트를 학습 테스트라고 한다.

학습 테스트의 장점은 다음과 같다.

  1. 다양한 조건에 따른 기능을 손쉽게 확인해 볼 수 있다.
  • 자동화된 테스트 코드를 사용하여, 기능이 어떻게 동작하는지 빠르게 확인할 수 있다.
  1. 학습 테스트 코드를 개발 중에 참고할 수 있다.
  • 다양한 기능과 조건에 대한 테스트 코드를 개별적으로 만들고 남겨둘 수 있다.
  1. 프레임워크나 제품을 업그레이드 할 때 호환성 검증을 도와준다.
  • 주요 기능에 대한 학습 테스트를 충분히 작성해 두었다면, 업그레이드 후에도 해당 프레임워크나 제품이 정상적으로 동작할 것을 보장할 수 있다.
  1. 테스트 작성에 대한 좋은 훈련이 된다.
  2. 새로운 기술을 공부하는 과정이 즐거워진다.

스프링 학습 테스트를 만들 때 참고할 수 있는 가장 좋은 소스는 스프링 자신에 대한 테스트 코드이다. 스프링은 거의 모든 기능에 대해 방대한 양의 테스트가 만들어져 있다. 스프링 테스트를 잘 살펴보면, 레퍼런스 문서에 미처 설명되지 않았던 중요한 정보도 많이 얻을 수 있고 테스트 작성 방법에 대한 좋은 팁 또한 얻을 수 있다.