[토비의 스프링 3.1] 2장. 테스트

    Source : yes24

    1. 해당 포스팅은 책읽기 스터디의 활동을 통해 작성된 포스팅입니다.
    2. 공부하면서 블로그를 참고하였는데, 책 내용을 그대로 정리하는데 그치는 글들이 절반이었습니다. 스스로 새롭게 알게 된 내용이거나 책의 설명이 너무 불친절한 경우 부가설명을 작성하거나 또는 새롭게 쓰고자 노력했습니다. 공부하다 생기는 의문들은 레포지토리 이슈에서 질의응답을 주고 받았으니 학습하다 궁금한 점이 생기면 검색 해보시기를 권장드립니다.
    3. 틀린 내용이 있다면 댓글로 알려주시면 감사하겠습니다.
    4. 코드나 책 내용 캡쳐 내용들은 다른 블로그의 캡쳐본이나 텍스트를 가져와 작성하였습니다. 대부분 출처를 표기하였으나 누락된 경우 원하시는 조치 내용을 댓글로 남겨주시면 시정하겠습니다.

    Code : 1장에서 소개된 UserDao가 잘 동작하는지 확인하는 테스트

    public class UserDaoTest (
        public static void main(String[] args) throws SQLException (
            ApplicationContext context =new GenericXmlApplicationContext("applicationContext.xml");
    
            UserDao dao =context.getBean("userDao", UserDao.class);
            User user =new User(); 
            user.setld("user"); 
            user.setName("백기선"); 
            user.setPassword("married");
    
            dao .add(user);
    
            System.out.println(user.getId() + " 등록 성공“);
            User user2 =dao.get(user.getld()); 
            System.out.println(user2.getName()); 
            System.out.println(user2.getPassword());
            System.out.println(user2.getld() + " 조회 성공");
        }
    }

    애플리케이션이 복잡하면 할 수록, 앞으로 나아가기 위해 뒤돌아보지 않게 되는 확신을 가지게 하는 것이 테스트다. 테스트를 잘 작성한다면 리팩토링과 확장 단계에서 문제가 없는지 코드가 의도대로 동작하는지를 확인하는 중요한 수단이다.

    테스트 코드를 사용하지 않는다면? 브라우저로 DAO 테스트를 한다고 가정하자.

    1. 웹 어플리케이션에서 DAO를 손으로 한다고 생각하면, 서비스 레이어, MVC 레이어까지 모두 완성해서 직접 접속할 수 있는 페이지로 접속한 뒤, 일일이 값을 입력하고 확인하는 작업을 손으로 해야한다.
    2. 문제가 생기면 DAO뿐 아니라 모든 레이어의 검증을 거쳐야 한다. 즉 DAO 외적인 문제 해결에 많은 시간을 소모할 것이다.

    테스트하고자 하는 대상이 명확하면 그 대상에만 집중해서 테스트하는 것이 바람직하다. IoC를 다루면서 지겹도록 보았을 관심사의 분리 또한 테스트에 적용된다. 이를 단위 테스트(unit test)라고 한다.

    관심사는 작게는 DAO의 메서드 하나부터, 크게는 유저 관리기능 전체로 볼 수도 있다. 정하기 나름이지만 집중해서 효율적으로 테스트할 수 있도록 범위를 확정한다. 책에서 소개된 UserDaoTest는 그 DAO를 테스트하는데만 집중했다. 그러나 각 기능이 잘 동작하더라도 나중에는 로그인→기능사용→로그아웃까지 여러 기능들이 한 곳에 참여할 때도 잘 동작하는지, 한 번의 호흡으로 테스트해야 할 필요도 있다.

    테스트를 자동으로 수행할 수 있다면, 자주 반복하여도 큰 부담이 없어 코드를 수정할 때마다 수행할 수 있을 것이다.

    UserDaoTest 개선하기

    맨 위의 코드는 몇 가지 문제점이 있다.

    1. 테스트 수행은 자동적으로 해주지만, 결과는 여전히 눈으로 검증하는 과정이 필요하다.
    2. 테스트가 많아진다면 각각의 모든 테스트 main() 메서드를 실행하는건 보통 일이 아니다.

    테스트 검증의 자동화

    테스트는 성공과 실패 두 가지의 결과를 가질 수 있다. 실패는 또 에러가 발생해서 테스트를 마치지 못한 경우와 테스트 수행은 문제없이 되었지만 예상했던 결과와 다르게 나오는 경우로 구분할 수 있다. 전자를 테스트 에러, 후자를 테스트 실패로 책에서는 구분한다.

    UserDaoTest에서는, 콘솔에 결과를 출력해 눈으로 비교하는 과정이 있었지만, 그 대신 아래 코드로 대체하면 결과를 자동으로 확인할 수 있다.

    if(!user.getName().equals(user2.getName())) {
            //테스트 성공!
    }
    else{
            //테스트 실패!
    }

    자동화 테스트 xUnit을 개발한 켄트 백은 "테스트란 개발자가 맘 편히 잠자리에 들 수 있도록 하는 것"이라고 정의했다. 작성한 코드의 기능을 모두 점검할 수 있는 포괄적인 테스트(comprehensive test)를 잘 작성하면, 수정에 코드가 망가지지 않았는지 하는 걱정은 하지 않아도 된다.

    테스트의 효율적인 수행과 결과관리

    UserDaoTest는 테스트의 기능을 모두 갖추었지만, 다수의 테스트를 편리하게 수행하는 데에는 main() 메서드에 구현된 내용으로는 부담이 크다. 그래서 JUnit이라는 테스트 프레임워크를 사용하기로 한다.

    JUnit은 프레임워크로, IoC가 핵심이기 때문에 JUnit 프레임워크에 개발자의 코드 흐름에 대한 제어권을 넘겨주어야 한다. 그러기 위해서는 main() 메서드를 테스트 메서드로 전환하여야 하는데, 2가지 조건을 따라야 한다.

    1. public으로 선언할 것
    2. @Test 어노테이션 붙여줄 것

    Code : JUnit 테스트 메서드로 전환

    import org.junit.Test;
    
    public class UserDaoTest {
        @Test
        public void addAndGet() throws SQLException {
            ApplicationContext context = new ClassPathXmlApplicationContext("applicaionContext.xml");
            UserDao dao = context.getBean("userDao", UserDao.class);
                    //...            
        }
    }

    테스트 메서드로 전환을 마쳤다면 테스트 결과를 검증하는 if/else를 JUnit에 맞게 바꾸어본다.

    if(!user.getName().equals(user2.getName())) {
            //테스트 성공!
    }
    //--------------------------------------
    assertThat(user2.getName(), is(user.getName()));

    assertThat() 메서드는 첫 번째 파라미터의 값을 뒤에 나오는 매처(matcher)라는 조건으로 비교해서 일치하면 다음으로 넘어가고, 아니면 테스트가 실패하도록 한다. is()는 equals()로 비교해주는 매처이다. 만약 결과가 일치하지 않는다면 AssertionError를 던진다.

    JUnit 테스트 실행

    임의의 클래스에 main() 메서드를 추가하고(개인적으론 별도의 클래스로 분리하는 것이 좋다고 생각), JUnitCore 클래스의 main 정적 메서드를 호출하는 간단한 코드를 넣는다. 파라미터에는 @Test 어노테이션이 붙은 테스트 메서드를 가진 클래스의 이름을 넣는다.

    Code : JUnit 테스트의 시작점이 되는 main() 메서드 작성

    import org.junit.runner.JUnitCore;
    //...
    public static void main(String[] args) {
        JUnitCore.main("spring.user.dao.UserDaoTest");
    }

    JUnit

    테스트 결과의 일관성

    테스트는 코드에 변경사항이 없다면 항상 동일한 결과를 내야한다.

    예를 들어 DAO를 테스트할 때, 이전 테스트에서 등록했던 유저정보를 지우지 않고 다음 테스트에서 똑같은 유저정보를 등록한다면, PRIMARY KEY 중복으로 인하여 등록이 되지 않을 것이고 테스트는 실패할 것이다., 그렇기에 테스트를 진행하기 전과 후의 상태가 동일하도록 테스트 중 발생한 변경을 정리해주어야 한다.

    포괄적인 테스트

    개발자는 성공하는 테스트만 골라서 만드는 경향이 있다. 코드를 작성하고 앞으로 나아가고 싶어 하기에 무의식중에 성공할만한 테스트만 작성할 수 있다는 것이다. 존재하는 id의 회원정보를 잘 가져오는 것도 중요하지만, 존재하지 않는 id를 요청했을 때에는 어떻게 반응할지 결정하고 꼼꼼하게 테스트를 작성하는 것이 중요하다. 항상 네거티브 테스트를 만들라는 스프링의 아버지 로드 존슨의 조언을 가슴에 새기도록 하자.

    요청이 실패하는 경우에는 결과값을 null로 반환하던가, 예외를 던질 수 있다. JUnit에서는 중간에 발생하는 예외 또한 테스트 결과로 활용할 수 있다.

    @Test(expected={발생을 기대하는 예외 클래스}.class)

    JUnit의 테스트 흐름

    1. '@Test 어노테이션이 붙으면서 public void 이며 파라미터가 없는 테스트메서드를 모두 찾는다.
    2. 테스트의 대상이 되는 클래스의 Object를 하나 만든다.
    3. @Before 메소드가 있다면 실행
    4. @Test 메소드를 하나 실행하고 결과를 저장
    5. @After 메소드가 있다면 실행
    6. 모든 @Test 메소드에 대해 2~5 반복
    7. 테스트 결과 반환

    위에서 테스트의 공통적인 사전준비나 테스트 결과 정리는 @Before와 @After가 붙은 메서드에 잘 작성하면 되며, 만약 테스트 메서드와 공유해야 하는 데이터가 있다면 인스턴스 변수를 이용하자.

    주의해야 할 점은 각 테스트메서드 마다 테스트 클래스의 인스턴스를 하나씩 만든다는 점이다. @Before→@Test→@After 사이클은 테스트 메서드의 갯수만큼 수행된다. 이는 서로의 테스트가 서로에게 영향을 주지 않도록 한 JUnit의 디자인이다. 다수의 테스트 수행시 순서는 매번 바뀌며, 예측할 수 없다.

    픽스쳐

    테스트를 수행하는데 필요한 정보나 오브젝트를 픽스쳐라고 한다. 이 오브젝트 만드는 코드가 중복된다면 @Before에 작성하자.

    TDD : 테스트가 이끄는 개발(Test Driven Development)

    테스트를 먼저 만들고 테스트가 실패하는 것을 확인한 뒤에 코드를 작성하거나 수정하는 것을 TDD라고 한다. 코드를 어떻게 테스트할까 생각하는 대신, 테스트를 만들며 추가하고 싶은 기능을 표현할 수 있다.

    테스트 코드에는

    • 어떤 조건을 가지고
    • 어떤 행위를 할 때
    • 어떤 결과가 나온다

    라는 것이 표현되어 있다. 기능설계, 구현, 테스트라는 일반적인 개발 흐름에서 기능설계 부분을 테스트를 작성함으로써 대신하는 것이다. 테스트가 하나의 기능 정의서라고 봐도 좋지 않을까?

    "실패한 테스트를 성공하도록 만드는 목적이 아닌 코드는 작성하지 않는다"는 원칙이 TDD의 핵심이다. 테스트를 먼저 만들고 그 테스트가 통과할 수 있도록 작성하는 TDD의 장점은

    1. 테스트부터 작성하기 때문에 빼먹지 않고 테스트 작성을 할 수 있다.
    2. 코드에 대한 피드백을 빠르게 받아볼 수 있다.
    3. 기능이 잘 작동하는지 확신을 가질 수 있다.

    TDD에서는 테스트를 작성하고 코드를 만드는 주기를 가능한 짧게 가져가도록 권장한다.

    2.4 스프링 테스트 적용


    테스트에 필요한 오브젝트는 매 테스트마다 새롭게 생성하는게 원칙이지만, 일부 몇몇 오브젝트는 생성에 오랜 시간이 걸리거나 많은 리소스를 할당하거나 독자적으로 쓰레드를 올리는 등 생성이 무거울 수 있다. 대표적인 예가 ApplicationContext이다. 그러나 매 테스트를 수행할 때 마다 만드는게 낭비일 수 있는 이유는, Application Context는 생성 이후 내부의 변화가 거의 없다. Stateless하다고 볼 수 있으므로 재사용을 하는 것이 좋다.

    객체 생성을 담당하는 코드가 @Before 어노테이션이 달린 메서드에 작성했다면, 매 테스트가 수행될 때 마다 테스트 객체가 생성되므로, 재사용 해도 될 객체를 매 테스트마다 생성한다.

    테스트를 위한 어플리케이션 컨텍스트 관리

    스프링 위의 JUnit은 테스트 컨텍스트 프레임워크, 즉 테스트에서 필요로 하는 ApplicationContext를 만들어서 공유하도록 할 수 있다.

    Code : 스프링 테스트 컨텍스트를 적용한 UserDaoTest

    @RunWith(SpringJUnit4ClassRunner.class) // 스프링 테스트 컨텍스트 프레임워크의 JUnit용 확장기능 설정
    @ContextConfiguration(locations="applicationContext.xml") // 테스트 컨텍스트가 관리하는 Context 설정파일 경로
    public class UserDaoTest {
        @Autowired 
        private ApplicationContext context;
        //...
    
        @Before
        public void setUp() {            
            //ApplicationContext context = new GenericXmlApplicationContext("applicationContext.xml");
            // 매번 새로 생성하지 않도록 주석처리한다.
            this.dao = context.getBean("userDao", UserDao.class);
      }
    }

    @Autowired

    @Autowired 어노테이션이 붙어있는 변수에는 초기화나 의존검색, 주입을 하는 코드가 없음에도 불구하고, 스프링 JUnit 확장기능이 ApplicationContext에 알맞는 Bean을 알아서 매칭해준다. 만약 타입이 일치하는 빈이 유일하지 않다면, 그 다음은 Bean의 이름과 변수 이름이 일치하는 Bean이 있는지 확인한다.

    자기 자신도 Bean이 될 수 있다

    ApplicationContext에는 ApplicationContext 타입의 Bean을 설정하지 않았음에도 불구하고 DI가 된다. 그 이유는 Context에서 자기 자신도 Bean으로 등록한다.

    테스트 코드 전용 DI - @DirtiesContext

    만약 UserDao를 빈에 등록해놓았는데, 실제 서비스에서 사용하는 DB에 연결되어있다고 가정하자. 이 DataSource를 이용했다가 테스트 deleteAll 메서드에 재앙이 벌어질 것이다. 그렇다고 XML 파일을 수정하기엔 너무 귀찮다.

    Code : 일회성 수동 DI를 위한 DirtiesContext 어노테이션

    @DirtiesContext // 이 어노테이션이 붙으면 수동 DI는 이 테스트클래스 내에서만 적용이 된다.
    public class UserDaoTest{
        @Autowired
        UserDao dao;
    
        @Before
        public void setUp(){
            //...
            DataSource dataSource = new SingleConnectionDataSource(
                    "jdbc:mysql://localhost/testdb","spring","book",true);
                    // SingleConnectionDataSource는 단일 커넥션을 사용하는 빠른 타입이다.
                    // 다중환경 테스트는 불가능하지만 테스트는 순차적으로 하나씩 실행되므로 문제없다.
                    // Application에 설정된 UserDao에 주입되는 DataSource 빈은 임시로 이것으로 대체된다.
            dao.setDataSource(dataSource);
        }
    }

    ApplicationContext는 모든 테스트 클래스에서 공유될 것이므로, 만약 DI 관계를 바꾼다면 이 변경점이 모든 테스트에 적용될 것이지만, @DirtiesContext는 해당 테스트 클래스 내부에서만 ApplicationContext를 변경함을 알려준다. 이 어노테이션은 클래스 뿐 아니라 메서드 레벨에도 적용가능하다. 몇몇 예외적인 테스트에 적용하면 좋다.

    테스트 코드 전용 DI - 별도의 설정파일 만들기

    위 방법이 너무 지저분하다고 생각되면, 별도의 설정파일을 두고 테스트에 사용해도 된다. 테스트에서 ApplicationContext를 사용할 경우 고려해보자.

    컨테이너 없는 테스트

    컨테이너 없이 테스트를 수행할 수도 있다. 직접 필요한 객체를 생성하여 테스트하면 가독성이 올라가고 작성도 간편해지지만 복잡한 의존관계 내에서의 테스트가 어려워 질 수 있고 매 테스트마다 객체가 생성된다는 불편한 점이 있으니 가벼운 테스트에서 사용해볼 법한 방법이다. 위에 소개된 세 개의 기법 중 상황에 따라 선택하자.

    침투적 기술과 비침투적 기술

    침투적(invasive)기술은 어플리케이션 코드에 API 호출 등 코드에 의존하기 때문에 해당 프레임워크에 종속적이다. 비침투적(noninvasive) 기술은 프레임워크에 종속적이지 않은 코드를 유지할 수 있도록 해준다. 이는 스프링의 DI가 대표적이며, DI가 주는 장점이다. Plain Old Java Object라고, 순수 자바 코드만으로도 동작하는 POJO 특성을 띄기도 한다.

    학습 테스트

    자신이 작성하지 않은 라이브러리나 프레임워크에 대해 테스트를 작성해야 한다. 이를 학습 테스트라 부르며, 검증이 아닌 사용 방법을 배우는 것이 목적이다.

    학습 테스트의 장점은 아래와 같다.

    1. 다양한 조건에 따른 기능을 손쉽게 확인할 수 있다
      자동화된 테스트 코드를 작성한다면 조건을 바꿔가며 기능이 어떻게 동작하는지 쉽고 빠르게 관측할 수 있다.
    2. 학습 테스트 코드를 개발 중에 참고할 수 있다.
      학습의 목적으로 테스트를 진행하면 실제 개발에서 샘플코드로 참고할 수 있는 좋은 자료가 된다.
    3. 프레임워크나 제품을 업그레이드 할 때 호환성 검증을 도와준다.
      버전업이 일어날 때 미묘한 변화 때문에 미묘한 문제가 일어날 수 가 있는데, 기존에 사용하던 API에 특별한 문제가 없는지 확인해 볼 수 있는 것이 학습 테스트를 하며 작성했던 테스트 코드이다. 업그레이드를 적용하기 전 학습 테스트로 작성했던 코드를 돌려보면 문제가 있는지 여부를 확인하 수 있다.
    4. 테스트 작성에 좋은 훈련이 된다.
    5. 새로운 기술을 공부하는 과정이 즐거워진다.
      딱딱한 문서만 보다보면 흥미를 잃기 쉬운데, 직접 작성하면서 배우는 것은 즐겁다.

    스프링 프레임워크 자체의 테스트 코드는 방대하다. 이를 참고하면 좋은 팁을 많이 얻을 수 있다.

    버그 테스트

    버그 테스트는 코드의 오류를 가장 잘 드러내 줄 수 있는 테스트를 말한다. 실패하는 버그 테스트를 잘 작성했다면, 해당 테스트가 성공할 수 있도록 수정한다.

    장점은

    1. 테스트의 완성도를 높여준다.
      기존 테스트가 허점이 있기 때문에 오류가 발생한 것이다. 버그를 수정했다면 해당 테스트를 원래 테스트에 보완한다.
    2. 버그의 내용을 명확하게 분석할 수 있다.
      예외적인 상황이나 입력 값 때문에 발생하는 오류였다면, 테스트 코드를 작성하면서 어떤 인풋이 문제를 일으키는지, 인풋의 범위는 어떤 것인지 분석해볼 수 있다. 테스트의 중요한 기법인 동등 분할이나 경계값 분석을 적용해볼 수도 있다.
    3. 기술적인 문제를 해결하는 데 도움이 된다.
      코드와 설정만 보고서는 별 문제없이 느낄 수 있지만, 테스트를 작성함으로써 이 문제에 대한 도움을 받을 수 있고, 외부 전문가의 도움을 받는데 좋은 자료가 될 것이다.

    동등 분할

    같은 결과를 내는 값의 범위를 구분해서 각 대표 값으로 테스트 하는 방법, 어떤 작업의 결과의 종류가 true, false 또는 예외발생 세가지라면 각 결과를 내는 입력 값이나 상황의 조합을 만들어 모든 경우에 대한 테스트틀 해보는 것이 좋다.

    경계값 분석

    에러는 동등분할 범위의 경계에서 주로 많이 발생한다는 특징을 이용해서 경계의 근처에 있는 값을 이용해 테스트하는 방법이다. 보통 숫자의 입력 값인 경우 0이나 그 주변 값 또는 정수의 최대값, 최소값 등으로 테스트해보는 것이 좋다.

    댓글