본문 바로가기

Study

토비의 스프링 - 2. 테스트 (2)

지난 글에 이어서 토비의 스프링 2장의 내용을 다루는 포스팅입니다.


 

2 - 3. 개발자를 위한 테스팅 프레임워크 JUnit

 

JUnit 에 대해 조금 더 이야기해 볼까 합니다. JUnit 은 Java 테스팅 프레임워크의 표준에 가깝게 사용되는 프레임워크입니다. 따라서 java 를 사용하여 개발한다면, 테스트를 위해 JUnit 을 알아두는 것이 좋습니다. 무엇보다 스프링의 테스트 모듈은 JUnit 프레임워크와의 통합을 지원하며, SpringBoot 에서는 기본 테스트 모듈에 아예 JUnit5(Jupiter) 가 포함되어 있기 때문에 스프링을 제대로 공부하기 위해서라도 JUnit 을 학습해야 합니다.

JUnit 과 같은 테스트 프레임워크가 제공하는 기능은 여러 가지가 있지만, 가장 두드러지는 장점은 앞서 UserDaoTest 의 개선 과정에서 알 수 있듯 자동 검증자동 실행입니다. 앞서 자동 검증과 실행이 수동으로 진행하는 테스트와 비교하여 어떤 편의성을 제공해 주는지 알아봤다면, 이번에는 JUnit 으로 활용할 수 있는 검증 기능과 IDE 와 통합하여 더욱 간단하게 사용 가능한 JUnit 의 테스트 기능들을 알아보겠습니다.

 

자동 검증

Java 어플리케이션의 검증에 사용할 수 있는 assertion 메서드(이하 검증 메서드)는 다양한 라이브러리에서 제공하는 것을 사용할 수 있습니다. JUnit 에서도 버전별로 상이하지만 기본 검증 메서드를 제공하고 있으며, AssertJ 와 Hamcrest 등 서드파티 라이브러리의 assertion과 matcher 를 사용해서 더 강력한 테스트 표현식을 작성할 수 있습니다. 여기서 잠시, 책이 쓰여질 시기와 조금은 달라진 검증 메서드의 사용과 헷갈리는 의존성들(어떤 검증 메서드를 사용하지? 이건 뭐고 저건 뭐지?)을 간단하게 정리해 보겠습니다. 

JUnit4 이전 버전(이하 JUnit)은 org.junit.Assert  클래스 내에 검증과 관련된 여러 static 메서드를 제공합니다. 그러나 이 메서드들은 현재 deprecate 되었습니다. 그렇기 때문에 앞서 addAndGet() 메서드에서 *Hamcrest 에 포함된 검증 메서드를 사용한 것입니다.

 

Hamcrest?

java 언어의 테스트에 사용되는 프레임워크로, 테스트 표현식의 우아하고 가독성 있는 표현을 위해 사용됩니다. 다른 테스트 프레임워크에 통합되어 사용할 수 있도록 설계되었으며, java 에서 시작되었으나 현재는 python, ruby, go, c# 등 다양한 언어에서 사용할 수 있도록 확장되었습니다.
검증(MatherAssert) 자체보다, 강력한 Matcher 로 자연스러운 연결과 문맥 표현이 강점인 프레임워크입니다.
JUnit4 에 포함되었으나 JUnit5(Jupiter) 에서는 제외되었습니다. - JUnit5 는 모듈화를 통한 경량화라는 컨셉이 있었기 때문입니다.- 개발 성능과 타입 안정성 문제로 현재는 AssertJ 같은 대체 라이브러리들도 많이 사용됩니다.

 

"그러면 JUnit 을 사용하고 있는데, JUnit 자체의 검증 메서드가 deprecate 된 지금 검증을 위해서는 반드시 Hamcrest 와 같은 외부 의존성을 포함시켜야 하나?"

하는 생각이 들 수도 있습니다.

결론부터 말하면, 검증을 위해 외부 의존성이 반드시 필요한 것도 아니고 의존성을 걱정할 필요도 없습니다.

요즘 주로 사용하는, Spring Framework 의 완벽한 상위 호환인 SpringBoot 에는 spring-boot-starter-test  라는 테스트 모듈이 포함되어 있습니다. 이 스타터 모듈은 테스트에 기본적으로 필요한 여러 라이브러리들을 포함합니다.

 

 

위 이미지는 SpringBoot 의 공식 문서를 참조한 내용입니다. spring-boot-starter-test  모듈에는 위에서 보는 것과 같이 JUnit5, AssertJ, Hamcrest, Mokito 등 검증 외에도 모킹, Json 관련 라이브러리 등 필수 요소들을 포함하고 있습니다. 따라서 SpringBoot 를 사용한다면 별다른 의존성 추가 없이도 AssertJ 의 검증 메서드, Hamcrest 의 매칭 함수 등 필요한 기능을 적절하게 사용할 수 있습니다.

또한 JUnit 의 Assert 클래스가 deprecate 되었더라도, JUnit5(Jupiter)가 별도로 제공하는 검증 메서드들이 있어 이를 사용할 수 있습니다. org.junit.jupiter.api.Assertions 클래스에는 다른 의존성 없이 JUnit5 에서 자체적으로 가지고 있는 assertAll, assertEquals, assertNotNull 등 다양한 static 메서드가 존재합니다. AssertJ 나 Hamcrest 의 사용이 어색하다면 JUnit5 의 기본 검증 메서드를 사용하는 것도 방법입니다.
다만 JUnit5 의 기본 검증 메서드보다 AssertJ 의 검증 메서드들이 가독성 좋은 표현식이나 복잡한 조건의 처리에서 더 좋은 성능을 가지고 있고, JUnit5 의 공식 문서에서도 더 높은 성능이나 추가 Matcher 가 필요한 경우 AssertJ 나 Hamcrest 와 같은 서드파티 라이브러리들을 사용할 것을 권장하고 있기 때문에 이러한 라이브러리의 사용에 미리 익숙해지는 것이 좋겠습니다.

 

결론지어 보면, JUnit 은 이전 버전부터 현재 사용되는 JUnit5 까지 꾸준히 자체 제공하는 검증 메서드를 가지고 있습니다. 이것만으로도 충분히 테스트코드의 작성이 가능합니다.
그러나 JUnit 과 통합하여 사용될 수 있는 AssertJ 나 Hamcrest 등을 활용한다면, 복잡한 조건이나 표현식에 구애받지 않는 강력한 테스트코드를 더 우아하게 작성할 수 있기 때문에 이런 외부 라이브러리들을 적극 활용하는 것이 좋은 방향입니다. 더욱이 이런 라이브러리들은 SpringBoot 를 사용한다면 기본 모듈에 포함되어 있기 때문에 의존성에 대한 염려를 하지 않아도 됩니다.

 

자동 실행

JUnit 의 또하나의 강점은, IDE 에서 더욱 간편하게, 한번에 여러 테스트를 수행할 수 있다는 것입니다. 책에서는 eclips IDE 를 사용하여 실행하는 것을 예로 들었지만, 현재는 eclips 를 거의 사용하지 않고 IntelliJ IDE 를 많이 사용하는 추세이기 때문에 IntelliJ 에서 테스트를 수행하는 방법만 간단히 다루겠습니다.

가장 간단하게, 하나의 테스트 클래스, 또는 하나의 테스트 메서드를 실행하는 방법은 다음과 같이 클래스 또는 메서드 옆의 실행 버튼을 누르는 것입니다.

 

메서드 옆의 실행 버튼을 누르면 해당 테스트 메서드만 실행됩니다. 클래스의 실행 버튼을 누르면 해당 클래스 내의 모든 테스트 메서드들이 실행됩니다.

어플리케이션의 모든 테스트 코드를 한 번에 실행시켜야 할 때가 있습니다. 그 때는 모든 테스트를 포함한 가장 상위의 패키지를 우클릭한 후 실행 버튼을 누르면 됩니다. 현재는 UserDaoTest 내의 addAndGet() 메서드만 존재하기 때문에 하나의 테스트만 실행됩니다.

 

포괄적인 테스트

현재 UserDaoTest 는 본연의 기능인 add 와 get 이 잘 동작한다는 것을 테스트를 통해 알 수 있습니다. 하지만 정말 잘 동작하는 코드임을 확신하게 하는 완벽한 테스트라고 할 수 있을까요?

모든 테스트는 실패하는 케이스를 포함하여 검증해야 합니다. 테스트 작성 방법론 중에 '실패하는 케이스를 먼저 만들어라' 는 말이 있습니다. 개발자는 대개 성공하는 케이스에 대한 테스트만을 작성하려 하는 습성이 있습니다. 더 나아가서, 성공할 수밖에 없는 케이스의 테스트만 교묘하게 작성하고 통과한 코드에 대한 확신을 얻는다고 합니다. 일부러 그런다기 보다는, 자신이 힘들여 작성한 코드가 옳게 동작하는 코드임을 믿고 싶은 마음이 그렇게 행동하게 만든다고 생각합니다. 목표한 기능이 잘 동작하는 것만 검증하고 자신의 코드에 안심하는 것이지요.

그러나 테스트는 코드가 동작할 수 있는 모든 경우의 수를 커버해야 함은 물론, 잘못된 케이스에 대한 검증까지 포함해야 합니다. 대표적으로 잘못된 입력이 들어왔을 때, 약속한 응답이나 예외를 뱉지 않는다면 프로덕션에서 큰 결함으로 나타나게 될 것입니다. 실패를 잡지 못하는 테스트는 하루에 두 번은 맞는다는 죽은 시계와 같을 수도 있습니다.

그런 의미에서 UserDaoTest 의 부정적인 케이스를 검증하는 테스트 메서드를 추가해 보겠습니다. 

예를 들어, UserDao의 get() 메서드의 파라미터로 저장되지 않은 Id 를 넣고 호출한다고 가정합니다. 
잘못된 호출이므로, get() 메서드에서는 null 과 같은 약속된 특수 값을 리턴하거나 예외를 던지는 방법이 있을 것입니다. 여기서는 잘못된 Id 로 get() 메서드를 호출할 경우 " IllegalArgumentException 예외를 던진다" 는 요구사항이 있다고 가정해 보겠습니다.

그렇다면 UserDaoTest 에도 "잘못된 파라미터로 get() 을 호출했을 때 예외가 반환된다" 는 케이스를 검증할 테스트가 필요할 것입니다. 예외가 던져졌을 때 테스트가 통과되도록 하는 조금 특이한 테스트를 작성해야 합니다. 이 케이스도 마찬가지로 jupiter 에서 제공하는 assertion이 있고, assertJ 에서 제공하는 assertion 이 있습니다.

먼저 junit.jupiter.api.Assertions 클래스에 포함된 스태틱 검증 메서드를 사용해 보겠습니다.

import static org.junit.jupiter.api.Assertions.assertThrows;

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

        dao.deleteAll();

        User user = new User();
        user.setId("whiteship");
        user.setName("백기선");
        user.setPassword("married");

        dao.add(user);
        
        // Exception assertion
        assertThrows(IllegalArgumentException.class, () -> {
            dao.get("illegalId");
        });
    }

 

가장 아래 부분의 assertThrows() 메서드가 바로 예외를 검증하는 부분입니다. 검증에 사용한 assertThrows() 메서드는 던져진 예외가 예상되는 예외 타입(IllegalArgumentException) 또는 하위 타입과 일치하는지를 검증합니다. 하위 타입을 허용하지 않고 정확하게 예상 예외 타입임을 검증하기 위해서는 assertThrowsExactly() 를 사용합니다.



위와 같이 작성한 테스트를 실행해 보면, 당연히 실패합니다. 예외가 발생해야 성공하는 테스트인데 어플리케이션 코드인 UserDao 에는 어떤 작업도 하지 않았기 때문입니다. 이 테스트가 통과하도록 하려면 UserDao 의 get() 메서드에서 잘못된 파라미터에 대해 예외를 반환하도록 수정해 주어야 합니다.

public User get(String id) throws ClassNotFoundException, SQLException {
        Connection c = this.connectionMaker.makeConnection();
        PreparedStatement ps = c.prepareStatement("select * from users where id = ?");
        ps.setString(1, id);
        ResultSet rs = ps.executeQuery();
        rs.next();

        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 IllegalArgumentException();
        return user;
    }

 

잘못된 파라미터로 조회를 시도할 경우 IllegalArgumentException 을 반환하도록 코드를 수정하였습니다.
그리고 다시 테스트를 돌려 보면,

 

 

테스트가 성공했음을 알 수 있습니다.

 

다음은 assertJ 의 예외 검증 메서드를 사용해 보겠습니다.  assertJ.core.api 내에는 매우 다양한 검증 메서드들이 있으며, 같은 기능 또는 목적이더라도 가독성을 위해 다양한 메서드를 사용할 수 있는 선택지가 있습니다. 또 더욱 세부적이고 까다로운 검증 요구사항을 해결할 수 있도록 높은 성능의 assertion 을 제공합니다.

import static org.assertj.core.api.Assertions.assertThatThrownBy;

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

        dao.deleteAll();

        User user = new User();
        user.setId("whiteship");
        user.setName("백기선");
        user.setPassword("married");

        dao.add(user);

        assertThatThrownBy(() -> {dao.get("illegalId");})
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Illegal parameter");
    }

 

예제에서는 제가 예외 검증 시 주로 사용하는 assertThatThrownBy() 메서드를 사용해 봤습니다. 예외 타입 뿐만 아니라 메세지에 대한 검증까지, 가독성 좋은 코드로 작성할 수 있음을 알 수 있습니다. 여러 assertion 중 요구사항과 취향에 따라 라이브러리와 assertion 을 선택해서 사용하면 되겠습니다. 

 

주요 assertion 사용법

JUnit 또는 AssertJ 의 assertion 을 학습할 때, Ai 에게 사용법을 물어보거나 구글링해서 사용 방법을 공부할 수 있겠습니다. 저도 항상 그렇게 해 왔고, 구현을 위해 가장 빠른 방법이긴 합니다. 

그러나 그 방법보다는 공식 문서를 보고 학습하는 것을 추천합니다. 생각보다 친절하고 자세하게 설명되어 있고, 정보의 출처가 확실하다는 점 때문에 개인적으로는 코드에 확신이 생기는 느낌이었습니다.
또 새로운 검증의 사용이나 조금 도전적인, 또는 레퍼런스가 없는 테스트를 작성할 때 가장 확실하게 참고할 수 있는 것은 문서인데 문서에 대한 두려움 때문에 보기를 피하게 되는 경우도 많은 것 같습니다. (제가 그랬습니다) 그렇지만 조금만 찾아보고 "문서 어디에 이러이러한 가이드가 있다"라는 사실만 알면 가장 빠르게 확실한 정보를 얻을 수 있는 루트라고 생각합니다. 비단 테스트만이 아니라 다른 것을 학습할 때도 마찬가지입니다.

그렇기에, 여기서 몇 개의 assertion 에 대한 사용법을 소개하기보다는 참고할 수 있는 문서를 첨부합니다. JUnit5 의 jupiter.api.Assertion 클래스와 AssertJ 의 assertion 을 소개하는 문서입니다.

https://junit.org/junit5/docs/current/user-guide/#writing-tests-assertions   JUnit5 assertion 개요 

https://junit.org/junit5/docs/current/api/org.junit.jupiter.api/org/junit/jupiter/api/Assertions.html    jupiter.api.Assertion

https://assertj.github.io/doc/#assertj-core-assertions-guide    AssertJ assertion 가이드

 

 

테스트가 주도하는 개발

예외처리를 검증하는 테스트와 테스트를 통과하는 어플리케이션 코드를 작성함으로써 UserDao 의 조회에 대한 예외처리 기능을 개발하였습니다. 그런데 개발 순서를 보면 조금 의아한 부분이 있습니다. 테스트할 기능을 만들기도 전에 기능에 대한 테스트를 먼저 작성한 것입니다.

조금 불편하거나 이해가 안 될 수도 있었지만 어쨌든 "예외를 반환해야 한다" 는 케이스에 대해 검증하고 어플리케이션 코드까지 잘 개발해 냈습니다. 명확한 요구사항이 있었기 때문에 명확한 테스트를 먼저 작성할 수 있었고, 테스트를 통과한다는 목적을 가지고 명료하게 기능을 개발할 수 있었습니다. 이렇게 테스트는, 개발할 기능에 대한 설계도이자 요구사항 명세로서의 기능도 가지고 있습니다. 

이런 아이디어에서 착안된 개발 방법론이 실제로 존재합니다. "테스트 주도 개발"(Test - Driven - Development : TDD) 은 말 그대로 테스트 코드로서 개발을 주도하는 개발 방법으로, 실패하는 테스트를 먼저 작성한 후 해당 테스트를 통과하도록 코드를 작성하는 개발 주기를 짧은 사이클로 가져가는 개발 방법입니다. TDD 는 테스트가 주는 이점들을 개발 프로세스에서 매우 효과적으로 누릴 수 있는 개발 방법으로, 많은 실력있는 개발자들이 추구하는 방식이기도 합니다. 그러나 이상적인 개발 방법론으로 알려져 있는 것과 달리 현실적인 이유들로 실제 개발 환경에서 100% 적용되는 경우가 많지는 않다고 합니다.



TDD 를 통해 얻을 수 있는 장점은 명확합니다. 테스트 코드를 먼저 작성하기 때문에, 개발이 완료된 코드에 대한 안정성 위에 다음 작업을 쌓아 올릴 수 있습니다. 또한 가능한 짧은 주기로 테스트와 프로덕션 코드를 작성하기 때문에, 문제가 발생한 부분에 대한 정확한 탐지와 빠른 처리가 가능합니다. 그리고 테스트로서 개발할 기능에 대한 정의를 명확히 하기 때문에, 정확히 테스트를 통과하는 것만을 목적으로 하면서도 요구사항을 충족하는 간결한 코드를 작성할 수 있습니다. 테스트 가능한 코드를 작성해야 함으로서 따라오는 깔끔하고 객체지향적인 코드 또한 장점입니다. 

물론 이를 다 활용하기 위해서는 TDD 가 요구하는 귀찮은 제약과 절차를 따라야 합니다. TDD 는 그 내용만으로도 책 한권이 나올 정도로 디테일하고, 난이도가 엄청 높은 것은 아니면서도 충분한 프랙티스가 필요한 방법입니다. 여기서는 TDD 에 대한 간단한 소개만 하고 넘어가지만, 지금 예제와 같이 간단하고 명확한 요구사항이 있다면 테스트 가능한 코드에 유의하면서 테스트가 주도하는 개발을 조금씩 연습해 보는 것도 좋다고 생각합니다.

 

 



이번 글에서는 JUnit 의 개요와 각종 assertion 을 알아보았습니다. 스프링 어플리케이션으로 테스트를 하다 만난 수많은 assertion 들 사이에서 매번 길을 잃었던 기억을 떠올리며, JUnit 의 변화와 요즘 많이 사용되는 라이브러리 등에 대한 정리를 통해 선택의 기준을 확실히 할 수 있었습니다. 또 부정적인 케이스까지 커버하는 포괄적인 테스트를 위해 예외 검증 테스트와 그것을 통과하는 코드를 작성했고, 그것으로 알아본 테스트가 주도하는 개발의 장점을 간단하게 살펴보았습니다.

원래 2장의 정리를 이 글로 끝내려고 했으나.. 내용이 너무 길어져 3편으로 나누게 되었습니다. 줄인다고 최대한 줄여 보려고 했는데도 쉽지 않은것 같습니다. 
다음 글에서는 JUnit 과 스프링의 통합, 테스트에서의 어플리케이션 컨텍스트 관리, 그리고 학습 테스트에 대해 알아보겠습니다. 긴 글 읽어주셔서 감사합니다.