테스트 코드 작성, JUnit 사용과 테스트 주도 개발(TDD)

2022. 1. 15. 21:38북리뷰/토비의 봄

728x90
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import springbook.user.domain.User;

import java.sql.SQLException;

public class UserDaoTest {
    public static void main(String[] args) throws ClassNotFoundException, SQLException {
        ApplicationContext context =
                new AnnotationConfigApplicationContext(Config.class);
        UserDao dao = context.getBean("userDao", UserDao.class);

        User user = new User();
        user.setId("whwip");
        user.setName("test");
        user.setPassword("1234");

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

위 코드는 UserDao가 기대했던 대로 동작하는지 확인하기 위한 테스트 코드다. main() 메서드를 이용해 UserDao 오브젝트의 add(), get() 메서드를 호출하고, 그 결과를 화면에 출력해서 그 값을 눈으로 확인시켜준다.
테스트란 결국 내가 예상하고 의도했던 대로 코드가 정확히 동작하는지를 확인해서, 만든 코드를 확실할 수 있게 해주는 작업이다. 또한 테스트의 결과가 원하는대로 나오지 않는 경우에는 코드나 설계에 결함이 있음을 알 수 있다. 이를 통해 코드의 결함을 제거해가며 결국 최종적으로 테스트가 성공하면 모든 결함이 제거됐다는 확신을 얻을 수 있다.

위 테스트 코드의 문제점

위 테스트 코드의 과정을 정리해보면 다음과 같다

  1. 자바에서 가장 손쉽게 실행 가능한 main() 메서드를 이용한다.
  2. 테스트할 대상인 UserDao의 오브젝트를 가져와 메서드를 호출한다.
  3. 테스트에 사용할 입력값(User 오브젝트)을 직접 코드에서 만들어 넣어준다.
  4. 테스트 결과를 콘솔에 출력해준다.
  5. 각 단계의 작업이 에러 없이 끝나면 콘솔에 성공 메세지로 출력해준다.
    이런 식으로 진행하면 어떤 문제가 있을까? 문제점은 다음과 같다.1. 수동 확인 작업의 번거로움UserDaoTest는 테스트를 수행하는 과정과 입력 데이터의 준비를 모두 자동으로 진행하도록 만들어졌다. 하지만 여전히 사람의 눈으로 확인하는 과정이 필요하다. add()에서 User 정보를 DB에 등록하고, 이를 다시 get()을 이용해 가져왔을 때 입력한 값과 가져온 값이 일치하는지를 테스트 코드는 확인해주지 않는다. 단지 콘솔을 보고 사람이 직접 확인할 뿐이다.2. 실행 작업의 번거로움아무리 간단하게 실행 가능하더라고 매번 main() 메서드를 싱행하는 것은 제법 번거롭다. 만약 테스트 클래스가 1000개 이상 되면? 사람이 일일이 전부 실행해야 한다.

JUnit 테스트로 전환

이미 자바에는 단순하면서도 실용적인 테스트를 위한 도구가 여러 가지 존재한다. 그 중에서도 JUnit이 대표적인 테스트 지원 도구다.
메서드에 public으로 선언하면서 @Test라는 애너테이션만 붙여주면 쉽게 테스트 메서드로 만들 수 있다.
다음은 JUnit 프레임워크에서 동작하도록 테스트 메서드를 재구성한 코드이다.

public class UserDaoTest {
    @Test
    public void addAndGet() throws SQLExeption {
        ApplicationContext context =
                new AnnotationConfigApplicationContext(Config.class);
        UserDao dao = context.getBean("userDao", UserDao.class);

        User user = new User();
        user.setId("whwip");
        user.setName("test");
        user.setPassword("1234");

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

검증 코드 사용

Junit을 사용하기 전에 gradle.build에 dependency를 추가해준다

dependencies {
    ...
    testImplementation 'junit:junit:4.12'
}

테스트의 결과를 검증하는 if/else 문장을 JUnit이 제공하는 방법을 이용해 전황해보자. Junit이 제공하는 assertThat 스태틱 메서드를 이용하면 된다

public class UserDaoTest
    @Test
    public void addAndGet() throws SQLException {
        ApplicationContext context =
                new AnnotationConfigApplicationContext(Config.class);
        UserDao dao = context.getBean("userDao", UserDao.class);


        User user = new User();
        user.setId("dkwip");
        user.setName("test");
        user.setPassword("1234");

        dao.add(user);

        User user2 = dao.get(user.getId());

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

assertThat 메서드는 첫 번째 파라미터 값을 뒤에 나오는 매처(matcher)와 비교하여 일치하면 다음으로 넘어가고, 아니면 테스트를 실패하도록 만들어준다. is()는 매처의 일종으로 equals()로 비교해주는 기능을 가졌다.
JUnit은 예외가 발생하거나 assertThat()에서 실패하지 않고 테스트 메서드의 실행이 완료되면 테스트가 성공했다고 인식한다.

intellij등 IDE에서도 Junit테스트를 실행할 수 있으며, 실행하면 다음과 같은 결과가 나온다.
만약 테스트가 실패하면 OK대신 FAILURES!!가 뜨며 실패한 테스트와, 실패 한 이유에 대해서 매세지를 전달해준다.

addAndGet 테스트 코드의 개선점

지금까지 Junit을 적용해서 깔끔한 테스트 코드도 만들었고, 편리하게 실행할 수 있는 툴의 사용 방법도 살펴봤다. 하지만 아직도 아쉬운 점이 많다.
일단, 매번 UserDaoTest 테스트를 실행하기 전에 DB의 User 테이블 데이터를 모두 삭제해줘야 할 때였다. 깜빡 잊고 그냥 테스트를 실행했다가는 이전 테스트에서 등록했던 사용자 정보와 기본키가 중복된다면서 add() 메서드 실행 중에 에러가 발생할 것이다.
이 점에서 생각해볼 문제는 테스트가 외부 상태에 따라 성공하기도 하고 실패하기도 한다는점이다. DB 서버가 다운됐다거나 네트워크 장애가 생겨서 DB에 접근하지 못하는 예외적인 상황이라면 뭐 이해할 수 있겠는데, 지금 발생하는 문제는 별도의 준비 작업 없이는 성공해야 마땅한 테스트가 실패하기도 한다는 점이다. 이는 좋은 테스트라고 할 수 없다. 코드에 변경사항이 없다면 테스트는 항상 동일한 결과를 내야 한다.
그럼 위 테스트코드는 어떻게 해결할 수 있을까? 가장 좋은 해결책은 테스트를 마치고 나면 테스트가 등록한 사용자 정보를 삭제해서 테스트를 수행하기 이전 상태로 만들어주는 것이다. 그러면 테스트를 아무리 여러 번 반복해서 실행하더라도 항상 동일한 결과를 얻을 수 있다.

deleteAll()과 getCount() 메서드 추가

위 문제를 해결하기 위해 UserDao에 몇가지 메서드를 추가한다.

public class UserDao{
    ...
    public void deleteAll() throws SQLException {
        Connection c = dataSource.getConnection();

        PreparedStatement ps = c.prepareStatement("delete from users");
        ps.executeUpdate();

        ps.close();
        c.close();
    }

    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();
        c.close();
        return count;
    }
}

새로운 기능을 추가했으니 추가된 기능에 대한 테스트도 만들어보자!

 @Test
 public void count() throws SQLException {
        ApplicationContext context =
                new AnnotationConfigApplicationContext(DaoFactory.class);

        UserDao dao = context.getBean("userDao", UserDao.class);
        User user1 = new User("123","1234","132");
        User user2 = new User("1243","12344","13342");
        User user3 = new User("13243","12344","13342");

        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));
 }   

먼저 세 개의 User 오브젝트를 준비해두고 deleteAll()을 불러 테이블의 내용을 모두 삭제한 뒤에 getCount()가 0임을 확인한다. 그리고 만들어둔 User 오브젝트들을 하나씩 넣으면서 매번 getCount()가 하나씩 증가되는지를 확인하면 된다.
주의해야 할 점은 다른 테스트와 함께 실행 시 테스트의 순서가 어떻게 실행될지는 알 수 없다는 것이다. JUnit은 특정한 테스트 메서드의 실행 순서를 보장해주지 않는다. 근데, 테스트의 결과가 테스트 실행 순서에 영향을 받는다면 테스트를 잘못 만든 것이다. 모든 테스트는 실행 순서에 상관없이 독립적으로 항상 동일한 결과를 낼 수 있도록 해야 한다.

예외조건 테스트

만약 get()메서드에 전달된 id값에 해당하는 사용자 정보가 없다면 어떻게 될까? 이런 경우에 대비해 예외처리를 하자.
주어진 id에 해당하는 정보가 없다는 의미를 가진 예외 클래스가 하나 필요하다. 스프링에서 EmptyResultDataAccessException이라는 예외 클래스를 제공한다. get()메서드에서 예외를 던지도록 하는 테스트를 제작하자. JUnit에는 예외조건 테스트를 위한 특별한 방법을 제공한다.

 @Test(expected = EmptyResultDataAccessException.class)
 public void userNotFound() throws SQLException {
     ApplicationContext context = new AnnotationConfigApplicationContext(Config.class);
     UserDao dao = context.getBean("userDao", UserDao.class);

     dao.deleteAll();
     assertThat(dao.getCount(), is(0));
     dao.get("uuuuuu"); // 이 메서드 실행 중 예외 발생
 }

@Test에너테이션의 expected를 이용하면 테스트 메서드 실행 중에 발생하리라 기대하는 예외클래스에 대해 테스트를 진행 할 수 있다.
@Test에 expected를 추가해놓으면 보통의 테스트와는 반대로, 정상적으로 테스트를 마치면 실패하고, expected가 지정한 예외가 던져지면 테스트가 성공한다.
이 테스트를 실행하면 어떻게 될까? 당연히 실패한다. 왜냐하면 아직까지 get()메서드에 대해서 예외처리에 대한 코드수정이 이루어지지 않았기 때문이다. get()메서드를 수정해보자

public class UserDao{
    ...
     public User get(String id) throws SQLException {
        Connection c = dataSource.getConnection();

        PreparedStatement ps = c.prepareStatement("select * from users where id  = ?");
        ps.setString(1, id);

        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);

        rs.close();
        ps.close();
        c.close();

        return user;
    }
    ...
}


user를 조회한 값이 null 이면 EmptyResultDataAccessException를 던지도록 수정했다. 이제는 테스트가 성공할 것이다.

테스트 주도 개발(TDD)

get() 메서드의 예외 테스트를 만드는 과정을 다시 돌아보면 한 가지 흥미로운 점을 발견할 수 있다. 작업한 순서를 잘 생각해보면 새로운 기능을 넣기 위해 UserDao 코드를 수정하고, 다음 수정한 코드를 검증하기 위해 테스트를 만드는 순서로 진행한 것이 아니다. 반대로 테스트를 먼저 만들어 테스트가 실패하는 것을 보고 나서 UserDao의 코드에 손을 대기 시작했다. 테스트할 코드도 안 만들어놓고 테스트 코드부터 만드는 것은 좀 이상하다고 생각할 지 모르겠지만, 이런 순서를 따라 개발을 진행하는 전략을 테스트 주도 개발, Test Driven Development, TDD라고 한다.
개발자들이 정신 없이 개발을 하다 보면 사이사이 테스트를 만들어서 코드를 점검할 타이밍을 놓치는 경우가 많다. 빨리 기능을 완성하고 싶은 욕구가 가장 크지 않을까... 문제는 코드를 만들고 나서 시간이 많이 지나면 테스트를 만들기가 귀찮아진다는 점이다.
TDD는 아예 테스트를 먼저 만들고 그 테스트가 성공하도록하는 코드만 만드는 식으로 진행하기 때문에 테스트를 빼먹지 않고 꼼꼼하게 만들어낼 수 있다. 또는 테스트를 작성하는 시간과 애플리케이션 코드를 작성하는 시간의 간격이 짧아진다. 또, 매번 테스트가 성공하는 것을 보면서 작성한 코드에 대한 확신을 가질 수 있어, 가벼운 마음으로 다음 단계로 넘어갈 수 있다. 한편으로는 자신감을, 다른 한편으로는 마음의 여유를 주는 방법이다.
혹시 테스트를 만들고 자주 실행하면 개발이 지연되지 않을까 염려할지도 모르겠다. 그렇지 않다. 테스트는 애플리케이션 코드보다 상대적으로 작성하기 쉬운데다 각 테스트가 독립적이기 때문에, 코드의 양에 비해 작성하는 시간이 얼마 걸리지 않는다. 게다가 테스트 덕분에 오류를 빨리 잡아낼 수 있어서 전체적인 개발 속도는 오히려 빨라진다.

728x90