elevne's Study Note

토비의 Spring (5: TDD (1)) 본문

Backend/Toby Spring

토비의 Spring (5: TDD (1))

elevne 2023. 4. 28. 18:19

스프링이 개발자에게 제공하는 가장 중요한 가치가 무엇이냐고 질문한다면 나는 주저하지 않고 객체지향과 테스트라고 대답할 것이다.

 

 

지속적으로 변화하고 복잡해지는 어플리케이션을 효과적으로 개발하기 위해서는, 확장과 변화를 고려한 객체지향적 설계와 그것을 효과적으로 담아낼 수 있는 IoC/DI 기술 뿐만 아니라 테스트 기술이 필요함을 강조한다. 테스트를 통해 코드가 내가 의도했던 대로 정확히 동작하는지를 확인하고, 코드에 대한 확신을 가질 수 있게된다. 

 

 

 

테스트하고자 하는 대상이 명확하다면, 그 대상에만 집중해서 테스트하는 것이 바람직하다. 가능하면 작은 단위로 쪼개서 집중하여 테스트를 진행하는 것이다. 이와 같이 작은 단위의 코등 대해 테스트를 수행하는 것을 Unit Test (단위 테스트) 라고 한다. (어떤 개발자들은 테스트 중 DB 가 사용되면 단위테스트가 아니라고 말한다. DB 의 상태를 테스트가 관장한다면 테스트라고 할 수 있지만, 매번 DB 의 상태가 달라지고, 테스트를 위해 DB 를 특정 상태로 만들어줄 수 없다면 단위 테스트로서 가치가 없어진다. 통제할 수 없는 외부의 리소스에 의존하는 테스트를 단위테스트가 아니라고 보는 입장인 것이다.) 

 

 

 

실제 프로젝트에서는 이전과 같이 main 메서드로 테스트를 하기보다는 JUnit 을 활용하여 테스트를 진행한다. JUnit 은 Java Testing framework 이다. JUnit 을 사용할 때, 테스트 메서드는 public 으로 선언되어야 하며 메서드에는 @Test 어노테이션을 붙여주어야 한다. 

 

 

 

import org.junit.Test;
import org.junit.runner.JUnitCore;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

import java.sql.SQLException;

import static org.hamcrest.core.Is.is;
import static org.junit.Assert.assertThat;

public class UserDaoTest {

    @Test
    public void addAndGet() throws SQLException, ClassNotFoundException {
        ApplicationContext context = new AnnotationConfigApplicationContext(DaoFactory.class);
        UserDao userDao = context.getBean("userDao", UserDao.class);
        User user = new User("TestID", "TestName", "pwd");
        userDao.add(user);
        User user2 = userDao.get(user.getId());

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

    public static void main(String[] args) {
        JUnitCore.main("org.example.firstchapter.UserDaoTest");
    }
}

 

 

result

 

 

 

main() 메서드는 어디에든 추가해주면 된다. 그 안에 JUnitCore 클래스의 main 메서드를 호출해주면서, 내부에 테스트 메서드를 가진 클래스의 이름을 넣어주면 되는 것이다. 결과로 테스트를 실행하는 데 걸리는 시간과 테스트 결과, 그리고 몇 개의 테스트 메서드가 실행됐는지를 보여준다.

 

 

 

지금까지 테스트를 할 때마다, DB 중복 데이터를 방지하기 위해 매번 데이터를 삭제하거나 넣는 데이터를 변경해주어야 했다. 이러한 과정을 생략하기 위한 가장 좋은 해결책은 테스트를 마치고 나서 테스트가 등록한 사용자 정보를 삭제해서 테스트를 수행하기 이전 상태로 만들어주는 것이다. 테이블의 모든 정보를 삭제하는 메서드와, 테이블에 존재하는 데이터의 수를 구하는 메서드를 아래와 같이 작성해준다.

 

 

 

    public void deleteAll() throws SQLException, ClassNotFoundException {
        Connection conn = connectionMaker.makeConnection();
        PreparedStatement ps = conn.prepareStatement("DELETE FROM USERS");
        ps.executeUpdate();
        ps.close();
        conn.close();
    }

    public int getCount() throws SQLException, ClassNotFoundException {
        Connection conn = connectionMaker.makeConnection();
        PreparedStatement ps = conn.prepareStatement("SELECT COUNT(*) FROM USERS");
        ResultSet rs = ps.executeQuery();
        rs.next();
        int count = rs.getInt(1);

        rs.close();
        ps.close();
        conn.close();

        return count;
    }

 

 

 

그 뒤 테스트 코드를 아래와 같이 간단하게 수정한다.

 

 

 

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

        userDao.deleteAll();
        assertThat(userDao.getCount(), is(0));

        User user = new User("TestID", "TestName", "pwd");
        userDao.add(user);
        assertThat(userDao.getCount(), is(1));
        
        User user2 = userDao.get(user.getId());
        assertThat(user2.getName(), is(user.getName()));
        assertThat(user2.getPassword(), is(user.getPassword()));
    }

 

 

 

위 테스트 코드를 사용하면 이전에 사용했던 값으로 다시 테스트를 진행해도 문제 없이 실행되는 것을 확인할 수 있다. 

 

 

 

특정 예외가 발생했을 때에만 테스트가 성공할 수 있게끔 하는 경우도 작성해볼 수 있다. 일반적으로 테스트 중에 예외가 던져지면 테스트 메서드의 실행은 중단되고 테스트는 실패한다. 반대로 테스트 진행 중에 특정 예외가 던져지면 테스트가 성공한 것이고 예외가 던져지지 않고 정상적으로 작업을 마치면 테스트가 실패했다고 판단하는 경우를 작성해보았다. 

 

 

 

    @Test(expected = SQLException.class)
    public void getUserInstance() throws SQLException, ClassNotFoundException {
        ApplicationContext context = new AnnotationConfigApplicationContext(DaoFactory.class);
        UserDao userDao = context.getBean("userDao", UserDao.class);

        userDao.deleteAll();
        assertThat(userDao.getCount(), is(0));

        userDao.get("TEST_FAIL");
    }

 

 

result

 

 

 

@Testexpected 를 추가하면 보통의 테스트와는 반대로, 정상적으로 테스트를 마치면 테스트가 실패하고, expected 에서 지정한 예외가 던져지면 테스트가 성공하게 된다. 개발자가 테스트를 직접 만들 때 자주 하는 실수로, 성공하는 테스트만 골라서 만드는 것이 있다고 한다. 테스트를 작성할 때, 부정적인 케이스를 먼저 만드는 습관을 들이는 것이 좋다고 한다. 

 

 

 

 

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

 

 

TDD 는 개발자가 테스트를 만들어가며 개발하는 방법이 주는 장점을 극대화한 방법이라고 볼 수 있다. TDD 는 테스트를 먼저 만들고 그 테스트가 성공하도록 하는 코드만 만드는 식으로 진행하기 때문에 테스트를 빠뜨리지 않고 꼼꼼하게 만들어낼 수 있다. 또, 테스트를 작성하는 시간과 애플리케이션 코드를 작성하는 시간의 간격이 0 에 가까워져 코드에 대한 피드백을 빠르게 받아볼 수 있다. 

 

 

 

이전까지 작성한 3 개의 메서드에 대한 테스트 코드를 리팩토링 해보았다. 우선 반복되는, 어플리케이션 컨텍스트를 만들고 UserDao 를 가져오는 코드는 다른 하나의 메서드로 뽑아낸다. 이 때, JUnit 의 기능을 사용해볼 수 있다. 테스트를 실행할 때마다 반복되는 준비 작업을 별도의 메서드에 넣게 해주고 이를 매번 테스트 메서드를 실행하기 전에 먼저 실행시켜주는 기능이 있다. @Before 어노테이션을 사용하는 것이다.

 

 

 

public class UserDaoTest {

    private UserDao userDao;

    @Before
    public void setUp() {
        ApplicationContext context = new AnnotationConfigApplicationContext(DaoFactory.class);
        this.userDao = context.getBean("userDao", UserDao.class);
    }

    @Test(expected = SQLException.class)
    public void getUserInstance() throws SQLException, ClassNotFoundException {
        userDao.deleteAll();
        assertThat(userDao.getCount(), is(0));

        userDao.get("TEST_FAIL");
    }

    @Test
    public void addAndGet() throws SQLException, ClassNotFoundException {
        userDao.deleteAll();
        assertThat(userDao.getCount(), is(0));

        User user = new User("TestID", "TestName", "pwd");
        userDao.add(user);
        assertThat(userDao.getCount(), is(1));

        User user2 = userDao.get(user.getId());
        assertThat(user2.getName(), is(user.getName()));
        assertThat(user2.getPassword(), is(user.getPassword()));
    }

    public static void main(String[] args) {
        JUnitCore.main("org.example.firstchapter.UserDaoTest");
    }
}

 

 

 

중복되는 코드를 넣는 setUp() 이라는 이름의 메서드를 생성하고, 다른 테스트 메서드에서 setUp() 에서 만드는 UserDao 에 접근할 수 있도록 인스턴스 변수를 하나 생성한다. 

 

 

 

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

 

 

  • 테스트 클래스에서 @Test 가 붙은 public 이고 void 형이며 파라미터가 없는 테스트 메서드를 전부 찾는다.
  • 테스트 클래스의 오브젝트를 하나 만든다.
  • @Before 이 붙은 메서드가 있으면 실행한다.
  • @Test 가 붙은 메서드를 하나 호출하고 테스트 결과를 저장해둔다.
  • @After 이 붙은 메서드가 있으면 실행한다.
  • 나머지 테스트 메서드에 대해 2 ~ 5 단계를 반복한다.
  • 모든 테스트의 결과를 종합해서 돌려준다.

 

 

JUnit 은 각 테스트 메서드를 실행할 때마다 테스트 클래스의 오브젝트를 새로 만든다. 한 번 만들어진 테스트 클래스의 오브젝트는 하나의 테스트 메서드를 사용하고나면 버려지는 것이다. 이를 통해 JUnit 은 사용자에게 각 테스트가 서로 영향을 주지 않고 독립적으로 실행될 수 있음을 보장한다. (덕분에 인스턴스 변수도 부담 없이 사용 가능하다)

 

 

테스트를 수행하는데 필요한 정보나 오브젝트를 Fixture (픽스처) 라고 한다. Fixture 은 여러 테스트에서 반복적으로 사용되기 때문에 @Before 메서드를 이용해 생성해두면 편리하다. 위에서는 UserDao 가 그 예시가 된다. UserDao 외에도 반복적으로 더미데이터로 사용할 User 클래스들을 여러 개 @Before 에서 만들어두는 것도 가능할 것이다.

 

 

추가로, @Before 메서드는 테스트 메서드 개수만큼 반복되기에, 위 코드는 테스트 메서드를 추가하는 만큼 어플리케이션 컨텍스트를 생성하게 될 것이다. Bean 이 많아지고 복잡해지면 어플리케이션 컨텍스트 생성에 상당한 시간이 소모될 수 있다. 테스트는 가능한 한 독립적으로 매번 새로운 오브젝트를 만들어서 사용하는 것이 원칙이지만, 어플리케이션 컨텍스트처럼 생성에 많은 시간, 자원이 소모되는 경우에는 테스트 전체가 공유하는 오브젝트를 만들기도 한다. 이러한 경우를 위해 JUnit 은 테스트 클래스 전체에 걸쳐 딱 한 번만 실행되는 @BeforeClass 스태틱 메서드를 지원한다. 이는 어플리케이션 컨텍스트를 만들어 static 변수에 저장해두고 테스트 메서드에서 사용할 수 있게끔 한다. (하지만 이보다 Spring 이 직접 제공하는 지원 기능을 사용하는 편이 편리하다)

 

 

 

 

 

Reference:

토비의 스프링

'Backend > Toby Spring' 카테고리의 다른 글

토비의 Spring (6: 템플릿 (1))  (0) 2023.05.03
토비의 Spring (5: TDD (2))  (0) 2023.05.01
토비의 Spring (4: DI (2))  (0) 2023.04.27
토비의 Spring (4: DI (1))  (0) 2023.04.17
토비의 Spring (3: IoC)  (0) 2023.04.15