토비의 스프링 정리 프로젝트 #2.3 개발자를 위한 테스팅 프레임워크 JUnit

Jake Seo·2021년 7월 14일
0

토비의 스프링

목록 보기
13/29

JUnit 프레임워크와 스프링

스프링은 테스트를 매우 중요하게 생각한다. 테스팅 없이는 스프링도 의미 없다. 심지어 스프링 프레임워크를 만드는 과정에서도 JUnit 테스트를 이용한 테스트를 꾸준히 해왔다.

스프링의 핵심 기능 중 하나인 스프링 테스트 모듈도 JUnit을 이용한다. 따라서 스프링의 기능을 익히기 위해서 JUnit은 꼭 사용할 줄 알아야 한다.

빌드 툴

ANTMAVEN과 같은 빌드 툴과 스크립트를 사용하면, 빌드 툴에서 제공하는 JUnit 플러그인이나 태스크를 이용해 JUnit 테스트를 실행할 수 있다.

테스트 결과는 빌드 스크립트를 작성하여 옵션에 따라 HTML, 텍스트 파일로 저장하고 이메일로 통보받으면 편리하다.

단, 개발자 개인이 테스트를 수행할 때는 IDE에서 JUnit 도구를 활용해 테스트를 실행하는 게 가장 편리하다.

테스트 결과의 일관성

지금까지 테스트를 실행하며 가장 불편했던 점은 매번 UserDaoTest를 실행하기 전에 DB의 users 테이블의 데이터를 모두 삭제해주는 것이었다. 사실 테스트 코드에 변경사항이 없다면 외부 영향에 상관없이 테스트는 항상 동일한 결과를 내야 좋은 테스트라고 말할 수 있다.

deleteAll(), getCount() 메소드 추가하기

UserDao에 데이터 삭제(deleteAll())와 카운팅(getCount()) 기능을 추가하자.

    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();
        ps.close();
        c.close();

        return count;
    }

deleteAll()과 getCount()의 테스트

deleteAll()을 따로 테스트하는 것은 애초에 테이블에 아무것도 없는 상태에서는 의미가 없으니 기존에 있던 테스트인 addAndGet() 테스트를 확장해보자.

deleteAll()getCount()를 확장된 테스트에서 둘 다 테스트해볼 건데 대략적인 설계는 아래와 같다.

  • deleteAll()이 정상적으로 수행된 상태라면 getCount()의 결과는 0이다.
    • deleteAll(), getCount() 기능 동작 확인
  • add()을 이용해 User를 추가하면, getCount()의 결과는 1이다.
    • 유저가 있을 때, getCount() 기능 동작 확인
  • get()을 이용해 User를 가져와서 내가 추가했던 유저가 맞는지 확인한다.
  • 다시 deleteAll()User를 삭제하면, getCount()의 결과는 0이 나와야 한다.
    • 유저가 있을 때, deleteAll(), getCount() 기능 동작 확인
@Test
    public void addAndGet() throws SQLException {
        ApplicationContext applicationContext = new GenericXmlApplicationContext("spring/applicationContext.xml");
        UserDao userDao = applicationContext.getBean(UserDao.class);

        // `deleteAll()`, `getCount()` 기능 동작 확인
        userDao.deleteAll();
        assertEquals(userDao.getCount(), 0);

        User userToAdd = new User();
        userToAdd.setId("jinkyu1");
        userToAdd.setName("진규");
        userToAdd.setPassword("password");
        userDao.add(userToAdd);
        // 유저가 있을 때, `getCount()` 기능 동작 확인
        assertEquals(userDao.getCount(), 1);

        User userToGet = userDao.get("jinkyu1");
        // 유저가 제대로 등록되었는지 확인
        assertEquals(userToAdd.getId(), userToGet.getId());
        assertEquals(userToAdd.getName(), userToGet.getName());
        assertEquals(userToAdd.getPassword(), userToGet.getPassword());

        // 유저가 있을 때, `deleteAll()`, `getCount()` 기능 동작 확인
        userDao.deleteAll();
        assertEquals(userDao.getCount(), 0);
    }

addAndGet() 테스트가 갖는 책임이 너무 많은 것 같지만, 일단은 정상적으로 동작한다.

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

위와 같이 테스트를 변경하면, 처음 시작부분에 데이터를 초기화하는 부분이 있어서 테스트를 몇번이고 반복해도 계속 성공할 것이다.

테스트가 어떤 상황에서 반복적으로 실행된다고 하더라도 동일한 결과가 나올 수 있게 된 것이다. 단위테스트는 코드가 바뀌지 않는다면 매번 실행할 때마다 동일한 테스트 결과를 얻을 수 있어야 한다.

동일한 테스트 결과를 얻기 위해서는 두가지 전략이 있을 수 있는데,

  • 테스트를 시작할 때 데이터를 초기화한다.
  • 테스트를 끝낸 뒤에 데이터를 초기화해준다.

이 중 테스트를 끝낸 뒤에 데이터를 초기화하는 방법은 테스트만 DB를 사용할 것이 아니라면 이전에 어떤 작업을 하다가 테스트를 실행할 지 알 수 없기 때문에 테스트를 시작할 때 초기화하는 편이 좋을 것이다.

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

포괄적인 (꼼꼼한) 테스트

미처 생각지도 못한 문제가 숨어있을지도 모르니 더 꼼꼼한 테스트를 해보는 것이 좋은 자세이다. 테스트를 안 만드는 것도 위험하지만, 성의 없이 테스트를 작성하여 문제가 있는 코드의 문제를 파악하지 못하는 것도 위험하다. 특히 한가지 결과만 검증하고 마는 것은 위험하다.

getCount() 테스트

JUnit은 하나의 클래스 안에 여러 개의 테스트 메소드가 들어가는 것을 허용한다. @Test가 붙어있고 public 접근자가 있으며 리턴 값이 void형이고 파라미터가 없다는 조건을 지키기만 하면 된다.

@Test
    @DisplayName("회원 카운팅")
    public void getCount() throws SQLException {
        ApplicationContext applicationContext = new GenericXmlApplicationContext("spring/applicationContext.xml");
        UserDao userDao = applicationContext.getBean(UserDao.class);

        User user1 = new User("user1", "김똘일", "1234");
        User user2 = new User("user2", "김똘이", "1234");
        User user3 = new User("user3", "김똘삼", "1234");
        User user4 = new User("user4", "김똘사", "1234");

        userDao.deleteAll();
        assertEquals(userDao.getCount(), 0);

        userDao.add(user1);
        assertEquals(userDao.getCount(), 1);

        userDao.add(user2);
        assertEquals(userDao.getCount(), 2);

        userDao.add(user3);
        assertEquals(userDao.getCount(), 3);

        userDao.add(user4);
        assertEquals(userDao.getCount(), 4);

        userDao.deleteAll();
        assertEquals(userDao.getCount(), 0);
    }

4명의 유저를 차례로 추가하면서 검증해보았다. 마지막에는 전부 삭제하여 다시 0이 카운팅되는지도 검증했다.

addAndGet() 테스트 보완

    @Test
    @DisplayName("회원 추가 및 불러오기")
    public void addAndGet() throws SQLException {
        ApplicationContext applicationContext = new GenericXmlApplicationContext("spring/applicationContext.xml");
        UserDao userDao = applicationContext.getBean(UserDao.class);

        // `deleteAll()`, `getCount()` 기능 동작 확인
        userDao.deleteAll();
        assertEquals(userDao.getCount(), 0);

        User user1 = new User();
        user1.setId("jinkyu1");
        user1.setName("진규");
        user1.setPassword("password");
        userDao.add(user1);
        // 유저가 있을 때, `getCount()` 기능 동작 확인
        assertEquals(userDao.getCount(), 1);

        User user2 = new User();
        user2.setId("jake2");
        user2.setName("제이크");
        user2.setPassword("password");
        userDao.add(user2);
        // 유저가 있을 때, `getCount()` 기능 동작 확인 2
        assertEquals(userDao.getCount(), 2);

        User user1Get = userDao.get("jinkyu1");
        // 유저가 제대로 불러와지는지 확인
        assertEquals(user1.getId(), user1Get.getId());
        assertEquals(user1.getName(), user1Get.getName());
        assertEquals(user1.getPassword(), user1Get.getPassword());

        User user2Get = userDao.get("jake2");
        // 항상 같은 유저를 불러오는 것은 아닌지, 유저가 제대로 불러와지는지 확인
        assertEquals(user2.getId(), user2Get.getId());
        assertEquals(user2.getName(), user2Get.getName());
        assertEquals(user2.getPassword(), user2Get.getPassword());

        // 유저가 있을 때, `deleteAll()`, `getCount()` 기능 동작 확인
        userDao.deleteAll();
        assertEquals(userDao.getCount(), 0);
    }

항상 같은 User를 불러오는 것은 아닌지, 새로운 User를 추가했을 때 새로운 User를 불러오는지에 대해 더 검증해보았다.

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

get() 메소드에 존재하지 않는 id를 입력하면 어떻게 될까? 이럴 땐 어떤 결과를 내보내는 것이 좋은지 생각해보면 두가지 방법이 있다.

  • 방법1: null과 같은 특수한 값을 반환한다.
  • 방법2: id에 해당하는 정보를 찾을 수 없다는 예외를 던진다.
    • 이 경우 사용할 예외 클래스가 필요하다.
    • 일단은 새로 만들지 않고, 스프링 자체 예외 클래스인 EmptyResultDataAccessResult 예외를 이용해보자.
@Test
    @DisplayName("존재하지 않는 회원을 조회할 때")
    public void getUserFailure() {
        ApplicationContext applicationContext = new GenericXmlApplicationContext("spring/applicationContext.xml");
        UserDao userDao = applicationContext.getBean(UserDao.class);

        // 스프링이 제공하는 EmptyResultDataAccessException 예외가 나타나게 만들자.
        assertThrows(EmptyResultDataAccessException.class, () -> {
            userDao.get("not_existing_user_id");
        });
    }

EmptyResultDataAccessException 클래스의 예외가 던져지면 테스트가 성공한다. 현재는 expected: <org.springframework.dao.EmptyResultDataAccessException> but was: <org.postgresql.util.PSQLException>와 같이 PSQLException 예외가 나타난다.

rs.next()를 실행할 때, 가져올 로우가 없다는 SQLException이 발생하기 때문이다. UserDao 코드를 수정하여 위 테스트가 성공하도록 만들어보자.

테스트 성공을 위해 UserDao 코드 수정하기

get() 메소드만 수정하면 되기 때문에 get() 메소드를 수정하겠다.

    public User get(String id) throws SQLException {
        // 1.2.2 중복 코드의 메소드 추출
        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"));
        }

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

        if(user == null) throw new EmptyResultDataAccessException(1);

        return user;
    }

다시 테스트를 수행해보면

새로 만든 테스트를 포함해서 기존에 있던 테스트도 전부 정상적으로 동작한다. 내가 변경한 .get() 메소드의 내용이 다른 기능에 영향을 주지 않았다는 것을 알 수 있다.

포괄적인 테스트의 이유

사실 DAO를 만든 경험이 많은 개발자라면, 위의 기능 정도는 테스트 없이도 정상적으로 작동할 것이라는 사실을 눈으로도 쉽게 알 수 있다. 하지만, 이렇게 DAO의 메소드에 대한 포괄적인 테스트를 만들어두는 편이 훨씬 안전하고 유용하다.

특히 만일 코드가 특정 상황에만 정상적으로 작동하지 않는다면, 문제가 발생했을 때 원인을 찾기 힘들어서 고생할 수도 있다. 종종 단순하고 간단한 테스트가 치명적인 실수를 피할 수 있게 도와주기도 한다.

개발자가 테스트를 직접 만들 때, 자주하는 실수는 성공하는 테스트만 골라서 만드는 것이다. 개발자는 머릿속으로 자신이 작성한 코드가 잘 돌아가는 상상을 하며 코드를 만드는 경우가 일반적이다.

"내 PC에서는 잘 되는데"라는 변명은 사실 개발자 PC에서 테스트할 때는 예외적인 상황은 모두 피하고 정상적인 케이스만 테스트해봤다는 뜻이 될 수 있다.

스프링의 창시자인 로드 존슨은 "항상 네거티브 테스트를 먼저 만들라"는 조언을 했다.

테스트를 작성할 때 부정적인 케이스를 먼저 만드는 습관을 들이는 게 좋다. get() 메소드의 경우라면, 존재하는 id가 주어졌을 때, 해당 레코드를 정확히 가져오는가를 테스트하는 것도 중요하지만, 존재하지 않는 id가 주어졌을 때는 어떻게 반응할지를 먼저 결정하고, 이를 확인할 수 있는 테스트를 먼저 만들려고 한다면, 예외를 빠뜨리지 않는 꼼꼼한 개발이 가능하다.

테스트가 이끄는 개발

테스트할 코드를 먼저 만드는 것이 아니라, 테스트 코드부터 만드는 개발 전략이 실제로 존재한다. 게다가 많은 전문적인 개발자들이 이런 개발 방법을 적극적으로 활용하고 있다.

기능설계를 위한 테스트

만들어진 코드를 보고 '이것을 어떻게 테스트할까?' 생각하는 것이 아니라 '추가하고 싶은 기능을 테스트 코드로 표현해보자' 생각하면 기능설계를 위한 테스트를 만들 수 있다.

getUserFailure() 테스트에는 만들고 싶은 기능에 대한 조건, 행위, 결과가 잘 표현되어 있다.

  • 조건 (when): 가져올 사용자 정보가 존재하지 않는 경우에
    • 코드: dao.deleteAll(); assertEquals(dao.getCount, 0);
  • 행위 (if): 존재하지 않는 idget()을 실행하면,
    • 코드: userDao.get("not_existing_user_id");
  • 결과 (then): 특별한 예외가 던져진다.
    • 코드: assertThrows(EmptyResultDataAccessException.class, ...)

기능 설계, 구현, 테스트라는 일반적인 개발 흐름 중 기능 설계에 해당하는 부분을 테스트코드가 일부 담당한다고 볼 수 있다. 이런식으로 추가하고 싶은 기능을 일반 언어가 아니라 테스트코드로 표현해서, 마치 코드로 된 설계문서처럼 만들어놓은 것이라고 생각해보자. 그 후 실제 기능을 가진 애플리케이션 코드를 만들고 나면, 바로 이 테스트를 실행해서 설계한대로 코드가 동작하는지 빠르게 검증할 수 있다.

코드를 수정하고 테스트를 수행해서 테스트가 성공하도록 애플리케이션 코드를 계속 다듬어 테스트가 성공하면, 코드 구현과 테스트라는 두가지 작업이 동시에 끝나는 흥미로운 개발 방법이다.

테스트 주도 개발

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

"실패한 테스트를 성공시키기 위한 목적이 아닌 코드는 만들지 않는다" 는 것이 TDD의 기본 원칙이다. 이 원칙을 따르면 TDD 과정에서 생겨난 모든 코드는 빠짐없이 테스트로 검증된 것이다.

일반적으로 개발자는 많은 코드를 만들고 나면 테스트를 만들기 귀찮아한다. TDD는 테스트를 먼저 만들고 테스트를 성공시키기 위한 코드만 만들기 때문에 테스트를 빼먹지 않고 꼼꼼하게 만들어낼 수 있다. 또한 테스트를 작성하는 시간과 애플리케이션 코드를 작성하는 시간의 간격이 짧아진다.

TDD에서는 테스트를 작성하고 이를 성공시키는 코드를 만드는 작업의 주기를 가능한 짧게 가져가도록 권장한다. 테스트를 반나절동안 만들고 오후 내내 테스트를 통과시키는 코드를 만드는 식의 개발은 그다지 좋은 방법이 아니다.

사실 모든 개발자는 TDD를 모르더라도 이미 테스트가 개발을 이끌어가는 방식으로 개발하고 있다. '이런 조건에서 이런 작업을 하면 이런 결과가 나올 것이다'라는 식으로 기능을 먼저 머릿속에서 정리하기 때문이다.

그리고 코드를 작성하면서도 머릿속으로는 계속 어떤 조건 하에서 어떤 행위를 하면 어떤 결과가 나올지 시뮬레이션한다. 코드를 살펴보다가 이런 경우에는 문제가 발생하겠다는 생각이 들면 코드를 수정할 것이다. 테스트가 실패했으니 테스트가 성공하도록 코드를 수정하는 것과 다를 바 없다.

문제는 이렇게 머릿속에서 진행되는 테스트는 제약이 심하고, 오류가 많고, 나중에 반복하기 힘들다. 스프링의 이점을 활용하여 편하게 테스트하며 개발해보자.

테스트코드 개선

UserDaoTest에는 사실 반복되는 부분의 코드가 조금 있다.

ApplicationContext applicationContext = new GenericXmlApplicationContext("spring/applicationContext.xml");
UserDao userDao = applicationContext.getBean(UserDao.class);

대표적으로 위와 같이 xml 파일을 이용해 애플리케이션 컨텍스트를 만들고, userDao 빈을 꺼내오는 작업이 있다.

중복된 코드는 메소드로 뽑아서 사용하는 것이 가장 간단한 방법이지만, JUnit에서는 편리한 특수 기능을 제공한다.

@BeforeEach

JUnit에는 @BeforeEach라는 애노테이션이 있어서, 매 테스트 메소드 실행 전마다 실행될 코드를 입력해놓을 수 있다.

JUnit4에서는 @Before이고, JUnit5에서는 @BeforeEach이다.

public class UserDaoTest {
    UserDao userDao;

    @BeforeEach
    public void setUp() {
        ApplicationContext applicationContext = new GenericXmlApplicationContext("spring/applicationContext.xml");
        userDao = applicationContext.getBean(UserDao.class);
    }
...

위와 같이 세팅을 해놓으면, 매번 테스트 메소드를 실행하기 전에 setUp() 메소드가 수행된다.

JUnit 프레임워크의 동작방식

프레임워크는 제어권을 가지고 주도적으로 동작하고, 개발자가 만든 코드는 프레임워크에 의해 수동적으로 실행된다. 그래서 프레임워크에 사용되는 코드만으로는 실행 흐름이 잘 보이지 않기 때문에 프레임워크가 어떻게 사용할지를 잘 이해하고 있어야 한다.

JUnit 이 하나의 클래스를 가져와 테스트를 수행하는 방식은 다음과 같다.

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

실제로는 이보다 복잡하지만 간단하게 7단계로 표현했다.

JUnit은 @Test가 붙은 메소드를 실행하기 전에 매번 @BeforeEach가 붙은 메소드를 실행하고, @Test가 붙은 메소드 수행을 마치면 매번 @AfterEach가 붙은 메소드를 실행한다.

또, JUnit은 각 테스트 메소드를 실행할 때마다 테스트 클래스의 오브젝트를 새로 만든다. 한번 만들어진 테스트 클래스의 오브젝트는 하나의 테스트 메소드를 사용하고 나면 버려진다. 테스트 클래스가 @Test 테스트 메소드를 두 개 갖고있다면, 테스트가 실행되는 중에 JUnit은 이 클래스의 오브젝트를 두번 만들 것이다.

JUnit의 개발자는 각 테스트가 서로 영향을 주지 않고 독립적으로 실행됨을 확실히 보장해주기 위해 매번 새로운 오브젝트를 만들게 했다. 덕분에 인스턴스 변수도 부담없이 사용할 수 있다. 어차피 다음 테스트 메소드가 실행될 때는 새로운 오브젝트가 만들어서 전부 초기화될 것이란 걸 알기 때문이다.

테스트의 일부에서만 공통적으로 사용되는 코드

이 때는 @BeforeEach보다는 일반적인 메소드 추출 방법을 통해 메소드를 분리하는 것이 좋다. 혹은 아예 공통적인 특성을 지닌 테스트 메소드를 모아둘 테스트 클래스를 따로 작성하는 것도 나쁘지 않다.

픽스쳐

테스트를 수행하는데 필요한 정보나 오브젝트를 픽스쳐(fixture)라고 한다. 일반적으로 픽스쳐는 여러 테스트에서 반복적으로 사용되기 때문에 @BeforeEach와 같은 메소드를 이용해 생성하여 편리하게 사용한다.

public class UserDaoTest {
    UserDao userDao;
    User user1;
    User user2;
    User user3;
    User user4;

    @BeforeEach
    public void setUp() {
        ApplicationContext applicationContext = new GenericXmlApplicationContext("spring/applicationContext.xml");
        this.userDao = applicationContext.getBean(UserDao.class);

        this.user1 = new User("user1", "김똘일", "1234");
        this.user2 = new User("user2", "김똘이", "1234");
        this.user3 = new User("user3", "김똘삼", "1234");
        this.user4 = new User("user4", "김똘사", "1234");
    }
...

위와 같이 픽스쳐(테스트용 오브젝트)를 @BeforeEach에 넣어두면 조금 더 편하게 테스트를 해볼 수 있다.

클래스에서 인스턴스 선언 즉시 초기화해도 되지만, 로직을 모아두기 위해 @BeforeEach에서 해주자.

profile
풀스택 웹개발자로 일하고 있는 Jake Seo입니다. 주로 Jake Seo라는 닉네임을 많이 씁니다. 프론트엔드: Javascript, React 백엔드: Spring Framework에 관심이 있습니다.

0개의 댓글