본문 바로가기

Spring/토비의 스프링 3.1

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


목차

  1. 2.3.1 JUnitTest 실행 방법
  2. 2.3.2 테스트 결과의 일관성
  3. 2.3.3 포괄적인 테스트
  4. 2.3.4 테스트가 이끄는 개발
  5. 2.3.5 테스트 코드 개선

2.3.1 JUnit 테스트 실행 방법

JUnitCore를 이용해 테스트를 실행할 수도 있지만, 테스트의 수가 많아지고 복잡해지면 관리하기가 힘들어짐

IDE

이클립스, STS는 JUnit 테스트를 지원한다.

  • @Test 애노테이션이 있는 클래스 선택
  • [Run] - [Run As] - [JUnit Test] 선택
  • main() 메소드 없어도 무관
  • 패키지 선택 후 JUnit Test를 실행할 경우, 해당 패키지 내 모든 @Test 메소드를 실행한다.
  • 단축키 : Alt + Shift + X,T

빌드 툴

ANT 또는 Maven 등의 빌드 툴과 스크립트를 사용할 경우, 빌드 툴에서 제공하는 JUnit 플러그인과 태스크 이용가능

  • HTML 또는 텍스트 파일로 보기 좋게 생성됨
  • 메일 등으로 통보받는 것도 가능
  • 통합 테스트 시 유용하며, 개발자 개인별로는 IDE에서 하는 것이 가장 편리

2.3.2 테스트 결과의 일관성

여전히 UserDaoTest 실행 시 마다 DB의 User 테이블을 정리해주어야 한다.

  • 외부 상태(DB)에 따라 테스트가 성공하기도 하고, 실패하기도 한다.
  • 코드에 변경사항이 없다면 테스트는 항상 동일한 결과를 내야한다.
  • 해결방법 : 테스트 후 테스트 이전 상태로 만들어주는 것

deleteAll()의 getCount() 추가

새로운 기능 추가

deleteAll()

User 테이블의 모든 레코드를 삭제

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

    PreparedStatement ps = c.prepareStatement("DELETE FROM users WHERE 1=1");
    ps.executeUpdate();

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

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

deleteAll()과 getCount()의 테스트

추가된 기능에 대한 테스트 필요

  • 별도의 메소드를 만들기는 애매
  • 기존의 addAndGet() 테스트를 확장
    1. addAndGet() 메소드 시작 시 deleteAll()을 실행
    2. 그러나 아직 deleteAll()에 대한 검증이 되지 않음
    3. deleteAll()이 정상적으로 작동한다면 deleteAll() 이후 getCount()는 0이 되어야 함.
    4. getCount()도 검증이 되지 않음

전체적인 Flow

  1. addAndGet() 실행
  2. deleteAll() 실행
  3. getCount() == 0 확인
  4. add() 실행
  5. getCount() == 1 확인
  6. add() 오브젝트와 get() 오브젝트의 동일성 확인

UserDaoTest.java

public class UserDaoTest {
    @Test
    public void addAndGet() throws SQLException {
        // ...
        dao.deleteAll();
        assertThat(dao.getCount(), is(0));

        dao.add(user);
        assertThat(dao.getCount(), is(1));

        // ...
    }
}

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

테스트를 실행하면 성공하는 것을 볼 수 있다. User DB를 정리하지 않고, 여러번 실행하여도 잘 실행된다.

  • addAndGet() 테스트 종료 직전에 지워주는 방법도 있다.
  • 그러나 이 방식은 addAndGet() 테스트 실행 이전에 다른 데이터가 User에 들어가 있다면 실패할 수도 있다. getCount가 맞지 않겠지?
  • 당연히 테스트 DB 이어야한다. 운영 DB면..

2.3.3 포괄적인 테스트

데이터를 여러번 넣어도 getCount()가 잘 작동할까?

  • 0,1 두가지를 해봤으니 잘 되겠지?
  • 제대로 만들지 않은 테스트는 하지 않는 것보다 못하다.

getCount 테스트

좀 더 꼼꼼한 테스트가 필요하다.

  • User를 여러번 등록하면서 매번 getCount()의 기능을 테스트
  • 기존의 addAndGet()보다는 새로운 테스트를 만드는 것이 더 좋다.
  • 테스트 역시 한 번에 한 가지 목적에 충실해야한다.

테스트 시나리오

  • deleteAll() 호출 후 getCount() 값이 0인지 확인
  • 3개의 User 정보를 입력하면서 getCount() 결과가 1씩 증가하는지 확인

UserDao.java

  • User 오브젝트를 여러번 만들어야 하므로 생성자를 만들어두면 편리하다.
  • 생성자를 명시적으로 추가하였을 경우, 디폴트 생성자를 함께 정의해주어야 한다. (자바빈 규약)
public class User {
    // ...

    public User() {
    }

    public User(String id, String name, String password) {
        this.id = id;
        this.name = name;
        this.password = password;
    }
}

UserDaoTest.java

public class UserDaoTest {
    @Test
    public void addAndGet() throws SQLException, ClassNotFoundException {
        // ...
        User user = new User("whiteship", "백기선", "married");

        // ...
    }
}

코드를 수정하였으므로 테스트를 해보자.

테스트가 성공했다면 위의 테스트 시나리오대로 getCount()를 생성하자.

getCount.java

public class getCount {
    @Test
    public void count() throws SQLException {
        // ...
        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));
    }
}

테스트는 성공할 것이다. 다만, 테스트의 순서는 알 수 없다.

  • 테스트의 결과가 테스트의 실행 순서에 영향을 받는다면 테스트를 잘못 만든 것이다.
  • 따라서 각 테스트는 독립적이어야 한다. 즉, addAndGet() 메소드에서 등록한 사용자 정보를 count() 테스트에서 활용하면 안 된다.

addAndGet() 테스트 보완

id를 조건으로 한 사용자 검색 기능을 가진 get()에 대한 테스트는 주어진 id에 해당하는 사용자인지, 그냥 아무나 가져온 것인지에 대한 확신이 없다.

  • User 2명을 각각 등록하고, 각 User의 id를 파라미터로 전달해서 get()을 실행한 후 테스트
public class UserDaoTest {
    @Test
    public void addAndGet() throws SQLException {
        // ...
        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() 메소드에 전달된 id 값에 해당하는 사용자 정보가 없다면 어떻게 될까?

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

예외를 던지는 방법을 사용

  • 예외 클래스를 정의 (스프링이 미리 정의해둔 예외 사용, EmptyResultDataAccessException)
  • UserDao의 get() 메소드에서 쿼리를 실행한 결과 아무것도 없으면 해당 예외를 던짐

테스트를 먼저 작성해보자.

  • 일반적으로 테스트는 예외가 던져지면 테스트는 중단되고, 테스트는 실패한다. (테스트 에러)
  • 그러나 이 경우에는 특정 예외가 던져져야만 성공하는 테스트이다.
  • 따라서 asserThat()으로 결과값을 비교할 수 없다.
  • 스프링에서는 테스트 중 발생할 것으로 기대하는 예외 클래스를 지정할 수 있다.
  • 일단 테스트 메소드를 추가하고, 모든 데이터를 지운 후 존재하지 않는 id로 get()을 호출해보자.
  • 이 때 EmptyResultDataAccessException이 발생하면 성공, 아니라면 실패다.
public class UserDaoTest {
    // 테스트 중 발생할 것으로 기대되는 예외 클래스를 지정
    @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");
    }
}

@Test 에노테이션에 expected 엘리먼트를 사용하면 테스트 메소드 실행 중 발생할 것으로 기대되는 예외 클래스를 지정할 수 있다.

  • 보통의 테스트와는 반대로 정상적으로 테스트를 마치면 테스트가 실패하고, expected에서 지정한 예외가 던져지면 성공한다.
  • 현재, 테스트는 실패한다. 아직 UserDao 코드에 손을 대지 않았으므로 rs.next() 실행 시 가져올 row가 없어서 SQLException이 발생한다.

테스트를 성공시키기 위한 코드의 수정

테스트가 성공하도록 get() 메소드 코드를 수정하여야 한다.

UserDao.java

public class UserDao {
    public User get(String id) throws SQLException {
        // ...

        // User는 null로 초기
        User user = null;

        // 쿼리 결과가 있을 경우만 User 오브젝트 생성 후 값을 넣어준다.
        if (rs.next()) {
            user = new User();
            user.setId(rs.getString("ID"));
            user.setName(rs.getString("NAME"));
            user.setPassword(rs.getString("PASSWORD"));
        }

        // ...

        // 결과가 없다면 User는 계속 null일 것이다.
        if (user == null) {
            // 예외를 던져준다.
            throw new EmptyResultDataAccessException(1);
        }

        return user;
    }
}

세 개의 테스트는 모두 성공할 것이다. 새로 추가한 기능도 정상적으로 동작하고, 기존의 기능에도 영향을 주지 않았다는 확신을 얻을 수 있다.

포괄적인 테스트

이 정도의 간단한 DAO의 경우, 다양한 테스트를 하지 않더라도 문제가 생기지 않을 것이라고 자신할 수 있는 개발자가 있을지도 모른다. 하지만 포괄적인 테스트를 만들어두면 나중에 문제가 생겼을 경우에도 원인을 찾기에도 편리하다.

테스트 중 자주하는 실수 : 성공하는 테스트만 골라서 만드는 것

  • '내 PC에서는 잘 되는데....' 와 동일
  • 제대로 만들지 않은 테스트는 없는 것보다 못하다.
  • 테스트 코드 작성에 대한 공부도 필요
  • "항상 네거티브 테스트를 먼저 만들라" - 로드 존슨

2.3.4 테스트가 이끄는 개발

2.3.3 후반부에 코드를 수정 후 테스트를 만든 것이 아니라 테스트를 먼저 작성하고 나서, 테스트가 실패하는 것을 보고 난 후 코드를 수정하였다.

기능설계를 위한 테스트

작업 순서

  1. 만들어야 할 기능을 결정 (존재하지 않는 id로 get()메소드를 실행하면 특정 예외가 던져져야 한다.)
  2. getUserFailure() 테스트를 작성
    • 테스트할 코드가 없는데 어떻게 테스트를 먼저 작성하지?
    • 추가하고 싶은 기능을 코드로 표현했기에 가능
단계 내용 코드
조건 어떤 조건을 가지고 가져올 사용자 정보가 존재하지 않는 경우 dao.deleteAll();
asserThat(dao.getCount),is(0));
행위 무엇을 할 때 존재하지 않는 id로 get()을 실행하면 get("unknown_id");
결과 어떤 결과가 나온다 특별한 예외가 던져진다 @Test(expected=EmptyResultDataAccessException.class)

테스트 코드는 마치 하나의 기능 정의서와 같다.

  • 테스트가 실패하면 설계한 대로 코드가 작성되지 않았음을 알 수 있음
  • 코드를 수정하고 테스트를 수행하는 과정을 반복한다.
  • 테스트가 성공한다면 코드 구현과 테스트라는 두 가지 작업이 동시에 끝난다.

테스트 주도 개발

테스트 주도 개발(TDD; Test Driven Development)
만들고자 하는 기능의 내용을 담고 있으면서 만들어진 코드를 검증해 줄 수 있도록 테스트 코드를 먼저 작성하고, 테스트를 성공하게 해주는 코드를 작성하는 방식의 개발 방법
테스트 우선 개발(TFD; Test First Development)라고도 함.

개발자가 테스트를 만들어가면 개발하는 방법이 주는 장점을 극대화

실패한 테스트를 성공시키기 위한 목적이 아닌 코드는 만들지 않는다. - TDD 기본원칙

개발을 하다보면 테스트를 만들 타이밍을 놓치는 경우가 있다.

  • 빨리 기능을 완성하고 싶다는 욕구
  • 한 번 집중하면 정신없이 빠져드는 습성
  • 코드를 다 만들고 시간이 지나면 테스트 만들기가 귀찮음

TDD의 장점

  • 테스트를 빼먹지 않고 꼼꼼하게 만들어 낼 수 있다.
  • 테스트를 작성하는 시간과 애플리케이션 코드를 작성하는 시간의 간격이 짧아진다.
  • 자연스럽게 단위 테스트를 만들 수 있다.
  • 머릿속에서 진행되는 시뮬레이션을 코드로 작성한다.

2.3.5 테스트 코드 개선

테스트 코드도 리팩토링이 필요하다.

  • context와 dao를 가져오는 부분이 반복되고 있다.
  • 중복된 코드는 메소드로 분리하는 것이 가장 손쉬운 방법이다.
  • 테스트의 경우, JUnit이 제공하는 기능을 사용할 수도 있다.

@Before

@Before 애노테이션을 사용하면 테스트 실행 전 준비작업을 분리할 수 있다.

  • 중복됐던 코드를 setUp()이라는 메소드에 넣고, 중복된 코드는 제거한다.
  • dao는 로컬 변수였으므로 인스턴스 변수로 바꾸어준다.
public class UserDaoTest {
    private UserDao dao;

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

    @Test
    public void addAndGet() throws SQLException {         // ...
    }

    @Test
    public void count() throws SQLException {
        // ...
    }

    @Test(expected=EmptyResultDataAccessException.class)
    public void getUserFailure() throws SQLException {
        // ...
    }
}

JUnit의 테스트 실행 과정

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

@Before/@After

  • 자동으로 실행되므로 메소드를 직접 호출할 필요가 없다.
  • 직접 호출하지 않으므로 인스턴스 변수를 이용해야 한다.

JUnit의 테스트 클래스 오브젝트는 매번 새로 만들어진다.

  • 각 테스트가 서로 영향을 주지 않고 독립적으로 실행됨을 보장
  • 따라서 인스턴스 변수도 부담없이 사용할 수 있다.

테스트 메소드 일부에서만 사용(중복)되는 코드가 있다면?

  • Before이 아닌 새로운 메소드로 분리하여 직접 호출하는 것이 낫다.
  • 혹은 공통적인 특징을 지닌 테스트 메소드를 모아 별도의 테스트 클래스로 만들수도 있다.

픽스쳐

픽스쳐
테스트를 수행하는 데 필요한 정보나 오브젝트

일반적으로 픽스쳐는 여러 테스트에서 반복적으로 사용되기 때문에 @Befor 메소드를 이용해서 만들어두면 편리하다. (ex. dao)

  • add() 메소드에 전달되는 User오브젝트들도 픽스처라고 볼 수 있다.
  • 해당 부분에도 중복이 발생
  • getUserFailure()에서는 사용되지 않지만 확장성을 고려하여 @Before에서 생성하는 것이 더 좋을 것으로 보임
public class UserDaoTest {
    private UserDao dao;
    private User user1;
    private User user2;
    private User user3;

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

        this.user1 = new User("gyumee", "박성철", "springno1");
        this.user2 = new User("leegw700", "이길원", "springno2");
        this.user3 = new User("bumjin", "박범진", "springno3");
    }
}


' Spring > 토비의 스프링 3.1' 카테고리의 다른 글

2.5 학습 테스트로 배우는 스프링  (0) 2019.01.17
2.4 스프링 테스트 적용  (0) 2019.01.17
2.2 UserDaoTest 개선  (0) 2019.01.11
2.1 UserDaoTest 다시보기  (0) 2019.01.11
2장. 테스트  (0) 2019.01.11