[Spring] 토비의 스프링 2장 : 테스트

헌치·2022년 9월 25일
0

Spring

목록 보기
7/13
post-thumbnail

이번에 토비님이 진행하시는 토비의 스프링 읽기모임에 들어갔다.

책을 읽으며 최대한 머릿속에 담기 위해 정리해봤는데, 해당 내용을 블로그에 기록하면 두고두고 볼 수 있을 것 같아 포스팅하게 되었다.
사진 자료는 저작권 문제로 제외했다.

들어가며

2장은 개인적으로는 쉬어가는 장이었다...!👀 이미 우테코 미션 등에서 Junit5를 통해 TDD를 실천했었고 그 유용성을 실감했기 때문이다.

속닥속닥에서, 실제 배포해 사용자가 있을 프로젝트를 만들어나가면서, 기능 구현에 바빠 이전에 목표로 했던(90% 이상의) 테스트 커버리지를 갖지 못한 점이 더 아쉬웠다. 앞으로 계속 테스트 코드를 추가할 의지가 생겼다.

책을 읽으며 버그테스트의 유용성에 대해서도 실감할 수 있었다.

"충분한 검증이 없는 테스트는 없는 것보다 나쁘다. 네거티브 테스트 먼저 작성하는 습관을 들이자."

이 단락에 많이 공감했다. 미니 프로젝트(aka.미션)를 진행하면서 낙관적인 테스트가 짜여진 클래스에 대해서는 믿고 간 적이 많다. 그러다보니 버그가 터졌을 때 더 당황하고 대응하기 까다로웠던 적이 많다.

앞으로 취직하고 회사 안에서 테스트로 시작하는 개발까진 아니어도, 테스트가 주도하는 개발을 하고 싶을 때, 이번 장에서 정리한 내용들을 통해 팀원들과 토론할 수 있을 것 같다.

💬
추가적인 얘기로, 이번 읽기모임에서 운좋게 테스트 관련 많은 질문들을 드릴 수 있었다. MockBean 등의 목 라이브러리 사용방식과 인터페이스를 통한 테스트더블 구현 중 어느 방향이 좋을지 항상 고민해 질문드렸다. 결론은 정답은 없되, Mockito 라이브러리의 이점을 활용하는 것은 좋은 방향이라는 것이었다.

관련해 토프링 읽기모임에서 나왔던 얘기들을 노션 링크로 공유한다. 테스트 범위부터, 테스트를 위한 다양한 방법론에 대한 고찰을 할 수 있어 좋았다!


2장 테스트

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

스프링으로 개발을 하면서 테스트를 만들지 않는다면 이는 스프링이 지닌 가치의 절반을 포기하는 셈이다.

개발자들이 낭만이라고도 생각하는 눈물 젖은 커피와 함께 며칠간 밤샘을 하며 오류를 잡으려고 애쓰다가 전혀 생각지도 못했던 곳에서 간신히 찾아낸 작은 버그 하나의 추억이라는 건, 사실 진작에 충분한 테스트를 했었다면 쉽게 찾아냈을 것을 미루고 미루다 결국 커다란 삽질로 만들어버린 어리석은 기억일 뿐이다.

일반적으로 테스트하기 좋은 코드가 좋은 코드일 가능성이 높다. 그 반대도 마찬가지다. 나는 이제까지 테스트하기 불편하게 설계된 좋은 코드를 본 기억이 없다.

2.1 UserDaoTest 다시 보기

2.1.1 테스트의 유용성

만든 코드는 어떤 방법으로든 테스트해야 한다!

  • 테스트란 결국 내가 예상하고 의도했던 대로 코드가 정확히 동작하는지를 확인해서, 만든 코드를 확신할 수 있게 해주는 작업이다.
  • 테스트가 실패한 후 코드의 결함을 제거해가는 작업, 일명 디버깅을 거치게 되고, 결국 최종적으로 테스트가 성공하면 “모든 결함이 제거됐다는 확신”을 얻을 수 있다.

2.1.2 UserDaoTest의 특징

💬 웹을 통한 DAO 테스트 방법의 문제점

웹 화면을 통해 값을 입력하고, 기능을 수행하고, 결과를 확인하는 방법은 가장 흔히 쓰이는 방법이지만, DAO에 대한 테스트로서는 단점이 너무 많다.

  • 모든 레이어의 기능을 다 만들고 나서야 테스트가 가능하다.
  • 다른 계층의 코드와 컴포넌트, 심지어 서버의 설정 상태까지 모두 테스트에 영향을 줄 수 있다. 이런 방식으로 테스트하는 것은 번거롭고, 오류가 있을 때 빠르고 정확하게 대응하기가 힘들다.

💬 단위 테스트

테스트는 가능하면 작은 단위로 쪼개서 집중해서 할 수 있어야 한다.

관심사의 분리라는 원리가 여기에도 적용된다. 테스트의 관심이 다르다면 테스트할 대상을 분리하고 집중해서 접근해야 한다.

이렇게 작은 단위의 코드에 대해 테스트를 수행한 것을 단위 테스트(unit test)라고 한다.

💬 DB가 사용되어도 단위테스트인가?

어떤 개발자는 테스트 중에 DB가 사용되면 단위 테스트가 아니라고도 한다. 그럼 UserDaoTest는 단위 테스트가 아니라고 봐야 할까?

NO!

  • 지금까지 UserDaoTest를 수행할 때 매번 테이블의 내용을 비웠다.
  • 사용할 DB의 상태를 테스트가 관장하고 있다면 단위 테스트라고 해도 된다.

다만, 통제할 수 없는 외부의 리소스에 의존하는 테스트는 단위 테스트가 아니라고 보기도 한다.

  • DB의 상태가 매번 달라지고, 테스트를 위해 DB를 세팅할 수 없다면?
  • UserDaoTest가 단위 테스트로서 가치가 없어진다.

💬 통합테스트도 필요하다

길고 많은 단위가 참여하는 테스트(통합테스트)도 필요하다.

  • 각종 기능을 모두 사용한 다음에 로그아웃까지 하는 전 과정을 묶어 테스트하자.
  • 각 단위 기능은 잘 동작하는데 묶어놓으면 안 되는 경우가 종종 발생한다.

💬 자동수행 테스트 코드

테스트가 자동 수행되지 않는다면?

  • 간혹 테스트 값 입력을 실수하면 다시 테스트를 반복해야 한다.
  • 세팅이 귀찮다 : 테스트를 위해 서버, 브라우저, 주소 입력…

따라서 테스트는 자동으로 수행되도록 코드로 만들어지는 것이 중요하다.

💬 테스트용 클래스를 분리하라

애플리케이션을 구성하는 클래스 안에 테스트 코드를 포함시키는 것보다는 별도로 테스트용 클래스를 만들어서 테스트 코드를 넣는 편이 낫다.

  • 처음엔 UserDao 클래스 하나만 존재했으니, 그 안에 main() 메소드를 만들어 사용했다.
  • 클래스를 분리하고 유연한 설계구조로 발전시키면서 테스트 코드를 넣을 위치를 결정하기가 애매해진다.
  • UserDaoTest라는 테스트용 클래스를 만들자.

💬 지속적인 개선과 점진적인 개발을 위한 테스트

작은 단계로 테스트를 수행하자.

  • 확신을 가지고 코드를 변경할 수 있다. 리팩토링이 더 쉬워진다.
  • UserDao의 기능 추가 시 이전 테스트 코드가 유용하다.

2.1.3 UserDaoTest의 문제점

public class XmlUserDaoTest {
    public static void main(String[] args) throws SQLException, ClassNotFoundException {
        ApplicationContext applicationContext = new GenericXmlApplicationContext("spring/applicationContext.xml");
        UserDao userDao = applicationContext.getBean(UserDao.class);

        User user = new User();
        user.setId("12341234");
        user.setName("제이크22522");
        user.setPassword("jakejake");

        userDao.add(user);

        System.out.println(user.getId() + " register succeeded");

        User user2 = userDao.get(user.getId());
        System.out.println(user2.getName());
        System.out.println(user2.getPassword());

        System.out.println(user2.getId() + " query succeeded");
    }
}

💬 수동 확인 작업의 번거로움

UserDaoTest는 테스트를 수행하는 과정과 입력 데이터의 준비를 모두 자동으로 진행하도록 만들어졌다.

  • 하지만 여전히 사람의 눈으로 확인하는 과정이 필요하다.
  • add()에서 User 정보를 DB에 등록하고, 이를 다시 get()을 이용해 가져왔을 때 입력한 값과 가져온 값이 일치하는지를 테스트 코드는 확인해주지 않는다.

💬 실행 작업의 번거로움

아무리 간단히 실행 가능한 main() 메소드라고 하더라도 매번 그것을 실행하는 것은 제법 번거롭다.

만약 DAO가 수백 개가 되고 그에 대한 main() 메소드도 그만큼 만들어진다면?

  • 전체 기능을 테스트해보기 위해 main() 메소드를 수백 번 실행해야 한다.

2.2 UserDaoTest 개선

2.2.1 테스트 검증의 자동화

자동화된 테스트를 위한 xUnit 프레임워크를 만든 켄트 벡은 테스트란 개발자가 마음 편하게 잠자리에 들 수 있게 해주는 것이라고 했다.

모든 테스트는 성공과 실패의 두 가지 결과를 가질 수 있다.

  • 테스트 에러 : 테스트가 진행되는 동안에 에러가 발생해서 실패
  • 테스트 실패 : 테스트 작업 중에 에러가 발생하진 않았지만 그 결과가 기대한 것과 다르게 나옴

테스트 프레임워크를 통해 두 경우 모두 검증할 수 있다.

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

JUnit은 프로그래머를 위한 자바 테스팅 프레임워크이다.

public class UserDaoTest {
    @Test
    public void addAndGet() throws SQLException {
        ApplicationContext applicationContext = new GenericXmlApplicationContext("spring/applicationContext.xml");

        UserDao userDao = applicationContext.getBean(UserDao.class);

        User userToAdd = new User();
        userToAdd.setId("hunch");
        userToAdd.setName("헌치");
        userToAdd.setPassword("password");

        userDao.add(userToAdd);

        User userToGet = userDao.get("hunch");

        Assertions.assertEquals(userToAdd.getId(), userToGet.getId());
        Assertions.assertEquals(userToAdd.getName(), userToGet.getName());
        Assertions.assertEquals(userToAdd.getPassword(), userToGet.getPassword());
    }
}

💬 JUnit 테스트로 전환

JUnit은 프레임워크다!

  • 프레임워크는 개발자가 만든 클래스에 대한 제어 권한을 넘겨받아서 주도적으로 애플리케이션의 흐름을 제어한다.
  • 개발자가 만든 클래스의 오브젝트를 생성하고 실행하는 일은 프레임워크에 의해 진행된다.

따라서 프레임워크에서 동작하는 코드는

  • main() 메소드도 필요 없고
  • 오브젝트를 만들어서 실행시키는 코드를 만들 필요도 없다.

💬 테스트 메소드 전환

기존에 만들었던 main() 메소드 테스트는 제어권을 직접 가졌다. 프레임워크에 적용하기엔 적합하지 않다. 테스트 코드를 main()에서 일반 메소드로 옮기자.

JUnit 테스트 메소드 요구조건

  • 메소드가 public으로 선언돼야 한다.
  • 메소드에 @Test 애노테이션을 붙여줘야 한다.

💬 검증 코드 전환

  • JUnit은 예외가 발생하거나 assertThat()에서 실패하지 않고 테스트 메소드의 실행이 완료되면 테스트가 성공했다고 인식한다.
  • JUnit은 테스트 성공/실패를 다양한 방법으로 알려준다.

💬 JUnit 테스트 실행

JUnit 프레임워크를 이용해 앞에서 만든 테스트 메소드를 실행하도록 코드를 만들어보자.

  • JUnit 프레임워크도 자바 프로그램이므로 초기에 JUnit 프레임워크를 시작시켜 줘야 한다.
  • 테스트 에러 : JUnitassertThat()을 이용해 검증을 했을 때 기대한 결과가 아니면 이 AssertionError를 던진다. 따라서 assertThat()의 조건을 만족하지 못하면 테스트는 더 이상 진행되지 않고 JUnit은 테스트가 실패했음을 알게 된다.
  • 테스트 예외 : 테스트 수행 중에 일반 예외가 발생한 경우에도 마찬가지로 테스트 수행은 중단되고 테스트는 실패한다.

2.3 개발자를 위한 테스팅 프레임워크 JUnit

2.3.1 JUnit 테스트 실행 방법

가장 좋은 JUnit 테스트 실행 방법은 자바 IDE에 내장된 JUnit 테스트 지원 도구를 사용하는 것이다.

💬 IDE

IDE를 통해 JUnit 테스트의 실행과 그 결과를 확인하는 방법

매우 간단하고 직관적이며 소스와 긴밀하게 연동돼서 결과를 볼 수 있다.

💬 빌드 툴

여러 개발자가 만든 코드를 모두 통합해서 테스트를 수행해야 할 때도 있다.

  • 이런 경우에는 서버에서 모든 코드를 가져와 통합하고 빌드한 뒤에 테스트를 수행하는 것이 좋다.
  • 이때는 빌드 스크립트를 이용해 JUnit 테스트를 실행하고 그 결과를 메일 등으로 통보받는 방법을 사용하면 된다.

2.3.2 테스트 결과의 일관성

테스트가 외부 상태에 따라 성공하기도 하고 실패하기도 하면 안된다. 일관성있는 결과를 위해 DB 초기화가 필요하다.

💬 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;
    }

일관성 있는 결과를 보장하기 위해 UserDao에 두 기능을 추가하자.

  • deleteAll() : USER 테이블의 모든 레코드를 삭제한다.
  • getCount() : USER 테이블의 레코드 개수를 돌려준다.

💬 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);
    }

deleteAll()이 기대한 대로 동작한다면, getCount()로 레코드의 개수를 가져올 경우 0이 나와야 한다.

getCount() 테스트

  • add()를 수행하고 나면 레코드 개수가 0에서 1로 바뀌어야 한다.
  • add() 메소드를 실행한 뒤에 getCount()의 결과를 한 번 더 확인해보자.
  • deleteAll() 직후에는 0이 나오고 add() 직후에는 1이 나온다면, getCount()의 기능이 검증된다.

deleteAll() 테스트

  • getCount()가 바르게 동작해야 한다.
  • deleteAll() 직후에 getCount()가 0이 나오면 deleteAll()의 기능이 검증된다.

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

이제 테스트 메소드별로 DB가 초기화된다.

설령 테스트 수행 직전에 DB에 작업을 하느라 USER 테이블에 정보를 넣어뒀더라도 일관된 결과를 얻을 수 있다.

2.3.3 포괄적인 테스트

테스트를 안 만드는 것도 위험한 일이지만, 성의 없이 테스트를 만드는 바람에 문제가 있는 코드인데도 테스트가 성공하게 만드는 건 더 위험하다.

getCount()는 정말 검증되었는가?

  • 기존 테스트에서는 deleteAll()을 실행했을 때 테이블이 비어 있는 경우(0)와 add()를 한 번 호출한 뒤의 결과(1)뿐이다.
  • 두 개 이상의 레코드를 add() 했을 때는 getCount()의 실행 결과가 어떻게 될까?

💬 getCount() 테스트

@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);
    }

JUnit은 하나의 클래스 안에 여러 개의 테스트 메소드가 들어가는 것을 허용한다.

  • @Test가 붙어 있고
  • public 접근자가 있으며
  • 리턴 값이 void형이고
  • 파라미터가 없다는 조건을 지키기만 하면 된다.

테스트는 순서에 영향받아선 안된다!

  • 두 개의 테스트가 어떤 순서로 실행될지는 알 수 없다는 것이다. (JUnit은 특정한 테스트 메소드의 실행 순서를 보장해주지 않는다.)
  • 테스트의 결과가 테스트 실행 순서에 영향을 받는다면 테스트를 잘못 만든 것!

💬 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);
    }

id를 조건으로 해서 사용자를 검색하는 기능을 가진 get()에 대한 테스트는 조금 부족한 감이 있다.

  • get()이 파라미터로 주어진 id에 해당하는 사용자를 가져온 것인지, 그냥 아무거나 가져온 것인지 테스트에서 검증하지는 못했다.

User를 하나 더 추가해서 두 개의 User를 add() 하고, 각 User의 id를 파라미터로 전달해서 get()을 실행하도록 만들어보자.

  • 이렇게 하면 주어진 id에 해당하는 정확한 User 정보를 가져오는지 확인할 수 있다.

💬 get() 예외조건에 대한 테스트

@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");
        });
    }

get() 메소드에 전달된 id 값에 해당하는 사용자 정보가 없을 때

  • null과 같은 특별한 값을 리턴한다
  • id에 해당하는 정보를 찾을 수 없다고 예외를 던진다

각기 장단점이 있다. 여기서는 후자의 방법을 써보자.

테스트 진행 중에 특정 예외가 던져지면 테스트가 성공한 것이고, 예외가 던져지지 않고 정상적으로 작업을 마치면 테스트가 실패했다고 판단해야 한다.

  • 문제는 예외 발생 여부는 메소드를 실행해서 리턴 값을 비교하는 방법으로 확인할 수 없다는 점이다.

이런 경우를 위해 JUnit은 특별한 방법을 제공해준다.

@Test에 expected를 추가해놓으면

  • 정상적으로 테스트 메소드를 마치면 테스트가 실패하고,
  • expected에서 지정한 예외가 던져지면 테스트가 성공한다.

예외가 반드시 발생해야 하는 경우를 테스트하고 싶을 때 유용하게 쓸수 있다.

💬 테스트를 성공시키기 위한 코드의 수정

이제부터 할 일은 이 테스트가 성공하도록 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() 메소드의 경우

  • 존재하지 않는 id가 주어졌을 때는 어떻게 반응할지를 먼저 결정하고, 이를 확인할 수 있는 테스트를 먼저 만들어야 한다.

2.3.4 테스트가 이끄는 개발

많은 전문적인 개발자가 테스트를 먼저 만들어 테스트가 실패하는 것을 보고 나서 코딩하는 방법을 개발 방법을 적극적으로 사용하고 있다.

💬 기능설계를 위한 테스트

테스트 코드는 마치 잘 작성된 하나의 기능정의서처럼 보인다.
보통 기능설계, 구현, 테스트라는 일반적인 개발 흐름의 기능설계에 해당하는 부분을 테스트 코드가 일부분 담당하고 있다.

💬 테스트 주도 개발(TDD, Test Driven Development)

과거 개발자들은 엔터프라이즈 애플리케이션의 테스트를 만들기가 매우 어렵다고 생각해 테스트 코드를 짜지 않았다.
하지만 스프링은 테스트하기 편리한 구조의 애플리케이션을 만들게 도와줄 뿐만 아니라, 엔터프라이즈 애플리케이션 테스트를 빠르고 쉽게 작성할 수 있는 매우 편리한 기능을 많이 제공하므로 테스트를 하자!

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

장점

  • 테스트를 먼저 만들고 그 테스트가 성공하도록 하는 코드만 만드는 식으로 진행하기 때문에 테스트를 빼먹지 않고 꼼꼼하게 만들어낼 수 있다.
  • 테스트를 작성하는 시간과 애플리케이션 코드를 작성하는 시간의 간격이 짧아진다. 이미 테스트를 만들어뒀기 때문에 코드를 작성하면 바로바로 테스트를 실행해볼 수 있기 때문이다. 그 덕분에 코드에 대한 피드백을 매우 빠르게 받을 수 있게 된다.
  • 매번 테스트가 성공하는 것을 보면서 작성한 코드에 대한 확신을 가질 수 있어, 가벼운 마음으로 다음 단계로 넘어갈 수가 있다. 자신감과 마음의 여유가 생긴다.

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

2.3.5 테스트 코드 개선

테스트 결과가 일정하게 유지된다면 얼마든지 리팩토링을 해도 좋다.

💬 @Before

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

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

장점

  • 보통 하나의 테스트 클래스 안에 있는 테스트 메소드들은 공통적인 준비 작업과 정리 작업이 필요한 경우가 많다. 이런 작업들을 @Before, @After가 붙은 메소드에 넣어두면 JUnit이 자동으로 메소드를 실행해주니 매우 편리하다.
  • 각 테스트 메소드에서 직접 setUp()과 같은 메소드를 호출할 필요도 없다.

주의사항

  • 대신 @Before나 @After 메소드를 테스트 메소드에서 직접 호출하지 않기 때문에 서로 주고받을 정보나 오브젝트가 있다면 인스턴스 변수를 이용해야 한다.
  • UserDaoTest에서는 스프링 컨테이너에서 가져온 UserDao 오브젝트를 인스턴스 변수 dao에 저장해뒀다가, 각 테스트 메소드에서 사용하게 만들었다.

왜 테스트 메소드를 실행할 때마다 새로운 오브젝트를 만드는 것일까? 그냥 테스트 클래스마다 하나의 오브젝트만 만들어놓고 사용하는 편이 성능도 낫고 더 효율적이지 않을까?

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

💬 픽스처

테스트를 수행하는 데 필요한 정보나 오브젝트를 fixture라고 한다.

픽스처는 여러 테스트에서 반복적으로 사용되기 때문에 @Before 메소드를 이용해 생성해두면 편리하다.

2.4 스프링 테스트 적용

이제 테스트 코드도 어느 정도 깔끔하게 정리를 마쳤다. 하지만 아직 한 가지 찜찜한 부분이 남아 있는데, 바로 애플리케이션 컨텍스트 생성 방식이다.

시간

  • @Before 메소드가 테스트 메소드 개수만큼 반복되기 때문에 애플리케이션 컨텍스트도 세 번 만들어진다. 지금은 설정도 간단하고 빈도 몇 개 없어서 별문제 아닌 듯하지만, 빈이 많아지고 복잡해지면 애플리케이션 컨텍스트 생성에 적지 않은 시간이 걸릴 수 있다.

에러

  • 또 한 가지 문제는 애플리케이션 컨텍스트가 초기화될 때 어떤 빈은 독자적으로 많은 리소스를 할당하거나 독립적인 스레드를 띄우기도 한다는 점이다.
  • 테스트 후 컨텍스트 안 빈 등을 초기화하지 않으면?
    • 다음 테스트에서 새로운 애플리케이션 컨텍스트가 만들어지면서 문제를 일으킬 수도 있다!

테스트는 가능한 한 독립적으로 매번 새로운 오브젝트를 만들어서 사용하는 것이 원칙이다. 하지만 애플리케이션 컨텍스트처럼 생성에 많은 시간과 자원이 소모되는 경우에는 테스트 전체가 공유하는 오브젝트를 만들기도 한다.

문제는 JUnit이 매번 테스트 클래스의 오브젝트를 새로 만든다는 점이다. 따라서 여러 테스트가 함께 참조할 애플리케이션 컨텍스트를 오브젝트 레벨에 저장해두면 곤란하다.

→ 스태틱 필드에 애플리케이션 컨텍스트를 저장해두면 어떨까?

  • JUnit은 테스트 클래스 전체에 걸쳐 딱 한 번만 실행되는 @BeforeClass 스태틱 메소드를 지원한다. 이 메소드에서 애플리케이션 컨텍스트를 만들어 스태틱 변수에 저장해두고 테스트 메소드에서 사용하게 할 수 있다.

하지만 이보다는 스프링이 직접 제공하는 애플리케이션 컨텍스트 테스트 지원 기능을 사용하는 것이 더 편리하다.

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

💬 스프링 테스트 컨텍스트 프레임워크 적용

테스트에서 사용할 ApplicationContext 하나를 만들고, 공유하게 된다.

// @SpringBootTest (SpringBoot)
// @Runwith(SpringJUnit4ClassRunner.class) (JUnit4)
@ExtendWith(SpringExtension.class) // (JUnit5)
@ContextConfiguration(locations="/spring/applicationContext.xml")
public class UserDaoTest {
    @Autowired ApplicationContext applicationContext;
    UserDao userDao;

    @BeforeEach
    public void setUp() {
        System.out.println("applicationContext = " + applicationContext);
        System.out.println("this = " + this);
        this.userDao = this.applicationContext.getBean("userDao", UserDao.class);
  1. @ExtendWith는 JUnit5에서 테스트 클래스를 확장할 때 쓰이는 애노테이션이다.
  2. @ContextConfigurationlocations에서 ApplicationContext에 사용될 xml파일의 위치를 지정해줄 수 있다.
  3. @Autowired는 테스트용 ApplicationContext 내부에 있는 정의된 타입의 빈(위 경우 ApplicationContext)을 찾아서 자동으로 주입한다.

💬 테스트 메소드의 컨텍스트 공유

그렇다면 context 변수에 어떻게 애플리케이션 컨텍스트가 들어 있는 것일까?

  • 스프링의 JUnit 확장기능은 테스트가 실행되기 전에 딱 한 번만 애플리케이션 컨텍스트를 만들어두고, 테스트 오브젝트가 만들어질 때마다 특별한 방법을 이용해 애플리케이션 컨텍스트 자신을 테스트 오브젝트의 특정 필드에 주입해준다.
  • 일종의 DI라고 볼 수 있는데, 애플리케이션 오브젝트 사이의 관계를 관리하기 위한 DI와는 조금 성격이 다르다.

💬 테스트 클래스의 컨텍스트 공유

두 개의 테스트 클래스가 같은 설정파일을 사용하는 경우?

  • 테스트 수행 중에 단 한 개의 애플리케이션 컨텍스트만 만들어진다.
  • 즉, 두 테스트 클래스가 한 애플리케이션 컨텍스트 공유한다!

💬 @Autowired

굳이 컨텍스트를 가져와 getBean()을 사용하는 것이 아니라, 아예 UserDao 빈을 직접 DI 받을 수도 있다.

인터페이스인 DataSource 타입으로 변수를 선언해도 된다.

  • 단, @Autowired는 같은 타입의 빈이 두 개 이상 있는 경우에는 타입만으로는 어떤 빈을 가져올지 결정할 수 없다.
  • 테스트에서 가능한 한 인터페이스를 사용해서 애플리케이션 코드와 느슨하게 연결해두는 편이 좋다.

2.4.2 DI와 테스트

SimpleDriverDataSource를 생성하고 사용하면 안 될까?

NO! 인터페이스를 두고 DI를 적용해야 한다.

  1. 소프트웨어 개발에서 절대로 바뀌지 않는 것은 없기 때문이다.
  2. 클래스의 구현 방식은 바뀌지 않는다고 하더라도 인터페이스를 두고 DI를 적용하게 해두면 다른 차원의 서비스 기능을 도입할 수 있기 때문이다.
  3. 테스트 때문이다. 자동으로 실행 가능하며 빠르게 동작하도록 테스트 코드를 만들려면 가능한 한 작은 단위의 대상에 국한해서 테스트해야 한다. DI는 테스트가 작은 단위의 대상에 대해 독립적으로 만들어지고 실행되게 한다.

💬 테스트 코드에 의한 DI

테스트할 때 운영용 DataSource를 이용하면 안된다!

  • UserDaoTest를 실행하는 순간 deleteAll()에 의해 운영용 DB의 사용자 정보가 모두 삭제된다.
  • 그렇다고 applicationContext.xml 설정을 수정하는 방법도 있겠지만, 번거롭기도 하고 위험할 수도 있다.

DI를 이용해서 테스트 중에 DAO가 사용할 DataSource 오브젝트를 바꿔주자!

  • 테스트용 DB에 연결해주는 DataSource를 테스트 내에서 직접 만들면 된다.

장점

  • XML 설정파일을 수정하지 않고도 테스트 코드를 통해 오브젝트 관계를 재구성할 수 있다.
  • 예외적인 상황을 만들기 위해 일부러 엉뚱한 오브젝트를 넣거나, 위와 같이 테스트용으로 준비된 오브젝트를 사용하게 할 수 있다.

주의사항

  • 이미 애플리케이션 컨텍스트에서 applicationContext.xml 파일의 설정정보를 따라 구성한 오브젝트를 가져와 의존관계를 강제로 변경하기 때문에 주의해야 한다.

💬 @DirtiesContext

  • 이 애노테이션은 스프링의 테스트 컨텍스트 프레임워크에게 해당 클래스의 테스트에서 애플리케이션 컨텍스트의 상태를 변경한다는 것을 알려준다.
  • 테스트 컨텍스트는 이 애노테이션이 붙은 테스트 클래스에는 애플리케이션 컨텍스트 공유를 허용하지 않는다.
  • 테스트에서 빈의 의존관계를 강제로 DI 하는 방법을 사용했을 때 문제는 피할 수 있다.
  • 메소드 레벨로도 적용할 수 있다.

💬 테스트를 위한 별도의 DI 설정

아예 테스트에서 사용될 DataSource 클래스가 빈으로 정의된 테스트 전용 설정파일을 따로 만들어두는 방법을 이용해도 된다.

  • 두 가지 종류의 설정파일을 만들어서 하나에는 서버에서 운영용으로 사용할 DataSource를 빈으로 등록해두고, 다른 하나에는 테스트에 적합하게 준비된 DB를 사용하는 가벼운 DataSource가 빈으로 등록되게 만드는 것이다.
  • 테스트에서는 항상 테스트 전용 설정파일만 사용하게 해주면 된다.
  • 번거롭게 수동 DI 하는 코드나 @DirtiesContext도 필요 없다.

💬 컨테이너 없는 DI 테스트

DI는 객체지향 프로그래밍 스타일이다. 따라서 DI를 위해 컨테이너가 반드시 필요한 것은 아니다. DI 컨테이너나 프레임워크는 DI를 편하게 적용하도록 도움을 줄 뿐, 컨테이너가 DI를 가능하게 해주는 것은 아니다.

아예 스프링 컨테이너를 사용하지 않고 테스트를 만들 수도 있다.

public class UserDaoTest {
  UserDao dao;

  //...

  @BeforeEach
  public void setUp() {
    //...
    dao = new UserDao();
    DataSource = new SingleConnectionDataSource(
      "jdbc:postgresql://localhost/test", "postgres", "password", true
    );
    dao.setDataSource(dataSource);
  }
}

장점

  • 애플리케이션 컨텍스트를 아예 사용하지 않으니 코드는 더 단순해지고 이해하기 편해졌다.
  • 애플리케이션 컨텍스트가 만들어지는 번거로움이 없어졌으니 그만큼 테스트시간도 절약할 수 있다.

단점

  • 테스트를 위한 DataSource를 직접 만드는 번거로움이 있다.
  • 매번 새로운 테스트 오브젝트를 만들기 때문에 매번 새로운 UserDao 오브젝트가 만들어진다.

💬 비침투적 기술

비침투적(noninvasive)인 기술은 애플리케이션 로직을 담은 코드에 아무런 영향을 주지 않고 적용이 가능하다.

  • 따라서 기술에 종속적이지 않은 순수한 코드를 유지할 수 있게 해준다.
  • 스프링은 이런 비침투적인 기술의 대표적인 예다. 그래서 스프링 컨테이너 없는 DI 테스트도 가능한 것이다.

💬 DI를 이용한 테스트 방법 선택

그렇다면 DI를 테스트에 이용하는 세 가지 방법 중 어떤 것을 선택해야 할까? 세 가지 방법 모두 장단점이 있고 상황에 따라 유용하게 쓸 수 있다.

  1. 항상 스프링 컨테이너 없이 테스트할 수 있는 방법을 가장 우선적으로 고려하자.
    • 이 방법이 테스트 수행 속도가 가장 빠르고 테스트 자체가 간결하다.
    • 테스트를 위해 필요한 오브젝트의 생성과 초기화가 단순하다면 이 방법을 가장 먼저 고려해야 한다.
  2. 여러 오브젝트와 복잡한 의존관계를 갖고 있는 오브젝트를 테스트해야 할 경우가 있다. 이때는 스프링의 설정을 이용한 DI 방식의 테스트를 이용하면 편리하다.
    • 테스트에서 애플리케이션 컨텍스트를 사용하는 경우에는 테스트 전용 설정파일을 따로 만들어 사용하는 편이 좋다.
    • 보통 개발환경과 테스트환경, 운영환경이 차이가 있기 때문에 각각 다른 설정파일을 만들어 사용하는 경우가 일반적이다.
    • 물론 개발자가 테스트할 때는 개발환경에 맞춰서 만든 설정파일을 사용한다.
  3. 테스트 설정을 따로 만들었다고 하더라도 때로는 예외적인 의존관계를 강제로 구성해서 테스트해야 할 경우가 있다. 이때는 컨텍스트에서 DI 받은 오브젝트에 다시 테스트 코드로 수동 DI 해서 테스트하는 방법을 사용하면 된다.
    • 테스트 메소드나 클래스에 @DirtiesContext 애노테이션을 붙이는 것을 잊지 말자.

2.5 학습 테스트로 배우는 스프링

나는 새로운 프레임워크를 사용하게 되거나 새로운 기술을 공부할 때는 항상 테스트 코드를 먼저 만들어본다. 테스트 코드를 만드는 과정을 통해 API의 사용 방법도 익히고 내가 가진 기술에 대한 지식도 검증할 수 있다.

학습 테스트를 통해 자신이 사용할 API나 프레임워크의 기능을 테스트로 보면서 사용 방법을 익힐 수 있다.

2.5.1 학습 테스트의 장점

  1. 다양한 조건에 따른 기능을 손쉽게 확인해볼 수 있다
  2. 자동화된 테스트 코드로 만들어지기 때문에 다양한 조건에 따라 기능이 어떻게 동작하는지 빠르게 확인할 수 있다.
    • 수동 테스트는 다양한 조건에 따라 어떻게 기능이 다르게 동작하는지 확인해보려면 수동으로 값을 입력하거나 코드를 계속 수정해가며 예제를 다시 실행해야 한다. 결과도 콘솔에 메시지를 출력하거나 UI 화면에 나타내주는 방법밖에 없다.
  3. 학습 테스트 코드를 개발 중에 참고할 수 있다
  4. 실제 개발에서 샘플 코드로 참고할 수 있다.
  5. 프레임워크나 제품을 업그레이드할 때 호환성 검증을 도와준다.
    1. 학습 테스트에 애플리케이션에서 자주 사용하는 기능에 대한 테스트를 만들어놓았다면 새로운 버전의 프레임워크나 제품을 학습 테스트에만 먼저 적용해볼 수 있다!
  6. 테스트 작성에 대한 좋은 훈련이 된다
    1. 개발자가 테스트를 작성하는 데 아직 충분히 훈련되어 있지 않거나 부담을 갖고 있다면, 먼저 학습 테스트를 작성해보면서 테스트 코드 작성을 연습할 수 있다.
  7. 새로운 기술을 공부하는 과정이 즐거워진다
    1. 책이나 레퍼런스 문서 등을 그저 읽기만 하는 공부는 쉽게 지루해진다.
    2. 그에 비해 테스트 코드를 만들면서 하는 학습은 흥미롭고 재미있다.

2.5.2 학습 테스트 예제

💬 JUnit 테스트 오브젝트 테스트

public class JUnitTest {
    static JUnitTest testObject;

    @BeforeAll
    public static void beforeAll() {
        testObject = new JUnitTest();
    }

    @AfterEach
    public void afterEach() {
        testObject = this;
    }

    @Test
    public void test1() {
        assertNotSame(testObject, this);
        System.out.println("testObject = " + testObject);
        System.out.println("this = " + this);
    }

    @Test
    public void test2() {
        assertNotSame(testObject, this);
        System.out.println("testObject = " + testObject);
        System.out.println("this = " + this);
    }

    @Test
    public void test3() {
        assertNotSame(testObject, this);
        System.out.println("testObject = " + testObject);
        System.out.println("this = " + this);
    }
}

💬 스프링 애플리케이션 컨텍스트 테스트

@ExtendWith(SpringExtension.class) // (JUnit5)
@ContextConfiguration(locations="/spring/applicationContext.xml")
public class ApplicationContextTest {
    @Autowired ApplicationContext applicationContext;
    static Set<ApplicationContext> applicationContexts = new HashSet<>();

    @AfterAll
    public static void afterAll() {
        assertEquals(applicationContexts.size(), 1);
    }

    @Test
    public void test1() {
        applicationContexts.add(applicationContext);
    }

    @Test
    public void test2() {
        applicationContexts.add(applicationContext);
    }

    @Test
    public void test3() {
        applicationContexts.add(applicationContext);
    }
}

2.5.3 버그 테스트

버그 테스트(bug test)란?

  • 코드에 오류가 있을 때 그 오류를 가장 잘 드러내줄 수 있는 테스트를 말한다.

장점

  1. 테스트의 완성도를 높여준다
    • 기존 테스트에서는 미처 검증하지 못했던 부분이 있기 때문에 오류가 발생한 것이다. 이에 대해 테스트를 만들면 불충분했던 테스트를 보완해준다.
    • 또, 이후에 비슷한 문제가 다시 등장하더라도 이전에 만들었던 버그 테스트 덕분에 쉽게 추적이 가능해진다.
  2. 버그의 내용을 명확하게 분석하게 해준다
    • 버그가 있을 때 그것을 테스트로 만들어서 실패하게 하려면 어떤 이유 때문에 문제가 생겼는지 명확히 알아야 한다.
    • 따라서 버그를 좀 더 효과적으로 분석할 수 있다.
  3. 기술적인 문제를 해결하는 데 도움이 된다
    • 때로는 버그 원인이 무엇인지 정확하게 파악하기 힘들 때가 있다. 이럴 땐 동일한 문제가 발생하는 가장 단순한 코드와 그에 대한 버그 테스트를 만들어보면 도움이 된다.

💬 경계값 분석(boundary value analysis)

에러는 동등분할 범위의 경계에서 주로 많이 발생한다는 특징을 이용해서 경계의 근처에 있는 값을 이용해 테스트하는 방법이다.

보통 숫자의 입력 값인 경우 0이나 그 주변 값 또는 정수의 최대값, 최소값 등으로 테스트해보면 도움이 될 때가 많다.

2.6 정리

  • 테스트는 자동화되고 빠르게 실행할 수 있어야 한다.
  • main()을 이용하지 말고, JUnit 프레임워크를 이용하면 테스트 자동화가 가능하다.
  • 테스트 결과는 일관성이 있어야한다. 환경이나 테스트 순서에 영향을 받으면 안 된다.
  • 테스트는 포괄적으로 작성해야 한다. 충분한 검증이 없는 테스트는 없는 것보다 나쁘다. 네거티브 테스트 먼저 작성하는 습관을 들이자.
  • 코드 작성과 테스트 수행의 간격이 짧을수록 효과적이다.
  • 테스트하기 쉬운 코드가 좋은 코드다.
  • 테스트를 먼저 만들고 테스트를 성공시키는 코드를 만들어가는 TDD도 유용하다.
  • 테스트 코드도 애플리케이션 코드와 마찬가지로 적절한 리팩토링이 필요하다.
  • @BeforeEach, @AfterEach를 사용해서 테스트 메소드들의 공통 준비 작업과 정리 작업을 처리할 수 있다.
  • 스프링 테스트 컨텍스트 프레임워크를 이용하면 테스트 성능을 향상시킬 수 있다.
  • 동일한 설정 파일을 사용하는 테스트는 하나의 애플리케이션 컨텍스트를 공유한다.
  • @Autowired를 사용하면 컨텍스트의 빈을 테스트 오브젝트에 DI할 수 있다.
  • 학습 테스트를 이용하면 기술의 사용 방법을 익히고 이해를 도울 수 있다.
  • 오류가 발견되는 경우 버그 테스트를 만들어두면 유용하다.

profile
🌱 함께 자라는 중입니다 🚀 rerub0831@gmail.com

0개의 댓글