[Spring MVC] 테스팅(Testing)
트랜잭션까지 적용된 커피 주문 샘플 애플리케이션은 이제 보안(Security)을 제외하고는 그럭저럭 잘 동작하는 애플리케이션이 되었습니다.
물론 학습용이기 때문에 별도의 외부 통신은 없지만 요구 사항이 늘어나더라도 기능을 추가하는 데는 크게 무리가 없을 정도의 기본기는 갖추게 되었습니다.
하지만 우리가 실무에서 애플리케이션 개발에 참여하게 되면 여러분들이 애플리케이션 구현을 끝냈다고 해서 끝이 아닙니다.
여러분들이 애플리케이션에 대한 1차 구현이 끝났다고 생각하더라도 여러분들 입장에서는 여러분들이 구현한 애플리케이션이 잘 동작한다고 생각할지 모르지만, 제3자 입장에 있는 사람들(QA 담당자, Frontend 개발 담당자, 애플리케이션을 사용하는 고객)이 여러분들의 애플리케이션을 사용하다 보면 여러분들이 발견하지 못했던 에러(버그)가 발생할 수 있습니다. (완벽하게 에러가 없는 애플리케이션을 한 번에 구현하지는 못할 겁니다.)
이처럼 제3자가 여러분들이 만든 애플리케이션을 사용할 때 발생할 수 있는 에러를 최소화할 수 있는 방법이 있습니다.
바로 애플리케이션에 대한 테스팅입니다.
우리가 만든 커피 주문 애플리케이션을 어떻게 테스트할 수 있을지 이번 유닛에서 자세히 알아보도록 하겠습니다.
[Spring MVC] 테스팅(Testing) 학습을 위한 사전 준비 사항
이번 유닛의 학습을 원활하게 진행하기 위해 지금까지 여러분들이 구현해 본 Controller 및 Service, Repository 클래스들이 포함된 템플릿 프로젝트를 사용하도록 하겠습니다.
데이터 액세스 계층은 Spring Data JPA의 리포지토리로 구성이 되어 있습니다.
- 템플릿 프로젝트 복제
- 아래 github 링크에서 실습용 repository를 clone합니다.
- IntelliJ IDE로 clone 받은 local repository 디렉토리의 프로젝트를 Open합니다.
- 학습을 진행하며 학습 내용에 따라 예제 코드를 타이핑해봅니다.
[Spring MVC] 테스팅(Testing) 학습 참고용 레퍼런스 코드
이번 유닛에서 학습한 예제 코드는 아래 github에서 확인할 수 있습니다.
챕터에서 사용한 예제 코드는 챕터에 있는 코드들을 직접 타이핑해본 후, 학습 내용을 조금 더 구체적으로 이해하기 위한 용도로만 활용해 주세요.
- 테스팅(Testing) 유닛에 사용한 예제 코드
학습 목표
- 테스트를 왜 해야 하는지 이해할 수 있다.
- 단위 테스트가 무엇인지 이해할 수 있다.
- JUnit의 기본적인 사용법을 이해할 수 있다.
- 슬라이스 테스트가 무엇인지 이해할 수 있다.
- Spring에서의 슬라이스 테스트 방법을 알 수 있다.
- Hemcrest의 기본적인 사용법을 이해할 수 있다.
- Mockito의 기본적인 사용법을 이해할 수 있다.
Chapter - 단위 테스트(Unit Test)
이번 챕터에서는 단위 테스트(Unit Test)에 대해서 알아보겠습니다.
단위 테스트(Unit Test)가 무엇이고, 단위 테스트를 해야 하는 이유에 대해서 알아보도록 하겠습니다.
추가적으로 JUnit 없이 비즈니스 로직에 대한 단위 테스트를 어떻게 할 수 있는지에 대해서도 살펴보도록 하겠습니다.
학습 목표
- 테스트를 왜 해야 하는지 이해할 수 있다.
- 단위 테스트가 무엇인지 이해할 수 있다.
- JUnit을 사용하지 않고 비즈니스 로직에 단위 테스트를 적용할 수 있다.
단위 테스트란?
테스트를 해야 되는 이유
✔ 일반적인 테스트의 의미
테스트란 용어 자체의 의미는 여러분들이 잘 알고 있을 것입니다.
우리가 이번 백엔드 코스 학습을 진행하면서 해왔든 또는 앞으로 진행하게 될 테스트에는 어떤 것들이 있을지 테스트 관점에서 잠깐 생각해 보도록 하겠습니다.
- 여러분들이 유닛별로 실습 과제를 진행하는 것은 해당 유닛의 학습 내용들을 잘 이해하고 있는지를 테스트하는 과정이라고 볼 수 있습니다.
- 여러분들이 백엔드 코스를 무사히 마치고, 프로젝트를 수행하는 날이 다가왔다고 생각해 봅시다.
프로젝트를 수행하는 것은 여러분들이 그동안 백엔드 코스의 각 섹션에서 학습한 내용들을 얼마나 잘 이해하고 있는지, 실전에서 잘 사용할 수 있는 기본기가 마련되어 있는지를 확인하는 테스트라고 볼 수 있습니다.
- 프로젝트를 무사히 마친 후에 여러분들이 입사할 기업에서 인터뷰 과정을 거치게 된다면 그 역시 기업 입장에서 여러분들이 해당 지원 분야의 개발자로서 자격이 있는지를 테스트하는 과정이 될 것입니다.
이처럼 테스트는 어떤 대상에 대한 일정 기준을 정해놓고, 그 대상이 정해진 기준에 부합하는지 부합하지 못하는지를 검증하는 과정이라고 볼 수 있습니다.
테스트를 하지 않으면 어떤 대상에 대한 검증이 정상적으로 이루어지지 않습니다.
검증이 정상적으로 이루어지지 않으면 잘못된 결과에 직면할 거고요.
테스트를 해야 되는 이유는 바로 여기에서 찾을 수 있습니다.
테스트 대상이 무엇이 되었든 간에 테스트를 제대로 잘 거쳐서 테스트 대상이 검증 과정에 잘 통과하게 만들어 최대한 더 나은 결과를 얻기 위해서입니다.
여기서 ‘최대한 더 나은 결과’라고 한 이유는 모든 테스트가 100 퍼센트 완벽하게 이루어질 수 없기 때문입니다.
완벽하게 좋은 결과를 얻는다는 보장이 없는데 테스트를 해야 할까요?
대답은 그래도 해야 한다입니다.
테스트를 전혀 하지 않을 때는 수습하기 힘든 상황에 직면할 가능성이 높습니다.
반면에 다양한 방법으로 테스트를 최대한 많이 시도해 본다면 100퍼센트는 아닐지라도 어느 정도 완성도 높은 결과를 얻을 수 있고, 또한 어떤 문제가 발생한다고 하더라도 문제를 해결할 가능성이 그만큼 커지기 때문입니다.
✔ Java 기반의 애플리케이션 테스트
그렇다면 우리가 현재 만들고 있는 커피 주문 샘플 애플리케이션에 대한 테스트도 해야 되지 않느냐는 생각이 들 수도 있습니다.
그런데 여러분들이 학습을 진행하면서 여러분들이 만들어보고 있는 샘플 애플리케이션에 대한 테스트를 이미 진행하고 있었을 것입니다.
예제 코드를 직접 타이핑해 보고, IntelliJ IDE에서 애플리케이션을 실행한 후에 여러분들이 실행시킨 애플리케이션에 Postman으로 HTTP 요청을 전송해서 여러분들이 기대했던 JSON 응답 결과가 출력되는지 확인하는 것.
이것도 테스트입니다.
만약 기대했던 JSON 응답 결과를 Postman으로 확인했다면 테스트에 성공한 것입니다.
기대했던 JSON 응답 결과가 아니라 에러 응답 같은 다른 결과를 확인했다면?
네. 테스트에 실패한 것입니다. (물론 에러 응답이 올 거야라고 기대했는데, 결과가 에러 응답이라면 그건 테스트에 성공한 것입니다. 실제로 테스트 결과로 예외가 넘어오면 테스트에 성공한 것이라고 기대하기도 합니다.)
테스트에 실패했다면 여러분들이 작성한 코드에서 어디가 문제인지 살펴볼 것이고, 코드상으로 찾기 어렵다면 콘솔에 출력된 로그를 확인할 수도 있을 것입니다.
그것마저도 안 된다면 코드 라인에 브레이크 포인트(breakpoint)를 걸어서 라인 단위로 어디가 잘못인지 확인할 것입니다.
애플리케이션의 테스트 역시 크게 어려운 개념은 아니라는 것을 이제 이해했을 거로 생각합니다.
✔ 조금 더 간편하고 쉬운 애플리케이션 테스트는 없을까?
매번 수작업으로 애플리케이션을 실행시키고, Postman을 열어서 HTTP 요청을 보낸다는 것은 아무래도 비효율적이고, 불편합니다.
그리고 한 가지 문제점은 애플리케이션 전체가 아니라 여러분이 구현한 API 계층, 서비스 계층, 데이터 액세스 계층 중에서 하나의 계층만 테스트하고 싶은 경우도 있는데 그러기가 쉽지 않다는 것입니다.
또한 비즈니스 로직에서 구현한 특정 메서드만 테스트하고 싶을 때가 있을 텐데 애플리케이션 실행, Postman 툴 실행 및 요청이라는 불편한 과정을 울며 겨자 먹기로 해야 되는 비효율적인 상황이 발생합니다.
이 경우 Java에서는 메서드 같은 아주 작은 단위를 가지는 기능들을 테스트할 수 있는 방법이 있습니다.
그리고, Spring에서는 계층별로 테스트할 수 있는 테스트 기법 역시 지원을 해주고 있습니다.
그중에서 이번 시간에는 아주 작은 단위의 기능을 테스트하는 방법을 먼저 살펴보도록 하겠습니다.
단위 테스트(Unit Test)란?
테스트 세계에서는 단위 테스트(Unit Test)라는 용어가 빠지지 않고 나옵니다.
단위 테스트란 무얼 의미하는 걸까요?
단위 테스트(Unit Test)라는 용어에서 테스트는 우리가 앞에서 이미 테스트의 의미에 대해서 이야기했기 때문에 잘 알고 있을 것입니다.
이제 ‘단위’에 대해서만 그 의미를 파악하면 될 것 같습니다.
얼핏 봐도 테스트를 어떤 특정한 단위로 구분해서 진행을 해야 될 것만 같은데, 어떤 단위를 기준으로 정해야 될지 한 번 살펴보겠습니다.
[그림 3-70] 애플리케이션의 일반적인 테스트 분류
[그림 3-70]은 우리가 만들고 있는 커피 주문 샘플 애플리케이션에 대해 ‘단위’라는 기준을 적용해서 표현한 그림입니다.
- 기능 테스트
[그림 3-70]을 보면 기능 테스트가 테스트의 범위가 제일 큰 것을 볼 수 있습니다. 단위로 따지자면 단위가 가장 큽니다.
기능 테스트는 주로 애플리케이션을 사용하는 사용자 입장에서 애플리케이션이 제공하는 기능이 올바르게 동작하는지를 테스트합니다.
기능 테스트를 하는 주체는 주로 해당 애플리케이션을 개발한 개발자가 될 수도 있지만 일반적으로는 테스트 전문 부서(QA 부서) 또는 외부 QA 업체가 됩니다. 종종 Frontend 개발자도 서버 측 애플리케이션이 잘 동작하는지 가볍게 테스트를 진행하기도 합니다.
아무튼 기능 테스트의 경우 API 툴이나 데이터베이스까지 연관되어 있어서 HTTP 통신도 해야 되고, 데이터베이스 연결도 해야 되는 등 우리가 개발한 애플리케이션과 연관된 대상이 많기 때문에 우리가 흔히 이야기하는 단위 테스트로 부르기는 힘듭니다. (그림 상에는 보이지 않지만 외부 서비스와도 연동될 수 있기 때문에 얽혀있는 것이 많을 수 있다고 보면 됩니다.)
- 통합 테스트
기능 테스트는 테스트를 하는 주체가 주로 개발자 이외의 제3자인 반면에 통합 테스트는 애플리케이션을 만든 개발자 또는 개발팀이 테스트의 주체가 되는 것이 일반적입니다.
통합 테스트는 클라이언트 측 툴 없이 개발자가 짜 놓은 테스트 코드를 실행시켜서 이루어지는 경우가 많습니다.
예를 들어, 개발자가 Controller의 API를 호출하는 테스트 코드를 작성한 후 실행하면 서비스 계층과 데이터 액세스 계층을 거쳐 DB에 실제로 접속해서 기대했던 대로 동작을 하는지 테스트하는 것은 통합 테스트의 하나라고 볼 수 있습니다.
그런데 통합 테스트 역시 애플리케이션의 여러 계층이 연관되어 있으며, DB까지 연결되어 있어서 독립적인 테스트가 가능하다고 볼 수는 없기 때문에 단위 테스트라고 하기에는 그 범위가 여전히 큰 편입니다.
- 슬라이스 테스트
슬라이스 테스트는 애플리케이션을 특정 계층으로 쪼개어서 하는 테스트를 의미합니다.
[그림 3-70]을 보면 API 계층, 서비스 계층, 데이터 액세스 계층이 각각 슬라이스 테스트의 대상이 될 수 있습니다.
그렇다면 슬라이스 테스트를 단위 테스트라고 부를 수 있을까요?
사실 이 정도로 쪼개어서 테스트한다면 단위 테스트라고 부를 수 있을지도 모르겠습니다.
하지만 슬라이스 테스트 역시 해당 계층에서 HTTP 요청이 필요하고, 외부 서비스가 연동되기도 하며 특히나 데이터 액세스 계층의 경우 여전히 DB와 연동되어 있기 때문에 슬라이스 테스트는 단위 테스트보다는 말 그대로 계층별로 쪼개어 테스트한다는 의미의 슬라이스 테스트라고 부릅니다.
슬라이스 테스트의 경우, Mock(가짜) 객체를 사용해서 계층별로 끊어서 테스트할 수 있기 때문에 어느 정도 테스트 범위를 좁히는 것이 가능합니다.
슬라이스 테스트는 단위 테스트라고 부르기에는 단위가 큰 테스트이며, 또한 애플리케이션의 일부만 테스트하기 때문에 부분 통합 테스트라고 부르기도 한다는 점 참고하세요.
Mock에 대해서는 뒤에서 다시 학습하게 되니 조금만 기다려주세요.
- 단위 테스트
단위 테스트를 설명하기 위해서 길게 돌아왔네요.
그런데 서비스 계층의 경우, 애플리케이션의 핵심 로직인 비즈니스 로직을 구현하는 계층입니다.
일반적으로 여러분들이 직접 구현하는 핵심 로직 즉, 비즈니스 로직에서 사용하는 클래스들이 독립적으로 테스트하기 가장 좋은 대상이기 때문에 단위 테스트라고 부르는 경우가 가장 많습니다.
여러분들이 만든 기능이 기대한 대로 빠르게 동작하는지 테스트해야 하기 위해서는 주로 무얼 테스트할까요?
바로 메서드입니다.
단위 테스트 코드는 메서드 단위로 대부분 작성된다고 생각하면 될 것 같습니다.
DB를 사용한다면 단위 테스트라고 보기 힘든 것일까?
통합 테스트나 슬라이스 테스트에서 데이터베이스와 연동된다고 해서 무조건적으로 단위 테스트라고 부르기 어렵다기보다는 데이터베이스의 상태가 테스트 이 전과 이 후가 동일하게 유지될 수 있다면 데이터베이스가 연동된다고 해도 단위 테스트에 포함될 수는 있습니다.
하지만 일반적으로 단위 테스트는 최대한 독립적인 것이 좋고, 최대한 작은 단위인 것이 더 좋습니다.
더 작은 단위일수록 다른 연관된 기능들을 생각할 필요도 없고, 테스트 코드 짜기도 더 단순해지고 그만큼 빠르게 테스트를 수행할 수 있기 때문입니다.
단위 테스트를 해야 되는 이유
그렇다면 우리가 단위 테스트를 해야 되는 이유는 무엇일까요?
- 우선 여러분들이 샘플 애플리케이션을 만들면서 IntelliJ IDE를 실행시키고, Postman을 열어서 HTTP 요청을 보내는 조금은 번거로운 일들을 단순화할 수 있습니다.
물론 HTTP 요청을 직접 보내는 테스트도 굉장히 중요하지만 매번 이렇게 하는 것은 비생산적입니다.
- 여러분들이 구현한 코드가 여러분들이 의도한 대로 동작하는지 그 결과를 빠르게 확인할 수 있습니다.
- 작은 단위의 테스트로 미리미리 버그를 찾을 수 있기 때문에 애플리케이션의 덩치가 커진 상태에서 문제의 원인을 찾아내는 것보다 상대적으로 더 적은 시간 안에 문제를 찾아낼 가능성이 높습니다.
- 테스트 케이스가 잘 짜여 있으면 버그가 발생하더라도 여러분들의 심리적인 안정감이 조금 더 높아질 가능성이 높습니다.
여러분들이 버그 리포트를 전달받을 경우, 버그가 발생한 기능의 테스트 케이스를 돌려보면서 문제가 발생한 원인을 단계적으로 찾아가기가 용이합니다.
테스트 케이스(Test Case)란?
테스트 케이스란 테스트를 위한 입력 데이터, 실행 조건, 기대 결과를 표현하기 위한 명세를 의미하는데, 한마디로 메서드 등 하나의 단위를 테스트하기 위해 작성하는 테스트 코드라고 생각하면 되겠습니다.
이 테스트 코드 안에 입력 데이터, 실행 조건, 기대 결과라는 로직들이 포함이 될 테니까요.
단위 테스트를 위한 F.I.R.S.T 원칙
단위 테스트를 위한 테스트 케이스를 작성하기 위해서 우리가 흔히 참고할 수 있는 가이드 원칙으로 F.I.R.S.T 원칙을 참고할 수 있습니다.
F.I.R.S.T 원칙이 어떤 원칙을 의미하는지 살짝 들여다보겠습니다.
- Fast(빠르게)
일반적으로 작성한 테스트 케이스는 빨라야 한다는 의미입니다. 작성한 테스트 케이스가 너무 느리다면 테스트 케이스를 돌려보고 싶은 마음이 잘 들지 않을 것입니다.
자주 돌려야 문제를 빨리 찾을 텐데, 너무 느려서 돌리기 힘들다면 테스트 케이스를 작성하는 의미가 퇴색될 것입니다.
- Independent(독립적으로)
각각의 테스트 케이스는 독립적이어야 한다는 의미입니다.
일반적으로 우리가 테스트 케이스를 작성할 때, 클래스 단위로 해당 클래스 내의 메서드 동작을 테스트 합니다.
메서드는 여러 개 존재할 가능성이 높을 테니 테스트 클래스 안에 테스트 케이스도 하나 이상이 될 것입니다.
이때, 어떤 테스트 케이스를 먼저 실행시켜도 실행되는 순서와 상관없이 정상적인 실행이 보장되어야 합니다.
예를 들어, A라는 테스트 케이스를 먼저 실행시킨 후에 다음으로 B라는 테스트 케이스를 실행시켰더니 테스트에 실패하게 된다면 테스트 케이스끼리 독립적이지 않은 것입니다.
- Repeatable(반복 가능하도록)
테스트 케이스는 어떤 환경에서도 반복해서 실행이 가능해야 된다는 의미입니다. IntelliJ 같은 여러분의 IDE에서 버튼을 눌러서 실행을 하든, Gradle 같은 빌드 태스크를 직접 입력해서 실행을 하든, 로컬 환경이나 서버 환경에서 실행하든 반복해서 같은 결과를 확인할 수 있어야 합니다.
외부 서비스나 외부 리소스가 연동되는 경우 앞에서 언급한 원칙들을 포함해서 동일한 테스트 결과 역시 보장하지 못하기 때문에 단위 테스트 시에는 외부의 서비스나 리소스의 연동을 끊어주는 것이 바람직합니다.
- Self-validating(셀프 검증이 되도록)
단위 테스트는 성공 또는 실패라는 자체 검증 결과를 보여주어야 한다는 의미입니다.
즉, 테스트 케이스 스스로 결과가 옳은지 그른지 판단할 수 있어야 한다는 것입니다.
- Timely(시기적절하게)
단위 테스트는 테스트하려는 기능 구현을 하기 직전에 작성해야 한다는 의미입니다. TDD(테스트 주도 개발) 개발 방식에서는 기능 구현 전에 실패하는 테스트 케이스를 먼저 작성하는 방식을 취하지만 실제로 기능 구현도 하지 않았는데 테스트 케이스부터 먼저 작성한다는 게 쉽지 않은 부분인 건 맞습니다. (연습이 필요합니다.
다만, 기능 구현을 먼저 한다 하더라도 너무 많은 구현 코드가 작성된 상태에서 테스트 케이스를 작성하려면 오히려 테스트 케이스를 작성하는데 더 많은 시간을 들일 가능성도 있습니다.
구현하고자 하는 기능을 단계적으로 조금씩 업그레이드하면서 그때그때 테스트 케이스 역시 단계적으로 업그레이드하는 방식이 더 낫다는 사실을 기억하면 좋을 것 같습니다.
우리가 학습하는 과정을 생각해 보세요. 한꺼번에 너무 많은 기술들을 배우지 않고, 단계적으로 학습 범위를 넓혀가고 있죠? 단위 테스트 케이스 작성도 우리의 학습 방식처럼 단계적으로 생각하면 좋을 것 같습니다.
JUnit 없이 비즈니스 로직에 단위 테스트 적용해 보기
앞에서 설명한 F.I.R.S.T 원칙이 무조건적으로 옳다고 볼 수는 없지만 상식 선에서 생각했을 때, 많은 개발자들이 단위 테스트를 수행하면서 느낀 점들이 굉장히 잘 반영되어 있습니다.
따라서 F.I.R.S.T 원칙을 최대한 지키는 노력을 하면서 단위 테스트를 작성하고 실행해보는 연습을 해 봅시다.
우리가 뒤 이어서 JUnit이라는 테스트 프레임워크를 사용하겠지만 JUnit 없이 기본적으로 테스트 케이스를 작성하는 흐름을 보면서 테스트 케이스는 이런 식으로 작성하면 되는구나 라는 감을 잡아 보도록 하겠습니다.
Java 기반의 소프트웨어를 테스트하기 위해서 JUnit은 사실상 표준 테스트 프레임워크라고 보면 되겠습니다.
JUnit은 이어지는 챕터에서 학습하게 됩니다. 조금만 기다려 주세요!
단위 테스트를 제일 쉽고 빠르게 적용할 수 있는 부분은 바로 헬퍼(helper) 클래스 또는 유틸리티(utility) 클래스입니다.
유틸리티 클래스에 대한 논쟁
유틸리티 클래스의 메서드들은 일반적으로 클래스의 객체로 인스턴스화될 필요가 없기 때문에 정적 메서드(static method)로 구성됩니다.
객체 지향 세계에서 모든 걸 객체 지향적인 시각으로 바라보는 사람들에게는 유틸리티 클래스의 사용을 지양하고 유틸리티 클래스조차 객체 지향적으로 구성하기 위한 노력을 합니다.
그런데 실제 우리가 잘 알고 있는 여러 검증된 오픈 소스에서 유틸리티 클래스를 사용하는 일은 드문 일이 아닙니다.
심지어 우리가 여태껏 학습을 해 온 Spring Framework에서 조차 StringUtils, BeanUtils 같은 유틸리티 클래스를 지원합니다.
이러한 사실을 미리 염두에 두고, 우리는 유틸리티 클래스에 대한 논쟁을 잠시 접어두고 단위 테스트라는 학습에 집중하도록 합시다.
public class StampCalculator {
public static int calculateStampCount(int nowCount, int earned) {
return nowCount + earned;
}
}
[코드 3-176] 테스트 대상인 헬퍼 클래스 예
코드 3-176은 커피 주문 샘플 애플리케이션에서 현재 회원이 보유한 스탬프 수와 회원이 주문한 커피 수량만큼 획득한 스탬프 수를 더해서 누적 스탬프 수를 계산해 주는 헬퍼 클래스입니다.
단위 테스트 학습을 목적으로 단 하나의 메서드만 가지고 있는 이 StampCalculator의 calculateStampCount() 메서드가 기대했던 대로 잘 동작하는지 검증하는 테스트 케이스를 작성해 보도록 합시다.
package com.codestates.helper;
public class StampCalculatorTestWithoutJUnit {
public static void main(String[] args) {
calculateStampCountTest();
}
private static void calculateStampCountTest() {
// given
int nowCount = 5;
int earned = 3;
// when
int actual = StampCalculator.calculateStampCount(nowCount, earned);
int expected = 7;
// then
System.out.println(expected == actual);
}
}
[코드 3-177] JUnit을 사용하지 않은 단위 테스트 예 1
코드 3-177은 JUnit을 사용하지 않고, StampCalculator의 calculateStampCount()를 테스트하는 테스트 케이스입니다.
JUnit이 없는 단위 테스트??
이번 챕터에서는 JUnit을 사용하지 않는다고 이미 얘기했지만 JUnit을 조금이라도 사용해 본 분들은 JUnit 없이 테스트 케이스를 작성한다는 게 이해가 되지 않는 분들도 있을 거라 생각합니다.
하지만 단위 테스트의 근본적인 목적은 메서드 같은 아주 작은 단위의 기능이 내가 원하는 대로 잘 동작하는지를 검증하는 것입니다.
코드 3-177은 단위 테스트의 근본적인 목적에 위배되는 것이 아니기 때문에 엄연히 단위 테스트라고 말할 수 있는 것입니다.
다만 조금 불편합니다. ^^ 이미 애플리케이션의 엔트리포인트(Entrypoint)로서 main() 메서드가 사용이 되는데도 불구하고 코드 3-177처럼 main() 메서드를 또 작성한다는 것 역시 어색하기도 하고요.
이 부분은 JUnit에서 개선될 부분이고, 지금은 단위 테스트를 하는 기본적인 방법 자체에 집중해 주세요.
코드 3-177에서의 테스트 대상은 StampCalculator.calculateStampCount() 메서드이고, 현재 주어진 스탬프 수가 5(nowCount)이고, 주문으로 얻게 되는 스탬프 수가 3(earned)인데
기대하는 값은 7(expected)이라고 예상하고 있습니다.
코드를 실행하면 당연히 계산 결과는 8(actual)이기 때문에 결과 값인 expected == actual은 false입니다.
즉, expected로 틀린 값인 7을 기대했기 때문에 결과(actual) 값으로 false가 나왔다는 것은 테스트에 실패한 것입니다.
✅ 우리가 JUnit을 사용하지 않았을 뿐이지 기본적인 단위 테스트 작성법은 코드 3-177과 크게 다르지 않다는 것을 기억해 주세요.
✅ Given-When-Then 표현 스타일
코드 3-177에서 주석으로 표시한 given - when - then이라는 용어는 BDD(Behavior Driven Development)라는 테스트 방식에서 사용하는 용어입니다.
단위 테스트에 익숙하지 않은 분들에게 또는 테스트 케이스의 가독성을 높이기 위해 given - when - then 표현 방법을 사용하는 것은 테스트 케이스를 작성하는데 유용한 방법입니다.
- Given
- 테스트를 위한 준비 과정을 명시할 수 있습니다.
- 테스트에 필요한 전제 조건들이 포함된다고 보면 됩니다.
- 테스트 대상에 전달되는 입력 값(테스트 데이터) 역시 Given에 포함됩니다.
- When
- 테스트할 동작(대상)을 지정합니다.
- 단위 테스트에서는 일반적으로 메서드 호출을 통해 테스트를 진행하므로 한두 줄 정도로 작성이 끝나는 부분입니다.
- Then
- 테스트의 결과를 검증하는 영역입니다.
- 일반적으로 예상하는 값(expected)과 테스트 대상 메서드의 동작 수행 결과(actual) 값을 비교해서 기대한 대로 동작을 수행하는지 검증(Assertion)하는 코드들이 포함됩니다.
Assertion(어써션)이란?
Assertion(어써션)을 검색해 보면 우리말로 ‘단언’, ‘단정’ 등의 뜻을 확인할 수 있는데 그 의미가 선뜻 와닿지 않습니다.
테스트 세계에서 Assertion(어써션)이라는 용어는 테스트 결과를 검증할 때 주로 사용합니다.
테스트 케이스의 결과가 반드시 참(true)이어야 한다는 것을 논리적으로 표현한 것이 Assertion(어써션)인데, 한마디로 ‘예상하는 결과 값이 참(true)이길 바라는 것’이라고 이해하면 될 것 같습니다.
Assertion(어써션)을 단언문, 단정문이라고 표현을 하는 곳이 많은데 우리는 앞으로 Assertion을 부를 때, 이름 그대로 Assertion(어써션)이라고 부르도록 하겠습니다.
public class StampCalculator {
// (1)
public static int calculateStampCount(int nowCount, int earned) {
return nowCount + earned;
}
// (2)
public static int calculateEarnedStampCount(Order order) {
return order.getOrderCoffees().stream()
.map(orderCoffee -> orderCoffee.getQuantity())
.mapToInt(quantity -> quantity)
.sum();
}
}
[코드 3-178] 테스트 대상인 헬퍼 클래스 예
코드 3-178을 보면 테스트 대상 클래스인 StampCalculator 클래스에 (2)와 같이 하나의 기능이 더 추가되었습니다. (1)은 앞에서 이미 테스트 한 기능입니다.
(2)의 calculateEarnedStampCount() 메서드는 회원이 주문한 주문 정보에서 얻게 되는 스탬프 개수를 계산하는 기능을 합니다.
이번에는 이 calculateEarnedStampCount()를 테스트해 보겠습니다.
public class StampCalculatorTestWithoutJUnit {
public static void main(String[] args) {
calculateStampCountTest(); // (1)
calculateEarnedStampCountTest(); // (2)
}
private static void calculateStampCountTest() {
// given
int nowCount = 5;
int earned = 3;
// when
int actual = StampCalculator.calculateStampCount(nowCount, earned);
int expected = 7;
// then
System.out.println(expected == actual);
}
private static void calculateEarnedStampCountTest() {
// given
Order order = new Order();
OrderCoffee orderCoffee1 = new OrderCoffee();
orderCoffee1.setQuantity(3);
OrderCoffee orderCoffee2 = new OrderCoffee();
orderCoffee2.setQuantity(5);
order.setOrderCoffees(List.of(orderCoffee1, orderCoffee2));
int expected = orderCoffee1.getQuantity() + orderCoffee2.getQuantity();
// when
int actual = StampCalculator.calculateEarnedStampCount(order);
// then
System.out.println(expected == actual);
}
}
[코드 3-179] JUnit을 사용하지 않은 단위 테스트 예 2
(1)은 이미 테스트를 진행했으니 설명은 생략합니다.
(2)는 calculateEarnedStampCount() 메서드를 테스트하는 테스트 케이스입니다.
Given-When-Then으로 테스트 케이스를 설명하면 다음과 같습니다.
- given
- 주문한 커피의 수량이 필요하기 때문에 Order와 OrderCoffee 객체를 직접 만들어서 테스트에 필요한 데이터를 생성합니다.
- when
- 테스트 대상인 StampCalculator.calculateEarnedStampCount()에 given에서 생성한 테스트 데이터를 입력값으로 전달합니다.
- 이번 테스트 케이스의 목적은 바로 StampCalculator.calculateEarnedStampCount() 메서드가 잘 동작하는지를 확인하는 것입니다.
- then
- 주문한 커피 수량만큼의 스탬프가 계산되는지를 Assertion합니다.
given에서 왜 데이터를 수작업으로 만들어주는 거지?라고 생각하는 분들도 계실 텐데, when 설명에서 설명한 것처럼 이번 테스트 케이스의 목적은 StampCalculator.calculateEarnedStampCount() 메서드의 동작을 테스트하는 것입니다.
StampCalculator.calculateEarnedStampCount() 메서드의 파라미터로 주어지는 입력 값인 Order(주문) 객체를 통해 얻게 되는 스탬프 개수를 잘 계산하는지 Assertion하는 것이 핵심이기 때문에 테스트 데이터가 입력 값으로 필요합니다.
그 입력값이 바로 given에서 사용한 OrderCoffee 객체를 포함한 Order 객체인 것입니다.
코드 3-179를 실행하면 두 개의 테스트 케이스가 실행되고 두 개의 결과가 다음과 같이 콘솔에 출력됩니다.
false
true
이 처럼 하나의 테스트 클래스에서 여러 개의 테스트 케이스를 한꺼번에 실행할 수 있으며, JUnit을 사용하더라도 마찬가지입니다.
그리고 두 개의 테스트 케이스를 한꺼번에 실행하고 순서를 바꿔서 사용하더라도 각각의 테스트 케이스는 독립적으로 실행되기 때문에 테스트 케이스를 실행할 때마다 테스트 결과가 바뀌는 경우는 없습니다.
즉, 간단한 예제 코드이지만 앞에서 설명한 F.I.R.S.T 원칙을 그럭저럭 잘 따른다고 볼 수 있습니다.
다음 시간에는 JUnit을 사용하지 않고 작성한 테스트 케이스를 JUnit을 사용하는 것으로 변경해 보면서 JUnit의 기본적인 사용법을 익혀 보도록 하겠습니다.
이번 시간을 통해서 여러분들이 단위 테스트를 어떻게 작성하는지 기본적인 방법은 충분히 이해했길 바랍니다.
핵심 포인트
- 테스트란 어떤 대상에 대한 일정 기준을 정해놓고, 그 대상이 정해진 기준에 부합하는지 부합하지 못하는지를 검증하는 과정이다.
- 우리가 IntelliJ IDE에서 애플리케이션을 실행한 후에 애플리케이션에 Postman으로 HTTP 요청을 전송해서 기대했던 JSON 응답 결과를 확인하는 것 역시 테스트이다.
- 기능 테스트는 주로 애플리케이션을 사용하는 사용자 입장에서 애플리케이션이 제공하는 기능이 올바르게 동작하는지 테스트하는 것을 의미한다.
- 통합 테스트는 클라이언트 측 툴 없이 개발자가 짜 놓은 테스트 코드를 실행시켜서 이루어지는 경우가 많다.
- 슬라이스 테스트는 애플리케이션을 특정 계층으로 쪼개어서 하는 테스트를 의미한다.
- 일반적으로 단위 테스트는 메서드 단위로 작성된다.
- 테스트 케이스란 테스트를 위한 입력 데이터, 실행 조건, 기대 결과를 표현하기 위한 명세를 의미한다.
- 단위 테스트를 위한 F.I.R.S.T 원칙
- Fast(빠르게)
- Independent(독립적으로)
- Repeatable(반복 가능하도록)
- Self-validating(셀프 검증이 되도록)
- Timely(시기적절하게)
- Given-When-Then 표현 스타일
- Given
- 테스트를 위한 준비 과정을 명시한다.
- When
- 테스트할 동작(대상)을 지정한다.
- Then
- 테스트의 결과를 검증(Assertion)한다.
- Given
- Assertion(어써션)은 ‘예상하는 결과 값이 참(true)이길 바라는 것’을 의미한다.
심화 학습
- F.I.R.S.T 원칙에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요
- Given-When-Then에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
- BDD(Behavior Dreiven Development)에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
Chapter - JUnit을 사용한 단위 테스트
이번 시간에는 이 전 챕터에서 JUnit을 사용하지 않고 작성한 단위 테스트 케이스에 JUnit을 적용해 보면서 JUnit의 기본적인 사용법을 익혀 보도록 하겠습니다.
핵심 포인트
- JUnit의 기본 사용법을 이해할 수 있다.
- JUnit으로 작성되지 않은 단위 테스트에 JUnit을 적용할 수 있다.
[기본] JUnit으로 비즈니스 로직에 단위 테스트 적용하기
이 전 챕터에서 여러분은 JUnit을 사용하지 않고, 단위 테스트를 작성하는 기본적인 방법을 학습했습니다.
이번 챕터에서는 JUnit을 학습하면서 이 전 챕터에서 Junit을 사용하지 않고 작성한 테스트 케이스에 JUnit을 적용해 보도록 하겠습니다.
JUnit이란?
JUnit은 Java 언어로 만들어진 애플리케이션을 테스트하기 위한 오픈 소스 테스트 프레임워크로서 사실상 Java의 표준 테스트 프레임워크라고 해도 과언이 아닙니다.
TestNG라는 JUnit의 강력한 경쟁자가 있긴 하지만 JUnit은 여전히 Java 애플리케이션 테스트를 위한 핵심입니다.
JUnit은 2022년 현재 Junit 5가 릴리스 된 상태이며, 이번 학습에서도 JUnit 5를 이용해서 테스트 케이스를 작성합니다.
Spring Boot의 디폴트 테스트 프레임워크는 JUnit입니다. 따라서 우리는 TestNG가 아닌 JUnit 5를 학습한다는 사실을 기억하세요.
TestNG에 대해서 더 알고 싶다면 아래 [심화 학습]을 참고하세요.
JUnit 기본 작성법
이제 JUnit을 사용해서 단위 테스트를 수행하는 방법을 단계적으로 알아보도록 하겠습니다.
Spring Boot Initializr에서 Gradle 기반의 Spring Boot 프로젝트를 생성하고 오픈하면 기본적으로 ‘src/test’ 디렉토리가 만들어집니다. JUnit 테스트 케이스는 프로젝트의 ‘src/test/java/com/springboot/’ 아래에 작성되어 있으니 참고 바랍니다.
Spring Boot Intializr를 이용해서 프로젝트를 생성하면 기본적으로 testImplementation >'org.springframework.boot:spring-boot-starter-test' 스타터가 포함되며, JUnit도 포함이 되어 있습니다.
여러분들은 별다른 설정 없이 JUnit을 사용하면 됩니다.
✔ JUnit을 사용한 테스트 케이스의 기본 구조
JUnit을 사용하는 테스트 케이스의 기본 구조는 기본적으로 굉장히 심플하며, 여러분들이 기본 구조만 작성하는 부분은 큰 어려움이 없을 거라 생각합니다.
import org.junit.jupiter.api.Test;
public class JunitDefaultStructure {
// (1)
@Test
public void test1() {
// 테스트하고자 하는 대상에 대한 테스트 로직 작성
}
// (2)
@Test
public void test2() {
// 테스트하고자 하는 대상에 대한 테스트 로직 작성
}
// (3)
@Test
public void test3() {
// 테스트하고자 하는 대상에 대한 테스트 로직 작성
}
}
[코드 3-180] JUnit을 사용한 테스트 케이스의 기본 구조
테스트 케이스에 JUnit을 적용하는 기본 구조는 코드 3-180과 같습니다.
여러분들이 보면 알겠지만 굉장히 심플합니다. (1), (2), (3)과 같이 애플리케이션에서 테스트하고자 하는 대상(Target)이 있으면 public void test1(){…} 같은 void 타입의 메서드 하나 만들고, @Test 애너테이션을 추가해 줍니다.
그리고 그 내부에 테스트하고자 하는 대상 메서드에 대한 테스트 로직을 작성해 주면 됩니다.
그럼 JUnit에 대한 Hello, JUnit을 작성해 보면서 사용법을 간단하게 익혀 볼까요?
✔ Assertion 메서드 사용하기
Assertion은 ‘예상하는 결과 값이 참(true)이길 바라는 논리적인 표현’이다라고 이 전 챕터에서 설명을 한 적이 있습니다.
Assertion의 의미가 아직 명확하지 않은 분들을 위해 ‘검증한다’라는 표현을 함께 사용하겠습니다.
JUnit에서는 Assertion과 관련된 다양한 메서드를 사용해서 테스트 대상에 대한 Assertion을 진행할 수 있습니다.
- assertEquals()
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class HelloJUnitTest {
@DisplayName("Hello JUnit Test") // (1)
@Test
public void assertionTest() {
String expected = "Hello, JUnit";
String actual = "Hello, JUnit";
assertEquals(expected, actual); // (2)
}
}
[코드 3-181] assertEquals() 사용 예
코드 3-181은 JUnit에서 사용할 수 있는 assertEquals() 메서드를 사용하는 예입니다.
assertEquals() 메서드를 사용하면 기대하는 값과 실제 결과 값이 같은지를 검증할 수 있습니다.
(2)에서는 기대하는 문자열(expected)과 실제 결과 값(autual)이 일치하는지를 검증하고 있습니다.
(1)은 테스트 케이스를 실행시켰을 때, 실행 결과 창에 표시되는 이름을 지정하는 부분입니다.
그럼 해당 테스트 케이스를 실행해 보도록 하겠습니다. 테스트 케이스 실행은 어떻게 할까요?
[그림 3-71] JUnit 테스트 케이스 실행 예
[그림 3-71]의 (1)을 클릭한 후에 [Run] 버튼을 클릭하면 클래스 내의 전체 테스트 케이스를 실행할 수 있습니다.
(2)에서의 [Run] 버튼을 클릭하면 해당 테스트 케이스만 실행할 수 있습니다.
우리는 (2)를 눌러서 코드 3-181의 테스트 케이스 실행 결과를 확인해 보겠습니다.
[그림 3-72] JUnit 테스트 케이스 실행 결과 화면 예
테스트 케이스 실행 결과 중에서 (2)와 같이 테스트에 통과(passed) 한 결과를 보기 위해서는 (1)의 체크 박스에 체크가 되어 있어야 합니다.
(2)를 보면 [코드 3-181]의 assertionTest1() 메서드에 추가한 @DisplayName("Hello JUnit Test")의 값이 실행 결과에 표시되는 걸 확인할 수 있습니다.
테스트 케이스가 성공(passed )이면 테스트 결과에 초록색 체크 아이콘이 표시됩니다.
그렇다면 테스트에 실패할 경우에는 실행 결과가 어떻게 표시될까요?
코드 3-181의 expected 변수의 값을 “Hello, World”로 바꿔보겠습니다.
[그림 3-73] JUnit 테스트 케이스 실행 결과 실패 예
그림 3-73을 보면 (1)을 통해서 테스트 실행 결과가 실패(failed) 임을 알 수 있고, (2)를 통해서 왜 실패했는지에 대한 설명을 볼 수 있습니다.
기대했던 값은 “Hello, World”인데, 실제 결과 값은 “Hello, JUnit”이다라고 친절하게 보여주고 있습니다.
학습을 진행하면서 JUnit에서 지원하는 Assertion 메서드 중에서 설명하지 않은 나머지 Assertion 메서드에 대해서 더 알아보고 싶다면 아래 \[심화 학습\]을 참고하세요.
이후부터는 테스트 성공은 “passed”, 실패는 “failed”로 간단하게 표현하겠습니다. 참고하세요!
- assertNotNull() : Null 여부 테스트
import com.springboot.CryptoCurrency;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertNotNull;
public class AssertionNotNullTest {
@DisplayName("AssertionNull() Test")
@Test
public void assertNotNullTest() {
String currencyName = getCryptoCurrency("ETH");
// (1)
assertNotNull(currencyName, "should be not null");
}
private String getCryptoCurrency(String unit) {
return CryptoCurrency.map.get(unit);
}
}
[코드 3-182] assertNull() 사용 예
(1)에서 assertNotNull() 메서드를 사용하면 테스트 대상 객체가 null 이 아닌지를 테스트할 수 있습니다.
assertNotNull() 메서드의 첫 번째 파라미터는 테스트 대상 객체이고, 두 번째 파라미터는 테스트에 실패했을 때, 표시할 메시지입니다.
실행 결과는 “ETH”에 해당하는 암호 화폐 이름이 map에 저장이 되어 있기 때문에 “passed”입니다.
CryptoCurrency 클래스 코드는 아래의 코드 3-183을 참고하세요.
import java.util.HashMap;
import java.util.Map;
public class CryptoCurrency {
public static Map<String, String> map = new HashMap<>();
static {
map.put("BTC", "Bitcoin");
map.put("ETH", "Ethereum");
map.put("ADA", "ADA");
map.put("POT", "Polkadot");
}
}
[코드 3-183] 테스트를 위한 CryptoCurrency 클래스
- assertThrows() : 예외(Exception) 테스트
이번에는 assertThrows()를 사용해서 호출한 메서드의 동작 과정 중에 예외가 발생하는지 테스트해보겠습니다.
import com.springboot.CryptoCurrency;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class AssertionExceptionTest {
@DisplayName("throws NullPointerException when map.get()")
@Test
public void assertionThrowExceptionTest() {
// (1)
assertThrows(NullPointerException.class, () -> getCryptoCurrency("XRP"));
}
private String getCryptoCurrency(String unit) {
return CryptoCurrency.map.get(unit).toUpperCase();
}
}
[코드 3-184] 예외가 발생하는지 테스트
코드 3-184에서는 getCryptoCurrency() 메서드를 호출했을 때, NullPointerException이 발생하는지 테스트하고 있습니다.
(1)에서 assertThrows()의 첫 번째 파라미터에는 발생이 기대되는 예외 클래스를 입력하고, 두 번째 파라미터인 람다 표현식에서는 테스트 대상 메서드를 호출하면 됩니다.
테스트 케이스를 실행하면 getCryptoCurrency() 메서드가 호출되고, 파라미터로 전달한 “XRP”라는 키에 해당하는 암호 화폐가 있는지 map에서 찾습니다.
하지만 XRP에 해당하는 암호 화폐는 map에 존재하지 않기 때문에 map에서 반환된 값은 null이 될 것입니다.
그리고 map에서 반환된 값이 null인 상태에서 toUpperCase()를 호출해서 대문자로 변환하려고 했기 때문에 NullPointerException이 발생할 것입니다.
따라서 (1)에서 NullPointerException이 발생할 것이라고 기대했기 때문에 테스트 실행 결과는 “passed”입니다.
✅ 예외 타입이 다를 경우에는 “passed”일까? “failed”일까?
그런데 만약 [코드 3-184]에서 assertThrows()의 첫 번째 파라미터로 NullPointerException.class 대신에 IllegalStateException.class으로 입력 값을 바꾸면 어떻게 될까요?
테스트 실행 결과는 “failed”입니다. 우선 기본적으로 IllegalStateException.class과 NullPointerException.class은 다른 타입이고, IllegalStateException.class이 NullPointerException.class의 상위 타입도 아니기 때문에 테스트 실행 결과는 “failed”입니다.
그렇다면 만약 NullPointerException.class 대신에 RuntimeException.class 또는 Exception.class으로 입력 값을 바꾸면 이번에는 테스트 실행 결과가 어떻게 될까요?
이 경우, 테스트 실행 결과는 “passed”입니다.
NullPointerException은 RuntimeException을 상속하는 하위 타입이고, RuntimeException은 Exception을 상속하는 하위 타입입니다.
이처럼 assertThrows()를 사용해서 예외를 테스트하기 위해서는 예외 클래스의 상속 관계를 이해한 상태에서 테스트 실행 결과를 예상해야 된다는 사실을 기억하기 바랍니다.
✅ 테스트 케이스 실행 시, 예외가 발생한다고 전부 “failed”인가요?
여러분들이 앞에서 예외 발생 테스트를 해보았기 때문에 그렇게 생각하지 않을 거라 기대(expected)하지만(^^), 노파심에서 한번 더 이야기하겠습니다.
애플리케이션에서 예외가 발생한다면 어떤 문제가 있는 것이기 때문에 ‘실패’, ‘문제 발생’ 같은 부정적인 단어를 떠올릴 수 있겠지만 테스트 세계에서는 다릅니다.
테스트 케이스 실행에서 예외가 발생한다 하더라도 여러분이 예외가 발생한다라고 기대하는 순간(expected) 예외가 발생하는 것은 “passed”가 되는 것입니다.
여러분이 ‘이 로직은 테스트해보면 예외가 발생 안 할 거야’ 라고 기대했는데, 예외가 발생하면 “failed”인 것입니다.
테스트 케이스는 무조건 이렇게 예상하든 저렇게 예상하든 예상(기대) 한 결과가 나와야지만 “passed”라는 사실 꼭 잊지 마세요.
여러분이 이해했을 거라고 기대합니다(expected).
Executable 함수형 인터페이스
assertThrows()의 두 번째 파라미터인 람다 표현식은 JUnit에서 지원하는 Executable 함수형 인터페이스입니다.
Executable 함수형 인터페이스는 void execute() throws Throwable; 메서드 하나만 정의되어 있으며 리턴값이 없습니다.
Java에서 지원하는 함수형 인터페이스 중에서 리턴값이 없는 Consumer에 해당된다고 보면 되겠습니다.
✔ 테스트 케이스 실행 전, 전처리
테스트 케이스를 실행하기 전에 어떤 객체나 값에 대한 초기화 작업 등의 전처리 과정을 해야 할 경우가 많습니다. 이 경우 JUnit에서 사용할 수 있는 애너테이션이 바로 @BeforeEach와 @BeforeAll()입니다.
- @BeforeEach
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
// (1)
public class BeforeEach1Test {
@BeforeEach
public void init() {
System.out.println("Pre-processing before each test case");
}
@DisplayName("@BeforeEach Test1")
@Test
public void beforeEachTest() {
}
@DisplayName("@BeforeEach Test2")
@Test
public void beforeEachTest2() {
}
}
[코드 3-185] @BeforeEach 애너테이션의 동작 방식
코드 3-185에는 두 개의 비어 있는 테스트 케이스가 있습니다. 테스트 케이스는 비어있지만 @BeforeEach의 동작 방식을 이해하기에는 적절합니다.
코드 3-185의 모든 테스트 케이스 메서드를 실행하면 어떤 결과가 콘솔에 출력이 될까요?
IntelliJ에서 테스트 케이스를 실행시킬 때, (1)과 같이 클래스 좌측에 표시되는 녹색 버튼(Run)을 실행시켜야 클래스 내의 모든 테스트 케이스 메서드가 실행이 됩니다.
Pre-processing before each test case
Pre-processing before each test case
실행 결과를 보면 init() 메서드가 총 두 번 실행되어서 “Pre-processing before each test case”가 콘솔에 두 번 출력되었습니다.
이처럼 @BeforeEach 애너테이션을 추가한 메서드는 테스트 케이스가 각각 실행될 때마다 테스트 케이스 실행 직전에 먼저 실행되어 초기화 작업 등을 진행할 수 있습니다.
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
public class BeforeEach2Test {
private Map<String, String> map;
@BeforeEach
public void init() {
map = new HashMap<>();
map.put("BTC", "Bitcoin");
map.put("ETH", "Ethereum");
map.put("ADA", "ADA");
map.put("POT", "Polkadot");
}
@DisplayName("Test case 1")
@Test
public void beforeEachTest() {
map.put("XRP", "Ripple");
assertDoesNotThrow(() -> getCryptoCurrency("XRP"));
}
@DisplayName("Test case 2")
@Test
public void beforeEachTest2() {
System.out.println(map);
assertDoesNotThrow(() -> getCryptoCurrency("XRP"));
}
private String getCryptoCurrency(String unit) {
return map.get(unit).toUpperCase();
}
}
[코드 3-186] @BeforeEach 애너테이션 예
자, 그럼 코드 3-186의 테스트 케이스를 클래스 레벨에서 실행시킬 경우의 실행 결과를 예상해 봅시다.
테스트 실행 결과는 어떻게 될까요? Test case1과 Test case 2의 결과를 예상해 보세요.
[그림 3-74] 코드 3-186 실행 결과
실행 결과를 보면, Test case 2는 “failed”이고, Test case 1은 “passed”입니다.
Test case 1은 assertDoesNotThrow()로 Assertion 하기 전에 map에 “XRP”의 값을 추가했습니다.
그렇기 때문에 map에 “XRP”의 값이 존재하므로 예외가 발생하지 않습니다.
assertDoesNotThrow() 메서드는 예외가 발생하지 않는다고 기대하는 Assertion 메서드입니다.
따라서, 예외가 발생하지 않는다고 기대했으므로 테스트 실행 결과는 “passed”입니다.
그런데 Test case 2는 Assertion 하기 전에 map에 “XRP”를 추가하지 않습니다.
따라서 Test case 2가 실행되기 전에 init() 메서드가 호출되면서 이 전에 한번 사용했던 map 객체가 다시 초기화됩니다.
Test case1에서 map에 “XRP”를 추가했다 하더라도 추가한 “XRP”는 Test case2 실행 전에 init() 메서드가 다시 호출되면서 map이 초기화되기 때문에 초기화된 상태로 되돌아갑니다.
따라서, Test case 2에서 예외가 발생하지 않는다고 기대했지만 NullpointerException이 발생하므로 테스트 실행 결과는 “failed”입니다.
콘솔에 출력된 “{BTC=Bitcoin, POT=Polkadot, ETH=Ethereum, ADA=ADA}”는 Test case 2 실행 시, map의 상태를 출력한 것입니다. map 안에 “XRP”는 없는 것을 확인할 수 있습니다.
- @BeforeAll()
@BeforeAll()은 @BeforeEach()와 달리 클래스 레벨에서 테스트 케이스를 한꺼번에 실행시키면 테스트 케이스가 실행되기 전에 딱 한 번만 초기화 작업을 할 수 있도록 해주는 애너테이션입니다.
public class BeforeAllTest {
private static Map<String, String> map;
@BeforeAll
public static void initAll() {
map = new HashMap<>();
map.put("BTC", "Bitcoin");
map.put("ETH", "Ethereum");
map.put("ADA", "ADA");
map.put("POT", "Polkadot");
map.put("XRP", "Ripple");
System.out.println("initialize Crypto Currency map");
}
@DisplayName("Test case 1")
@Test
public void beforeEachTest() {
assertDoesNotThrow(() -> getCryptoCurrency("XRP"));
}
@DisplayName("Test case 2")
@Test
public void beforeEachTest2() {
assertDoesNotThrow(() -> getCryptoCurrency("ADA"));
}
private String getCryptoCurrency(String unit) {
return map.get(unit).toUpperCase();
}
}
[코드 3-187] @BeforeAll 애너테이션 예
코드 3-187에서는 @BeforeAll 애너테이션을 사용해서 map 객체를 한 번만 초기화하기 때문에 두 개의 테스트 케이스 실행 결과는 모두 “passed”입니다.
그리고, 콘솔에는 아래와 같이 “initialize Crypto Currency map”이 한 번만 출력됩니다.
initialize Crypto Currency map
@BeforeAll 애너테이션을 추가한 메서드는 정적 메서드(static method)여야 한다는 사실을 기억하세요!
✔ 테스트 케이스 실행 후, 후처리
JUnit에서는 테스트 케이스 실행이 끝난 시점에 후처리 작업을 할 수 있는 @AfterEach, @AfterAll 같은 애너테이션도 지원합니다.
이 애너테이션은 @BeforeEach , @BeforeAll과 동작 방식은 같고, 호출되는 시점만 반대입니다.
@AfterEach, @AfterAll 은 여러분이 직접 한 번 예제를 만들어서 동작 방식을 확인해 보길 바랍니다.
✔ Assumption을 이용한 조건부 테스트
Junit 5에는 Assumption이라는 기능이 추가되었습니다.
Assumption은 ‘~라고 가정하고’라는 표현을 쓸 때의 ‘가정’에 해당합니다.
JUnit 5의 Assumption 기능을 사용하면 특정 환경에만 테스트 케이스가 실행되도록 할 수 있습니다.
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
public class AssumptionTest {
@DisplayName("Assumption Test")
@Test
public void assumptionTest() {
// (1)
assumeTrue(System.getProperty("os.name").startsWith("Windows"));
// assumeTrue(System.getProperty("os.name").startsWith("Linux")); // (2)
System.out.println("execute?");
assertTrue(processOnlyWindowsTask());
}
private boolean processOnlyWindowsTask() {
return true;
}
}
[코드 3-188] Assumption 예제
코드 3-188은 Assumption 기능을 사용하는 예제 코드입니다.
(1)에서 assumeTrue() 메서드는 파라미터로 입력된 값이 true이면 나머지 아래 로직들을 실행합니다.
만약, 여러분들이 코드 3-188의 테스트 케이스를 실행하는 PC의 운영체제(OS)가 윈도우(Windows)라면 assumeTrue() 메서드의 파라미터 값이 true가 될 것이므로 assumeTrue() 아래 나머지 로직들이 실행이 될 것이고, 여러분들의 PC 운영체제(OS)가 윈도우(Windows)가 아니라면 assumeTrue() 아래 나머지 로직들이 실행되지 않을 것입니다.
이처럼, assumeTrue()는 특정 OS 환경 등의 특정 조건에서 선택적인 테스트가 필요하다면 유용하게 사용할 수 있는 JUnit 5의 API입니다.
JUnit으로 비즈니스 로직에 단위 테스트 적용해 보기
public class StampCalculator {
public static int calculateStampCount(int nowCount, int earned) {
return nowCount + earned;
}
public static int calculateEarnedStampCount(Order order) {
return order.getOrderCoffees().stream()
.map(orderCoffee -> orderCoffee.getQuantity())
.mapToInt(quantity -> quantity)
.sum();
}
}
[코드 3-189] 단위 테스트 대상인 StampCalculator 헬퍼 클래스
코드 3-189는 이 전 챕터에서 JUnit 없이 단위 테스트를 진행했던 테스트 대상 클래스입니다.
우리는 아래의 코드 3-190과 같이 JUnit을 사용하지 않고, StampCalculator의 메서드에 대해 단위 테스트를 진행했었습니다.
public class StampCalculatorTestWithoutJUnit {
public static void main(String[] args) {
calculateStampCountTest(); // (1) 첫 번째 단위 테스트
calculateEarnedStampCountTest(); // (2) 두 번째 단위 테스트
}
private static void calculateStampCountTest() {
// given
int nowCount = 5;
int earned = 3;
// when
int actual = StampCalculator.calculateStampCount(5, 3);
int expected = 7;
// then
System.out.println(expected == actual);
}
private static void calculateEarnedStampCountTest() {
// given
Order order = new Order();
OrderCoffee orderCoffee1 = new OrderCoffee();
orderCoffee1.setQuantity(3);
OrderCoffee orderCoffee2 = new OrderCoffee();
orderCoffee2.setQuantity(5);
order.setOrderCoffees(List.of(orderCoffee1, orderCoffee2));
// when
int actual = StampCalculator.calculateEarnedStampCount(order);
int expected = 8;
// then
System.out.println(expected == actual);
}
}
[코드 3-190] JUnit 없이 진행했던 StampCalculator 클래스에 대한 단위 테스트
StampCalculator 클래스에 JUnit을 적용해서 단위 테스트를 진행하는 것은 여러분이 직접 실습을 통해서 해볼 수 있도록 단위 테스트 실습 과제로 남겨 두겠습니다.
JUnit을 사용해서 여러분들만의 방식으로 StampCalculator 클래스의 단위 테스트를 진행해 보세요!
핵심 포인트
- JUnit은 Java 언어로 만들어진 애플리케이션을 테스트하기 위한 오픈 소스 테스트 프레임워크이다.
- JUnit은 2022년 현재 Junit 5가 릴리스 되어 있다.
- JUnit으로 테스트 케이스를 작성하기 위해서는 기본적으로 @Test 애너테이션을 추가해야 한다.
- JUnit은 assertXXXX()로 시작하는 다양한 Assertion 메서드를 지원한다.
- JUnit은 테스트 케이스 실행 전, 후에 어떤 처리 로직을 작성할 수 있도록 @BeforeEach, @BeforeAll, @AfterEach, @AfterAll 등의 애너테이션을 지원한다.
심화 학습
- TestNG에 대해서 더 알고 싶다면 아래 링크를 참고하세요.
- JUnit 5의 Assertion 메서드에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
- JUnit 5의 Assumption에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
[실습] JUnit을 사용한 단위 테스트 실습
JUnit을 사용한 단위 테스트 실습 개요
이번 실습은 JUnit을 사용하여 비즈니스 로직의 특정 기능에 단위 테스트를 적용해 보는 실습입니다.
- 지난 챕터까지 학습했던 구현 코드들이 기본적으로 포함이 되어 있으며, 이를 기반으로 요구 사항에 맞게 비즈니스 로직의 특정 기능에 대한 단위 테스트를 할 수 있는 테스트 케이스를 작성하면 됩니다.
- 실습용 프로젝트 패키지는 ‘com.springboot’ 패키지 하위에 아래와 같이 구성되어 있습니다.
- advice
- audit
- coffee
- config
- dto
- exception
- member
- order
- response
- stamp
- validator
- 단위 테스트를 위한 테스트 케이스는 일반적으로 Gradle 기반 프로젝트에서 ‘src/test/java/**’ 경로에 작성합니다.
- 여러분들이 작성할 테스트 케이스 역시 ‘src/test/java/com/springboot/homework’의 실습 과제용 테스트 케이스 내부에 작성하면 됩니다.
실습 사전 준비
- 실습용 샘플 프로젝트 복제
- 아래 github 링크에서 실습용 repository를 fork합니다.
- fork한 repository를 여러분의 PC에서 git clone 명령으로 local repository에 복제합니다.
- IntelliJ IDE로 clone 받은 forked local repository 디렉토리의 프로젝트를 Open합니다.
- 아래 실습 요구 사항에 따라 실습을 진행합니다.
- 작성한 코드는 main branch에 작성해 주세요.
- main branch가 아닌 별도의 branch를 생성해서 작업을 했다면 작업이 끝난 후, 반드시 main branch로 merge 해야 합니다.
실습 종료 시 요청 사항
- 실습 코드 작성이 완료되었으면 아래의 실습 과제 제출 안내에 따라 실습 과제를 제출해 주세요.
실습 과제 내용
실습 1: StampCalculator 클래스에 대한 단위 테스트 케이스 작성
- 테스트 케이스 작성을 위한 설명
- 현재 StampCalculator (com.codestates.helper) 클래스를 JUnit 없이 단위 테스트 한 StampCalculatorTestWithoutJUnit 테스트 클래스가 아래 경로에 작성되어 있습니다.
- src/test/java/com/springboot/helper/StampCalculatorTestWithoutJUnit.java
- StampCalculatorTestWithoutJUnit 테스트 클래스에 작성되어 있는 두 개의 테스트 케이스를 JUnit을 사용한 테스트 케이스로 변경해 주세요.
- 실습 과제 작성을 위한 StampCalculatorTest 클래스는 아래 경로에 비어 있는 채로 생성되어 있습니다.
- src/test/java/com/springboot/homework/StampCalculatorTest.java
- StampCalculatorTest 내에 두 개의 테스트 케이스를 JUnit을 사용해서 작성하면 됩니다.
- 비어 있는 두 개의 테스트 케이스
- calculateStampCountTest()
- calculateEarnedStampCountTest()
- 비어 있는 두 개의 테스트 케이스
- 실습 과제 작성을 위한 StampCalculatorTest 클래스는 아래 경로에 비어 있는 채로 생성되어 있습니다.
- 현재 StampCalculator (com.codestates.helper) 클래스를 JUnit 없이 단위 테스트 한 StampCalculatorTestWithoutJUnit 테스트 클래스가 아래 경로에 작성되어 있습니다.
- 제한 사항
- 당연한 얘기지만 JUnit의 Assertion 메서드를 이용해야 합니다.
- 테스트 입력 값에 별도의 제한 사항은 없습니다.
- 여러분이 다양한 상황을 테스트해보세요.
실습 2: RandomPasswordGenerator 클래스의 단위 테스트 케이스 작성
- 테스트 케이스 작성을 위한 설명
- 여러분이 학습하고 있는 샘플 애플리케이션에는 아직 회원 로그인 기능은 없습니다. 하지만 로그인 인증과 관련된 학습 이후에 회원 로그인 기능이 추가될 수 있으며, 따라서 회원의 패스워드 역시 데이터베이스에 저장될 수 있습니다.
- RandomPasswordGenerator (com.springboot.helper) 클래스의 generate() 메서드는 회원이 로그인 패스워드를 분실했을 경우, 임시 패스워드를 랜덤으로 생성하는 기능을 합니다.
- generate(int numberOfUpperCaseLetters, int numberOfLowerCaseLetters, int numberOfNumeric, int numberOfSpecialChars) 메서드의 파라미터는 다음을 의미합니다.
- int numberOfUpperCaseLetters : 생성될 패스워드 문자열에서 알파벳 대문자의 개수
- int numberOfLowerCaseLetters: 생성될 패스워드 문자열에서 알파벳 소문자의 개수
- int numberOfNumeric: 생성될 패스워드 문자열에서 0 이상인 숫자의 개수
- int numberOfSpecialChars: 생성될 패스워드 문자열에서 특수문자의 개수
- generate(int numberOfUpperCaseLetters, int numberOfLowerCaseLetters, int numberOfNumeric, int numberOfSpecialChars) 메서드의 파라미터는 다음을 의미합니다.
- 다음 조건으로 JUnit을 사용해 RandomPasswordGenerator.generate() 메서드에 대한 테스트 케이스를 작성해 주세요.
- 검증해야 되는 조건
- 생성된 패스워드의 길이가 입력한 파라미터 숫자의 합과 일치하는지 검증(Assertion)하세요.
- 예) RandomPasswordGenerator.generate(2, 5, 2, 1)인 경우, 임시 패스워드의 길이는 10(2 + 5 + 2 + 1)입니다.
- 생성된 패스워드의 ‘알파벳 대문자’ 개수가 입력한 파라미터(numberOfUpperCaseLetters ) 숫자와 일치하는지 검증하세요.
- 생성된 패스워드의 ‘알파벳 소문자’ 개수가 입력한 파라미터(numberOfLowerCaseLetters) 숫자와 일치하는지 검증하세요.
- 생성된 패스워드의 ‘0 이상인 숫자’의 개수가 입력한 파라미터(numberOfNumeric) 숫자와 일치하는지 검증하세요.
- 생성된 임시 패스워드의 ‘특수문자’ 개수가 입력한 파라미터(numberOfSpecialChars) 숫자와 일치하는지 검증하세요..
- 생성된 패스워드의 길이가 입력한 파라미터 숫자의 합과 일치하는지 검증(Assertion)하세요.
- 검증해야 되는 조건
- 실습 과제 작성을 위한 RandomPasswordGeneratorTest 클래스는 아래 경로에 비어 있는 채로 생성되어 있습니다.
- src/test/java/com/springboot/homework/RandomPasswordGeneratorTest.java
- RandomPasswordGeneratorTest내의 비어 있는 한 개의 테스트 케이스를 JUnit을 사용해서 작성하면 됩니다.
- 비어 있는 한 개의 테스트 케이스
- generateTest()
- 비어 있는 한 개의 테스트 케이스
- 제한 사항
- JUnit의 Assertion 메서드를 이용해야 합니다.
- 여러분이 RandomPasswordGenerator.generate() 파라미터의 입력 값으로 다양한 숫자를 입력해서 테스트해보세요.
RandomPasswordGenerator
package com.springboot.helper;
import org.apache.commons.lang3.RandomStringUtils;
import java.util.Collections;
import java.util.List;
import static java.util.stream.Collectors.toList;
public class RandomPasswordGenerator {
public static String generate(int numberOfUpperCaseLetters,
int numberOfLowerCaseLetters,
int numberOfNumeric,
int numberOfSpecialChars) {
String upperCaseLetters = RandomStringUtils.random(numberOfUpperCaseLetters, 65, 90, true, false);
String lowerCaseLetters = RandomStringUtils.random(numberOfLowerCaseLetters, 97, 122, true, false);
String numbers = RandomStringUtils.randomNumeric(numberOfNumeric);
String specialChars = RandomStringUtils.random(numberOfSpecialChars, 33, 47, false, false);
String combinedLetters = combineLetters(upperCaseLetters, lowerCaseLetters, numbers, specialChars);
List<Character> shuffledLetters = shuffleLetters(combinedLetters);
return shuffledLetters.stream()
.collect(StringBuilder::new, StringBuilder::append, StringBuilder::append)
.toString();
}
private static List<Character> shuffleLetters(String combinedLetters) {
List<Character> shuffledLetters = combinedLetters.chars().mapToObj(c -> (char) c).collect(toList());
Collections.shuffle(shuffledLetters);
return shuffledLetters;
}
private static String combineLetters(String upperCaseLetters, String lowerCaseLetters, String numbers, String specialChars) {
return upperCaseLetters.concat(lowerCaseLetters).concat(numbers).concat(specialChars);
}
}
StampCalculatorTest
package com.springboot.homework;
import com.springboot.helper.StampCalculator;
import com.springboot.order.entity.Order;
import com.springboot.order.entity.OrderCoffee;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class StampCalculatorTest {
@Test
@DisplayName("실습1: 스탬프 카운트 계산 단위 테스트")
public void calculateStampCountTest() {
// TODO 여기에 테스트 케이스를 작성해주세요.
// given
int nowCount = 5;
int earned = 3;
int expected = 7;
// when
int actual = StampCalculator.calculateStampCount(nowCount, earned);
// then
assertEquals(expected, actual);
}
@Test
@DisplayName("실습1: 주문 후 누적 스탬프 카운트 계산 단위 테스트")
public void calculateEarnedStampCountTest(){
// TODO 여기에 테스트 케이스를 작성해주세요.
// given
Order order = new Order();
OrderCoffee orderCoffee1 = new OrderCoffee();
orderCoffee1.setQuantity(3);
OrderCoffee orderCoffee2 = new OrderCoffee();
orderCoffee2.setQuantity(5);
order.setOrderCoffees(List.of(orderCoffee1, orderCoffee2));
int expected = orderCoffee1.getQuantity() + orderCoffee2.getQuantity();
// when
int actual = StampCalculator.calculateEarnedStampCount(order);
// then
assertEquals(expected, actual);
}
}
RandomPasswordGeneratorTest
package com.springboot.homework;
import com.springboot.helper.RandomPasswordGenerator;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class RandomPasswordGeneratorTest {
@DisplayName("실습 3: 랜덤 패스워드 생성 테스트")
@Test
public void generateTest() {
// TODO 여기에 테스트 케이스를 작성해주세요.
// given
int numberOfUpperCaseLetters = 2;
int numberOfLowerCaseLetters = 5;
int numberOfNumeric = 2;
int numberOfSpecialChars = 1;
int expectedPasswordLength = numberOfUpperCaseLetters + numberOfLowerCaseLetters + numberOfNumeric + numberOfSpecialChars;
// when
String randomPassword = RandomPasswordGenerator
.generate(numberOfUpperCaseLetters,
numberOfLowerCaseLetters,
numberOfNumeric,
numberOfSpecialChars);
// then
// 조건1
assertEquals(expectedPasswordLength, randomPassword.length());
// 조건2
long expectedUpperCaseLetters = randomPassword.chars()
.filter(c -> Character.isUpperCase((char) c))
.count();
assertEquals(expectedUpperCaseLetters, numberOfUpperCaseLetters);
// 조건3
long expectedLowerCaseLetters = randomPassword.chars()
.filter(c -> Character.isLowerCase((char) c))
.count();
assertEquals(expectedLowerCaseLetters, numberOfLowerCaseLetters);
// 조건 4
long expectedNumeric = randomPassword.chars()
.filter(c -> Character.isDigit((char) c))
.count();
assertEquals(expectedNumeric, numberOfNumeric);
// 조건 5
// 특수문자 : "[!@#$%^&*(),.?\":{}|<>]"
// isLetterOrDigit : 문자 혹은 숫자인지 여부를 판단하여 true/false로 리턴
long expectedSpecialChars = randomPassword.chars()
// .filter(c -> "[!@#$%^&*(),.?\":{}|<>]".indexOf((char) c) >= 0)
.filter((c -> !Character.isLetterOrDigit((char) c)))
.count();
assertEquals(expectedSpecialChars, numberOfSpecialChars);
}
}