본문 바로가기
BackEnd/Spring

Spring - 테스트와 TDD

by 규난 2023. 3. 26.
728x90

테스트란?

개발자가 예상하고 의도했던 대로 코드가 정확히 동작하는지 확인해서 만든 코드를 확신할 수 있게 해주는 작업입니다.

테스트하고자 하는 대상이 명확하다면 그 대상에만 집중해서 테스트하는 것이 바람직합니다. 테스트하려는 범위가 클수록 테스트가 힘들어지고 오류가 발생했을 때 정확한 원인을 찾기가 힘들어지므로 가능하면 테스트의 관심이 다르다면 테스트할 대상을 분리(관심사의 분리) 하고 작은 단위로 쪼개서 테스트를 하는 것이 좋습니다. 이것을 단위 테스트라 합니다.

 

이전 포스트에서 테스트를 하기 위해 사용했던 main() 메소드를 보시면 테스트를 하기 위해서 프레젠테이션, 서비스 계층이 필요 없고 한 가지 관심에 집중해서 작은 단위로 테스트를 할 수 있기 때문에 단위 테스트를 적용했다 할 수 있습니다.

package one;

import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

import java.sql.SQLException;

public class Main {

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

        User user = new User();
        user.setId("rbskszz");
        user.setName("한규빈");
        user.setPassword("11111");

        userDao.add(user);

        System.out.println(user.getId() + " 등록 성공");

        User getUser = userDao.get(user.getId());
        System.out.println(getUser.getName());
        System.out.println(getUser.getPassword());
        System.out.println(getUser.getId() + " 조회 성공");
    }
}

위에서 설명한 단위 테스트가 중요한 이유를 말씀드리면

위에서 언급한 내용처럼 프레젠테이션 계층, 서비스 계층 등이 필요 없고 간단하고 테스트하고자 하는 오브젝트에 집중해서 테스트를 할 수 있기 때문입니다.

예를 들어 UserController, UserService, UserDao 이렇게 3개의 오브젝트가 있고 테스트하려는 오브젝트는 UserDao라고 할 때

직접 서버를 띄우고 요청을 보내서 테스트를 하게 된다면 UserController에서 요청을 받아서 UserService를 호출하고 UserService에서 UserDao를 호출해서 UserDao를 검증하게 된다면 테스트하고자 하는 오브젝트인 UserDao의 범위를 넘어서 UserController, UserService를 포함한 전체적인 테스트를 하게 되는 것이며 테스트하려는 오브젝트가 아니라 다른 오브젝트에서 예외가 발생하게 된다면 그 오브젝트를 수정하고 서버를 다시 시작한 후에 재 요청을 보내고 또 예외가 발생하면 수정 후 서버를 다시 시작한 후에 재 요청을 보내고 이런 악순환이 반복이 되어 최악의 경우 원래 테스트하고자 하는 목적인 오브젝트를 테스트하기까지 시간이 굉장히 많이 소요될 수 있습니다. 이러한 이유 때문에 테스트는 작은 단위로 쪼개서 테스트하고자 하는 부분만 테스트가 되게끔 하는 것이 굉장히 중요합니다.

또한 개발자가 설계하고 만든 코드가 원래 의도한 대로 동작하는지를 개발자 스스로가 빨리 확인이 가능하고 초기에 에러를 찾아 디버그가 가능하기 때문에 되도록이면 단위 테스트 작성을 하는 것이 좋습니다.

JUnit의 도입

main() 메서드를 이용한 테스트는 애플리케이션 규모가 커지고 테스트의 개수가 많아지면 테스트를 수행하는 일이 점점 부담이 될 것 입니다. 자바에서는 이런 부담을 줄여 줄 수 있는 단순하면서도 실용적인 단위 테스트를 위한 대표적인 테스팅 프레임워크인 JUnit이 존재합니다.

 

밑의 코드는 main() 메서드에서 작성했던 테스트 코드를 UserDaoTest라는 오브젝트에 addAndGet()이라는 메서드로 옮긴 코드입니다.

Junit을 적용 방법은 라이브러리를 추가 후 public 메소드 위에 @Test 어노테이션만 붙여주면 쉽게 적용할 수 있습니다.

그리고 테스트의 결과를 검증하기 위해 조회 성공이라고 출력했던 부분을 JUnit이 제공하는 assertThat이라는 static 메소드를 이용해서

검증하게 되면 assertThat 메소드의 첫 번째 파라미터(actual value)와 두 번째 파라미터인 matcher의 조건으로 비교해서 일치하면 테스트가 계속 진행이 되고 끝까지 진행이 되면 테스트가 성공되었다고 인식하고 일치하지 않으면 테스트를 실패하도록 만들어 줍니다.

package one;
import org.junit.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import java.sql.SQLException;

import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThat;

public class UserDaoTest {

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

        User user = new User();
        user.setId("rbskszz");
        user.setId("rbsks147");
        user.setName("한규빈");
        user.setPassword("11111");

        userDao.add(user);
        User getUser = userDao.get(user.getId());
        
//        수정 전
//        System.out.println(getUser.getName());
//        System.out.println(getUser.getPassword());
//        System.out.println(getUser.getId() + " 조회 성공");

//        수정 후
        assertThat(getUser.getName(), is(user.getName()));
        assertThat(getUser.getPassword(), is(user.getPassword()));
    }
}

 

UserDao의 get(String id) 메소드의 대한 예외조건 테스트 적용

UserDao의 get(String id) 메소드를 만들 때 한 가지 생각하지 않았던 부분이 있습니다.

바로 전달된 파라미터인 id 값에 해당하는 사용자 정보가 없을 때에 대한 부분입니다.

이럴 땐 어떤 결과가 나오면 좋을까요?? 크게 두 가지 방법을 생각해 볼 수 있습니다. 하나는 null과 같은 특별한 값을 리턴하는 것이고, 다른 하나는 사용자 정보를 찾을 수 없다는 예외를 던져주는 것입니다. 이번 예제에서는 사용자 정보가 없을 때 예외를 던지는 방식을 사용해 보도록 하겠습니다.

 

일단 get 메소드를 수정하기 전에 테스트 코드를 먼저 작성해 보도록 하겠습니다.

예외 발생 여부는 메소드를 실행해서 리턴 값을 비교하는 방법으로 확인할 수 없기 때문에  @Test 어노테이션에 expected라는 엘리먼트를 사용해서 기대하는 예외 클래스를 넣어주면 테스트가 가능해집니다.

package one;

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

import java.sql.SQLException;

import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.assertThat;

public class UserDaoTest {


    /**
     * 해당 사용자에 대한 데이터가 없을때 예외 테스트
     *
     * @throws ClassNotFoundException
     * @throws SQLException
     */
    @Test(expected = SQLException.class)
    public void getUserFailure() throws ClassNotFoundException, SQLException {

        ApplicationContext context = new AnnotationConfigApplicationContext(DaoFactory.class);
        userDao = context.getBean("userDao", UserDao.class);
        userDao.deleteAll();
        assertThat(userDao.getCount(), is(0));

        userDao.get("exceptionUser");
    }
}

이렇게 테스트를 작성한 후 테스트를 실행시키면 결과는 당연히 실패합니다. 그 이유는 UserDao의 get 메소드를 수정하지 않았기 때문이죠. 테스트를 성공시키기 위해 get 메소드를 수정해 보도록 하겠습니다. 수정 후 다시 테스트 코드를 돌려보면 테스트가 성공하는 것을 볼 수 있게 됩니다.

package one;

import org.springframework.core.io.ClassPathResource;
import org.springframework.util.ObjectUtils;

import java.sql.*;
import java.util.Objects;

public class UserDao {

    private ConnectionMaker connectionMaker;

    public UserDao(ConnectionMaker connectionMaker) {
        this.connectionMaker = connectionMaker;
    }

    // add ...
  
    public User get(String id) throws ClassNotFoundException, SQLException {
        // db connection
        Connection connection = connectionMaker.makeConnection();

        // get user
        PreparedStatement ps = connection.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"));
        }

        // connection close
        rs.close();
        ps.close();
        connection.close();

        // 사용자가 없을 때 예외 발생
        if (ObjectUtils.isEmpty(user)) throw new SQLException("user not found");

        return user;
    }

    // deleteAll ...

    // getCount ...

}

TDD(Test Driven Development)

방금 예외 테스트를 만드는 과정을 다시 보면 처음에 add, get 메소드를 만들 때처럼 UserDao에 코드를 먼저 작성 및 수정하지 않고 테스트 코드를 먼저 작성 후 테스트가 실패하는 것을 보고 나서 UserDao의 get 메소드의 코드를 수정하였습니다. 이렇게 만들고자 하는 기능의 내용을 담고 있으면서 만들어진 코드를 검증도 해줄 수 있도록 테스트 코드를 먼저 작성 후 테스트를 성공하게 해주는 코드를 작성하는 방식의 개발 방법을 TDD라고 합니다. TDD는 개발자가 테스트를 만들어가며 개발하는 방법이 주는 장점을 극대화 한 방법이라고 볼 수 있으며 "실패한 테스트를 성공시키기 위한 목적이 아닌 코드는 만들지 않는다."는 것이 TDD의 기본 원칙입니다.

 

TDD는 테스트를 먼저 만들고 그 테스트가 성공하도록 하는 코드를 만드는 식으로 진행하기 때문에 테스트를 빼먹지 않고 꼼꼼하게 만들 수 있고, 테스트를 작성하는 시간과 애플리케이션 코드를 작성하는 시간의 간격이 짧아지게 됩니다. 또한 이미 테스트를 만들어뒀기 때문에 바로 테스트를 실행해 볼 수 있고 그 덕분에 코드에 대한 피드백(성공, 실패, 예외)을 매우 빠르게 받을 수 있게 됩니다.

 

TDD를 사용하지 않고 개발자 머릿속에서 "이런 조건에서 이런 작업을 하면 이런 결과가 나올 것이다."라는 식으로 정리를 한 후 코드를 작성하고 코드를 작성하다가 이런 경우에 문제가 발생하겠다는 생각이 들어 코드를 수정하는 이러한 방식의 코드 작성법은 오류가 많아지게 됩니다. 그래서 차라리 머릿속에서 복잡하게 진행하던 작업을 실제 테스트 코드에 작성하게 되면 TDD가 되고 자연스럽게 단위 테스트가 만들어지게 되며 개발한 코드의 오류를 빨리 발견하고 쉽게 대응이 가능해집니다.

 

출처 - 토비의 스프링 3. 1 Vol 1 스프링의 이해와 원리

728x90