소개
이전 장에서 복잡한 분산 시스템을 만들었다. 세 개의 기능적인 마이크로서비스(UI, 곱셈, 게임화)와 이를 지원하는 두 개의 마이크로서비스(주울로 만든 API 게이트웨이와 유레카로 구현한 서비스 레지스트리)로 구성했다. 그리고 답안부터 점수까지 여러 마이크로서비스에 걸쳐 진행되는 비즈니스 프로세스를 충족시키기 위해 이벤트 중심 접근법을 적용했다.
이렇게 많은 구성 요소가 있다면 그중 하나가 실패할 가능성이 있다. 물론 일체형에서도 마찬가지지만, 마이크로서비스 아키텍처에서는 컴포넌트(마이크로서비스)가 독립적으로 빌드 및 배포되기 때문에 모든 구성 요소가 잘 동작하는지 확인하는 것이 훨씬 더 중요하다.
마이크로서비스별로 테스트 스위트(suite)를 만드는 것으로는 충분하지 않다. 수정 후에도 모든 엔드투엔드 유스 케이스가 동작하는지 확인할 전략이 필요하다.
마이크로서비스를 구축할 때 단위 테스트, 통합 테스트, 컴포넌트 테스트, 계약 테스트, 엔드투엔드 테스트까지 모두 사용해야 한다.
Testing Strategies in a Microservice Architecture
The microservice architectural style presents challenges for organizing effective testing, this deck outlines the kinds of tests you need and how to mix them.
martinfowler.com
모든 종류의 테스트 가운데 엔드투엔드 테스트는 피라미드의 최상위에 있는 테스트이다. 테스트는 유지하기 어렵기 때문에 많아져선 안 된다. 하지만 테스트가 모든 마이크로서비스를 통합하고 비즈니스 프로세스를 보호하기 때문에 양을 줄인다고 해서 중요도까지 낮아지는 것은 아니다.
이번 장에서는 마이크로서비스 아키텍처에 대한 엔드투엔드 테스트를 알아본다! 테스트를 쉽게 유지하고, 비즈니스 로직에 집중하기 위해 큐컴버(Cucumber) 프레임워크로 몇 가지 모범 사례를 다뤄보자.
엔드투엔드 테스트에도 TDD와 비슷한 전략을 사용한다. 먼저 완벽한 시나리오에 초점을 맞춘 다음 모든 것이 동작하는지 확인하기 위한 로직을 구현한다.
들어가며
애플리케이션 전체가 잘 동작하는지 확인하기 위해서 최소한 두 가지 기능을 확인해야 한다.
- 사용자가 애플리케이션에 요청을 보내면 알맞은 응답을 받고 정답 여부에 따라 점수를 얻는다.
- 리더보드는 사용자의 순위를 정확하게 반영한다.
이제 엔드투엔드 케이스를 어떤 레벨에서 구현할지 결정해야 한다. 우리 시스템은 모든 기능을 REST API로 제공하기 때문에 엔드투엔드 서비스 테스트로 진행한다. 엔드투엔드 서비스 테스트는 엔드투엔드 UI 테스트(예를 들면 셀레늄(Selenium)으로 구현할 수 있다)보다 훨씬 더 쉽게 관리할 수 있다. 왜냐하면 UI 테스트는 시스템의 추가 계층에 의존하고 있고, 변화에 매우 민감하기 때문이다(큐컴버 문서에 따르면 "UI를 통해 비즈니스 규칙을 검증하는 것은 느리고, 실패했을 경우 오류가 발생한 위치를 파악하기 어렵다.").
마지막으로 엔드투엔드 시나리오에 사용할 접근 방식과 기술을 선택해야 한다. 우리는 행위에 초점을 맞춘 강력한 테스트 도구인 큐컴버를 사용하려고 한다. 테스트 명세를 사람의 언어로 작성하면, 큐컴버가 테스트를 실행하는 스크립트로 변환하고 결과 보고서를 출력한다.
큐컴버를 통해 행위 주도 개발(Behavior-Driven Development)를 할 수도 있다. 코드의 다른 부분보다 먼저 엔드투엔드 테스트 시나리오를 작성하고, 테스트를 통과할 때까지 기능을 개발한다. 이미 코드를 작성했기 때문에 여기서 BDD를 완전히 사용할 순 없으나, BDD는 초기 단계부터 요구사항을 명확히 하고 문서화할 수 있는 좋은 방법이다.
Cucumber - Cucumber Documentation
You can help us improve this documentation. Edit this page.
cucumber.io
큐컴버는 많은 장점이 있다. 그중에서도 비즈니스 사용자가 테스트 시나리오를 잃고 직접 수정해서 어떻게 동작할지 결정할 수 있다는 점은 개발과 비즈니스 요구사항 사이의 간격을 없애주기 때문에 큐컴버의 최대 장점이다. 거킨(Gherkin)으로 작성하면 오해가 적고 바로 실행도 가능하다. 게다가 거킨 파일은 유스 케이스 문서로도 사용할 수 있다. 기능이 수정되면 테스트가 실패하기 때문에 문서가 확실히 유지되고 있다는 걸 알 수 있다.
큐컴버 사용해보기
큐컴버는 여러 언어와 프레임워크를 지원한다. 기능은 여러 개의 .feature 파일로 구성된다. 각 기능 상단에 기능에 대한 설명이 있고, 이 설명은 엔진에 의해 무시되는 부분이다. 기능은 테스트 케이스를 정의하는 여러 시나리오로 구성된다. 마지막으로 각 시나리오는 BDD 키워드(Given, When, Then, And, But)를 이용한 여러 스텝으로 정의된다(큐컴버를 한글로 사용하려면 먼저(조건), 만약(만일),그러면, 그리고, 하지만(단)으로 작성한다).
기능(또는 테스트 케이스 정의) 안의 각 시나리오는 캐시된 동일한 객체 내에서 실행된다. 테스트를 정확히 구현하기 위해서는 이 개념을 이해하는 것이 중요하다. 같은 시나리오의 스텝은 서로 상태를 공유할 수 있다. 하지만 다른 시나리오와는 상태를 공유할 수 없다.
즉, 스텝에서 여러 클래스를 사용하더라도 메모리에 일부 데이터를 저장할 수 있다는 뜻이다(예를 들어 클래스 필드를 사용한다거나). JUnit 테스트에서는 테스트 메서드별로 클래스가 인스턴스화되기 때문에 자바 개발자에게는 혼란스러울 수 있다.
같은 스텝 정의를 여러 시나리오에서 다른 매개변수를 넣어 사용할 수 있다. 데이터 테이블(Example)을 같은 시나리오에 전달할 수도 있다. 시나리오는 데이터 로우당 한 번씩 실행된다.
매개변수가 어떻게 동작하는지 이해하기 위해 스텝을 살펴보자.
multiplication.feature
만약 사용자 철수가 1개의 정답 답안을 제출한다.
원칙적으로는 거킨은 어떤 단어가 매개변수인지 알지 못한다. 우리는 코드 레벨에서 매개변수를 정의한다. 이 경우에는 사용자의 닉네임, 답안 횟수, 정답 여부를 테스트 스텝에 매개변수로 넘기려고 한다.
MultiplicationFeatureSteps.java
@먼저("^사용자 ([^\\s]+)가 (\\d+)개의 ([^\\s]+) 답안을 제출한다$")
public void 사용자가_정답을_제출한다(final String userAlias,
final int attempts,
final String rightOrWrong) throws Throwable {
// 로직 구현하기
}
문장 내 단어와 일치하는 정규 표현식으로 스텝을 구성하기만 하면 된다. 이 경우에는 두 단어 표현식과 숫자 표현식이 효과가 있을 것이다.
문장 내 단어(매개 변수가 아닌 단어)를 수정하면 메서드의 표현도 수정해야 한다. 다행히 대부분의 IDE는 플러그인을 통해 큐컴버와 통합할 수 있어, 코드 내 문장이 패턴에 일치하지 않으면 경고해 준다.
시나리오와 스텝에 대한 세부 사항이 포함된 기능을 실행하면 그 결과는 다양한 형식으로 출력된다. 큐컴버 특유의 보고서(색이 있는 거킨)뿐만 아니라 지속적인 통합 프레임워크에서 사용할 수 있는 표준 JUnit 보고서도 있다.
이제 실제로 우리 애플리케이션에 적용할 코드를 작성하자!
직접 코딩하기
이번 장 처음에 정의한 대로 두 가지 기능을 작성할 것이다. 첫 번째는 답안을 통한 상호 작용을 테스트하고, 두 번째는 리더보드의 기능을 테스트한다.
빈 프로젝트 만들고 도구 선택하기
- Cucumber: cucumber-jvm, 이 도구의 자바 구현체
- Cucumber JUnit: 큐컴버와 JUnit을 통합하는 도구
- Cucumber Picocontainer: 테스트에 종속성을 주입하는 기능으로, 리더보드 기능에서 사용
- JUnit: 자바 테스트를 위한 코어
- AssertJ: 검증을 자연스럽게 할 수 있는 방법을 제공
- Apache Fluent HttpClient: 애플리케이션의 REST API와 연결
- Jackson 2: JSON을 쉽게 역직렬화
테스트 프로파일
엔드투엔드 테스트 전략은 테스트 후 트랜잭션 롤백 같은 스프링 테스트의 기능을 사용할 수 없다. 스프링 테스트는 단일 스프링 애플리케이션 내에서 실행되는 모든 종류의 테스트에서 정말 유용하다(마이크로서비스 간 통합 테스트 포함). 하지만 여기서는 별로 유용하지 않은 이유가 있다.
- 블랙박스 테스트 접근법에 따르면 테스트는 각자 다른 프로젝트에서 실행되고 다른 애플리케이션과 REST 호출로 상호작용한다. 우리 마이크로서비스에서 테스트 애노테이션과 스프링 테스트의 일반적인 기능은 무시된다.
- 엔드투엔드 접근법에 따르면 데이터베이스의 실제 트랜잭션과 RabbitMQ를 통해 전달되는 메시지 등 인프라가 어떻게 동작하는지를 테스트한다.
- 마이크로서비스에 트랜잭션 롤백을 흉내 내는 프로필을 만들더라도 이 프로세스는 단일 트랜잭션이 아니다. 답안부터 점수까지의 프로세스는 곱셈 서비스가 답안을 데이터베이스에 저장하고 이벤트를 전송하는 하나의 트랜잭션과, 게임화 서비스가 이벤트를 소비해 새로운 점수를 계산하는 다른 트랜잭션이 더해진 것이다. 만약 첫 번째 트랜잭션이 종료 후 롤백한다면 두 번째는 실패한다.
따라서, 우리의 엔드투엔드 접근법에서는 더미 데이터를 처리하고 직접 정리해야 한다.
큐컴버 테스트 작성
큐컴버는 완전한 테스트 프레임워크가 아니라 단지 BDD 접근법에 따라 기능을 정의한 거킨과 자바 코드를 연결해 주는 도구다. 따라서, 테스트를 작성하기 위해서는 JUnit과 함께 가장 많이 사용되는 프레임워크 또는 라이브러리(AssertJ, TestNG, Mockito 등)를 필요에 따라 함께 사용해야 한다.
- MultiplicationFeatureSteps 클래스는 테스트의 메인 로직을 포함하고 동작을 조정하며 결과를 검증한다. 모든 스텝의 코드를 10줄 이하로 유지해야 한다.
- MultiplicationApplication 클래스는 노출되는 서비스(답안 전송, 통계 조회 등)를 모델링하기 위해 만든 클래스이다.
- ApplicationHttpUtils 클래스는 HTTP 요청을 수행하도록 지원하는 클래스로 예제 애플리케이션의 REST API로 접속할 수 있다.
실운영 준비: CI 시스템에서 Sleep를 사용하지 말자!
코드에서 스텝을 진행하기 전에 sleep() 메서드를 호출하는 부분이 몇 군데 있다. 다음 마이크로서비스가 이벤트를 소비하고 작업을 수행하는 데 시간이 걸리기 때문에 시스템의 일관성을 유지하기 위해 필요하다. 하지만 여기서는 설명을 위해 사용한 것이지 실제 CI 시스템에서는 사용하면 안 된다. 알 수 없는 에러를 발생시키는 원흉이 되기 쉽다(예를 들면 제시간에 응답하지 않는 복잡한 CI 머신일 경우).
이를 연습 삼아서 유효한 응답을 받거나 제한 시간이 만료될 때까지 REST를 여러 번 호출하는 재시도 메커니즘을 구현하는 것이 좋다.
테스트를 지원하는 클래스
큐컴버는 완전한 엔드투엔드 테스트 프레임워크는 아니다. 사람이 읽기 쉽게 기능 정의를 작성하고 이를 테스트 코드와 연결하는 역할을 한다. 엔드투엔드 테스트 전략을 충족하려면 몇 가지 로직을 추가해야 한다. 이 로직은 큐컴버나 스프링 부트의 로직이 아니기 때문에 MultiplicationFeatureSteps 클래스를 참고해서 전체 절의 나머지 코드를 직접 작성할 수 있다.
MultiplicationApplication 클래스는 시스템 동작을 모델링한다. 각 메서드는 REST를 소비해 수행할 수 있는 동작을 나타낸다. 이 클래스는 기본적으로 REST API로 JSON을 받고 Jackson을 이용해 일반 객체로 매핑한다. Stats, AttemptResponse, User, ScoreResponse, LeaderBoardPosition 클래스도 작성해야 한다.
ApplicationHttpUtils 클래스는 Apache HTTP Fluent API를 이용해 요청을 실행하고 API 게이트웨이에서 응답을 받아온다.
여러 기능에서 스텝 재사용하기
이전에 작성한 스텝을 재사용해 어떻게 시간을 절약하고 코드 중복을 줄이는지 살펴보자!
이제 리더보드 기능을 엔드투엔드 테스트하겠다. 두 가지 기본 시나리오를 테스트한다.
- 처음부터 시작하면 사용자가 다른 사용자보다 정답을 더 많이 제출하는 경우 순위의 첫 번째가 된다.
- 다른 사용자가 위에 있는 경우라면 더 높은 점수를 받아서 그 사용자보다 높은 순위로 올라갈 수 있다.
다음과 같이 feature 파일에 거킨으로 스토리를 작성할 수 있다.
# language: ko
기능: 사용자는 점수가 높은 순부터 낮은 순으로 목록에 올라 있다.
점수를 얻으면 순위가 올라갈 수 있다.
시나리오: 사용자가 정답 답안을 더 많이 제출하고 1등이 된다.
만약 사용자 철수가 2개의 정답 답안을 제출한다
그리고 사용자 영희가 1개의 정답 답안을 제출한다
그러면 사용자 철수가 리더보드에서 1등이 된다
그리고 사용자 영희가 리더보드에서 2등이 된다
시나리오: 사용자는 더 높은 점수를 얻으면 다른 사용자를 등수를 앞지른다
먼저 사용자 철수가 3개의 정답 답안을 제출한다
그리고 사용자 영희가 2개의 정답 답안을 제출한다
그리고 사용자 철수가 리더보드에서 1등이 된다
만약 사용자 영희가 2개의 정답 답안을 제출한다
그러면 사용자 영희가 리더보드에서 1등이 된다
그리고 사용자 철수가 리더보드에서 2등이 된다
보다시피 답안을 전송할 때 MultiplicationFeatureSteps 클래스에 정의한 스텝을 사용하고 있다. 여기서 까다로운 점은 새로운 스텝을 해당 클래스에 추가하고 싶지 않다는 점이다. 모든 기능과 스텝을 하나의 클래스에 넣기보다 정리하고 적절하게 나눠서 클래스가 너무 커지는 것을 막기 위함이다. 그래서 새로운 클래스인 LeaderboardFeatureSteps를 만드려고 한다. 하지만 그리고 나면 어떻게 LeaderboardFeatureSteps에 있는 기능과 데이터에 접근할 수 있을까? 답안 전송 단계의 결과는 클래스 내부(lastAttemptResponse)에 저장되므로 리더보드의 기능과 스텝에서는 접근할 수가 없다.
다행히 cucumber-picocontainer에서 제공하는 종속성 주입으로 해결할 수 있다. 큐컴버는 스텝을 포함한 모든 클래스별로 새로운 인스턴스를 생성하므로 생성자 주입을 이용해 큐컴버가 MultiplicationFeatureSteps 인스턴스를 LeaderboardFeatureSteps 인스턴스로 주입하게 할 수 있다. 이를 위해서는 스텝을 정의한 클래스를 생성자의 인자로 넘겨주기만 하면 된다.
새 클래스에 종속성을 주입해서, 이제 리더보드 관련 기능을 확인하는 스텝만 작성할 수 있다. 다음 코드에서 볼 수 있듯이 종속성 주입 동작에 어떠한 애노테이션도 필요하지 않다.
package mallang;
import cucumber.api.java.ko.그러면;
import mallang.testutils.beans.LeaderBoardPosition;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
public class LeaderboardFeatureSteps {
private MultiplicationFeatureSteps mSteps;
public LeaderboardFeatureSteps(final MultiplicationFeatureSteps mSteps) {
this.mSteps = mSteps;
}
@그러면("^사용자 ([^\\s]+)가 리더보드에서 (\\d+)등이 된다$")
public void 사용자가_리더보드에서_등수에_오른다(final String user, final int position) throws Throwable {
Thread.currentThread().sleep(500);
List<LeaderBoardPosition> leaderBoard = mSteps.getApp().getLeaderboard();
assertThat(leaderBoard).isNotEmpty();
long userId = leaderBoard.get(position - 1).getUserId();
String userAlias = mSteps.getApp().getUser(userId).getAlias();
assertThat(userAlias).isEqualTo(user);
}
}
리더보드에서 사용자의 위치를 확인하기만 하면 큐컴버 시나리오의 스텝이 다른 모든 작업을 해줄 것이다. 이를 위해 다음처럼 해당 러너와 큐컴버 옵션을 사용해 주요 테스트 클래스인 LeaderboardFeatureTest를 만들어야 한다.
package mallang;
import cucumber.api.CucumberOptions;
import cucumber.api.junit.Cucumber;
import org.junit.runner.RunWith;
@RunWith(Cucumber.class)
@CucumberOptions(plugin = {"pretty", "html:target/cucumber", "junit:target/junit-report.xml"},
features = "src/test/resources/leaderboard.feature")
public class LeaderboardFeatureTest {
}
테스트를 실행하고 보고서 확인하기
늘 그렇듯이 서비스를 시작해야 하지만 이번에는 게이트웨이(관리자 엔드포인트로 라우팅 하도록) 서비스, 곱셈 서비스, 게임화 서비스(테스트 데이터베이스를 사용하고 관리자 빈을 노출하기 위해)에 테스트 프로파일을 활성화해야 한다.
실행 순서를 정리해 보자.
- RabbitMQ 서버를 실행
- 서비스 레지스트리 마이크로서비스 실행(프로파일 없음)
- 게이트웨이 마이크로서비스 실행(테스트 프로파일)
- 곱셈 마이크로서비스 실행(테스트 프로파일)
- 게임화 마이크로서비스 실행(테스트 프로파일)
- 제티 웹 서버 실행(단, UI는 테스트하지 않으므로 실행하지 않아도 됨)
[IntelliJ] Commuity 버전에서 스프링부트 active profile 설정하기
스프링 부트로 개발을 하다보면, 기본 active profile 외에 다른 profile로 실행을 해야할 때가 있다. Ultimate의 경우 이에 대한 설정을 지원하는데, Community 버전은 별도로 VM Option을 추가해야한다.-Dspring
progfrog.tistory.com
메인 테스트 클래스에서 사용한 @CucumberOptions 애노테이션 덕분에 큐컴버 보고서와 JUnit XML 보고서가 지정된 폴더에 생성된다. 이러한 결과를 CI 시스템에 배포하면 중앙에서 볼 수 있다.
정리
이번 장에서는 분산 시스템에서 좋은 테스트 스위트가 왜 중요한지 살펴봤다. 모든 계층이 중요하지만, 엔드투엔드 접근법은 일반적으로 유지보수가 힘들고 복잡하다.
간단하면서도 강력한 엔드투엔드 전략을 만들기 위해 큐컴버를 활용해 테스트 프로젝트를 레이어로 구성했다. 큐컴버를 이용해 비즈니스 친화적인 언어(거킨)로 테스트를 설계하고 자바와 통합할 수 있어서 우리의 사례에 잘 맞아떨어졌다!
또한 개발을 쉽게 만들어주는 세부사항도 살펴봤다. 스텝에 파라미터를 적용하고, 스텝을 재사용하고, 큐컴버가 테스트를 인스턴스화하는 방법을 이해하고, 종속성 주입을 활용했다. 그리고 가장 중요한 것은 몇 가지 로직을 서비스에 추가함으로써 테스트를 더 쉽게 만드는 방법도 배웠다.
이제 시스템의 각 부분이 제대로 동작하는 것뿐만 아니라 전반적인 비즈니스 사례까지 확인함으로써 시스템 전체를 테스트할 수 있게 되었다. 이 같은 프레임워크는 다른 방법으로는 얻기 어려운 안정성을 추가로 제공하기 때문에 프로젝트가 성숙해질수록 더 유용하게 사용할 수 있다.