pr: https://github.com/GIST-Petition-Site-Project/GIST-petition-server/pull/174 (글에는 이 pr 이후 추가 수정된 코드도 담고있습니다)
+ 일명 '테스트에 미친남자'의 코드를 참고하였다. 백엔드 팀원의 지인인데 진짜 테스트에 진심이더라... 배우자 배우자 배우자 많이 배우자!
필자가 이해한 인수테스트란 다양한 user story 를 서비스가 잘 실현하는가를 api 콜에 직접 요청하는 전구간(End-to-End) 테스트이다. 배포를 할 수 있을 정도의 사용성이 보장되는지 살피는 것이 큰 목적이며, Black-box 테스트, 즉 interface가 아닌 내부 로직에 의존하지 않아야 한다.
인수테스트용 라이브러리로 rest-assured를 이용한다. @SpringBootTest 어노테이션을 통해 띄워진 서버에 직접 api 요청을 날리는 방식으로 사용할 수 있어, 프레젠테이션 계층과 서비스, 데이터 계층까지 한 번에 테스트 할 수 있기에 선택하였다.
+ rest assured 라이브러리의 maven repository 의존성 최신 버전은 4.5.x 이다. 하지만 4.5.x 에서 rest-assured:4.5.x 에 기본으로 포함되어 있어야 할 xml-path, json-path 버전이 4.5.x 가 아닌 4.3.x 로 예상과는 다르게 추가되어있음을 확인하여 4.4.x 버전을 gradle 의존성으로 추가하였다.
rest-assured 를 이용하기 위해선 @SpringBootTest의 webEnvironment 속성을 이용해 서버를 띄워야 한다. webEnvironment 속성에 RANDOM_PORT 를 넣으면 ApplicationContext를 로드하여 랜덤 포트번호의 실제 웹 서버 환경을 구축한다. (ApplicationContext는 모든 Bean을 띄워서 가지고 있다.)
모든 인수테스트는 @SpringBootTest 와 webEnvironment 설정을 가져야 하므로, AcceptanceTest 라는 추상 클래스를 모든 인수테스트 클래스의 부모 클래스로 둔다.
// AcceptanceTest.java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public abstract class AcceptanceTest {
@LocalServerPort
int port;
@BeforeEach
void setUp() {
RestAssured.port = port;
}
}
Enum 클래스에 테스트에 사용할 사용자를 미리 정의해둔다.
각 사용자는 api 요청에 필요한 정보 (username, password, userId, jsessionid) 를 가지고 있다 (id 는 회원가입, jsessionid 는 로그인시 값이 추가된다).
또한 User 가 로그인 되기 전에 요청할 수 있는 요청 (회원가입, 로그인) 이 메서드로 작성되어 있다. 회원가입은 총 세번의 api 콜이 필요하여 해당 요청들 또한 메서드로 분리하였다.
// TUser.java - 일부 메서드
public enum TUser {
T_ADMIN("testAdmin@gist.ac.kr", "admin"),
GUNE("gune@gm.gist.ac.kr", "gune"),
EUNGI("handsomeGuy@gm.gist.ac.kr", "It's me!"),
WANNTE("wannte@gm.gist.ac.kr", "wannte"),
KOSE("kose@gist.ac.kr", "kose");
private final String username;
private final String password;
private Long id;
private String jSessionId;
TUser(String username, String password) {
// 생략
}
public void doSignUp() {
// psuedo code
...
getVerificationCodeWith()
confirmVerificationCodeWith()
doRegisterWith()
...
}
public Response getVerificationCodeWith(String username) {
// 생략
}
public Response confirmVerificationCodeWith(String username, String verificationCode) {
// 생략
}
public Response doRegisterWith(String username, String password, String verificationCode) {
// 생략
}
public void doLogin() {
// 생략
}
}
LoginAndThenAct 클래스는 TUser 가 로그인을 한 뒤 요청할 수 있는 api 콜(현재는 '청원생성', '답변생성', '유저 role 업데이트')을 정의해두었다. 멤버로 TUser 를 가지고 있어서 요청에 필요한 정보를 가져올 수 있다. 필요한 API 콜은 해당 시나리오를 구현할 때마다 구현하는 방식으로 사용하고 있다.
// LoginAndThenAct.java
public class LoginAndThenAct {
private final TUser tUser;
LoginAndThenAct(TUser tUser) {
this.tUser = tUser;
}
public Response createAnswer(Long petitionId, AnswerRequest answerRequest) {
// 생략
}
public Response createPetition(PetitionRequest petitionRequest) {
// 생략
}
public LoginAndThenAct updateUserRoleAndThen(TUser target, UserRole userRole) {
// 생략
}
}
인수테스트를 만들며 신경썼던 부분은 테스트의 가독성과 일관성 이다. TUser 에 등록된 사용자가 어떤 흐름으로 요청을 보내는지 한 눈에 확인하기 위해선 가독성을, 수많은 api 콜을 메서드화 하여 구현해야하므로 코드의 일관성을 유지하려 한다.
이를 위해 각 메서드의 이름으로 controller메서드명 + 용도 을 사용하는 컨벤션을 만들어 지키려 했고, 리턴값을 TUser 나 LoginAndThenAct 를 뱉게 하여 메서드 체이닝을 구현하려 했으며, 응답의 header, body 정보가 필요한 경우엔 Response 를 직접 반환하게 구현하였다. 또, RequestDto 가 아닌, dto 를 구성하는 정보를 직접 전달하는 메서드는 With 라는 접미사를 붙여 이름지었다.
청원 생성 api 를 예시로 들어보자
// 컨트롤러
@LoginRequired
@PostMapping("/petitions")
public ResponseEntity<Void> createPetition(@Validated @RequestBody PetitionRequest petitionRequest,
@LoginUser SimpleUser simpleUser) {
// 생략
return ResponseEntity.created(URI.create("/petition/" + petitionId)).build();
}
// return Response, dto
Response createPetition(PetitionRequest pr) {
return given().
contentType(ContentType.JSON).
cookie("JSESSIONID", tUser.getJSessionId()).
body(petitionRequest).
when().
post("/v1/petitions");
}
// return LoginAndThenAct, dto
public LoginAndThenAct createPetitionAndThen(PetitionRequest pr) {
createPetition(pr).
then().
statusCode(HttpStatus.CREATED.value());
return this;
}
// return Response, With
public Response createPetitionWith(String title, String description, Long categoryId) {
PetitionRequest petitionRequest = new PetitionRequest(title, description, categoryId);
return createPetition(petitionRequest);
}
메서드 체이닝을 통해 가독성을 챙긴 테스트 코드는 다음과 같다.
// 메서드 체이닝을 통해 가독성을 챙긴 테스트 코드 예시
...
GUNE.doLoginAndThen().createPetitionAndThen().createAnswer();
...
+ RequestDto 를 파라미터로 넘겨주는 메서드가 진짜 필요한지는 아직 고민중이다
api 콜에서 응답에 대한 검증은 Http.statusCode 의 비교로 이루어진다. 요청에 대한 처리가 비정상적으로 이루어지면 미리 정의한 예외에 따라 StatusCode와 함께 예외 내용이 주어지는데, 우리가 기대한 status code 와 같지 않으면 예외를 일으킨것으로 간주한다.
(예외 코드에 대한 내용은 220111 CustomException 리팩토링 참조)
체이닝할 수 있는 메서드와 그렇지 않은 메서드의 차이는 내부에서의 응답검증 여부다. AndThen 메서드는 내부에서 응답을 검증한다. 해당 메서드는 당연히 성공하리라 간주하고 테스트 코드를 짜는 식이다. 반면, Response를 뱉는 메서드는 응답을 그대로 반환하여 반환값을 가지고 직접 검증을 진행한다. 실패하는 테스트를 작성할 때 유용하다.
응답 검증은 Response에 체이닝되는 then()
구문을 사용한다. then에 체이닝된 메서드는 Assertion 을 위한 메서드로, then().statusCode(401)
은 응답코드가 401임을 기대함을 의미한다.
//응답 검증 코드 예시
GUNE.doLoginAndThen().createPeition().
then().
statusCode(HttpStatus.CREATED.value());
+ 다만, 성공을 가정한 모든 테스트 코드에 AndThen 메서드를 사용하는 점은 썩 맘에 들지 않는다. 테스트의 끝이 \_AndThen();
으로 끝나면 어색하다..
인수테스트도 결국 데이터를 DB에 넣고 조회하는 과정이므로, 테스트 후 DB를 정리해야 한다. 현재는 각 인수테스트마다 직접 entity repository 를 주입받아서 데이터를 정리하는데, 그떄그때 직접 지워줘야 하므로 상당히 불편하며 실수할 가능성도 높아진다 (사람을 믿는 코드는 안전하지 않다....)
// CreatePetitionAcceptanceTest.java
...
@AfterEach
void tearDown() {
TUser.clearAll();
petitionRepository.deleteAllInBatch();
// User 테이블은 왜 안지울까요? 아래 나와요...
}
위처럼 TUser 에 사용자를 등록해놓고 테스트마다 회원가입을 하게 만들면 'Admin' 등급을 가진 사용자가 존재할 수 없다. 우리 서비스에서는 회원가입 시 무조건 User 등급으로 등록되고, 미리 등록되어있는 ADMIN 이 필요에 따라 Manager나 Admin으로 승급하는 구조이기 때문이다.
하지만, ADMIN의 등록은 dev 나 prod 프로파일에서만 이루어지므로 인수테스트만을 위한 DataLoader가 필요하게 된다. (dev&prod DataLoader 는 ADMIN 등록 외에도 다른 작업을 수행하므로 분리가 필요하다)
미리 등록된 ADMIN이 필요한 테스트는 인수테스트 뿐이다. 따라서 뿌리 클래스인 AcceptanceTest.class 를 acceptance 프로파일로 선언하고 ADMIN을 미리 생성하는 DataLoader 를 accepatnce 프로파일에서만 동작하도록 선언한다.
(@ActiveProfiles Docs: 부모 클래스에 선언된 프로파일이 그 자식 클래스에 상속됨을 알 수 있다 (Spring 5.3 이상))
// AcceptanceTest.java
@ActiveProfiles("acceptance") // 프로파일 선언
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public abstract class AcceptanceTest {
// 생략
}
인수테스트를 위해 등록된 ADMIN을 T_ADMIN이라 명명한다.
// TestDataLoader.java
import static com.gistpetition.api.acceptance.common.TUser.T_ADMIN;
@Profile("acceptance") // acceptance 프로파일에서만 사용
@Component
public class TestDataLoader implements CommandLineRunner {
private final UserRepository userRepository;
private final Encoder encoder;
TestDataLoader(UserRepository userRepository, Encoder encoder) {
this.userRepository = userRepository;
this.encoder = encoder;
}
@Override
public void run(String... args) {
userRepository.save(new User(T_ADMIN.getUsername(), encoder.hashPassword(T_ADMIN.getPassword()), UserRole.ADMIN));
}
}
이제 준비된 재료들을 살짝식 버무려서 청원 생성 시나리오를 구현해보자.
회원가입 -> 로그인 -> 청원생성 시나리오
// CreatePetitionAcceptanceTest.java
public class CreatePetitionAcceptanceTest extends AcceptanceTest {
@Autowired
PetitionRepository petitionRepository;
@Autowired
UserRepository userRepository;
@Test
void createPetitionByNormal() {
KOSE.doSignUp();
PetitionRequest petitionRequest = new PetitionRequest("title", "description", Category.ACADEMIC.getId());
Response createPetition = KOSE.doLoginAndThen().createPetition(petitionRequest);
assertThat(createPetition.statusCode()).isEqualTo(HttpStatus.CREATED.value());
assertThat(createPetition.header(HttpHeaders.LOCATION)).contains("/petitions/");
}
@Test
void createPetitionByManager() {
WANNTE.doSignUp();
T_ADMIN.doLoginAndThen().updateUserRoleAndThen(WANNTE, UserRole.MANAGER);
PetitionRequest petitionRequest = new PetitionRequest("titleOver10Characters", "description", Category.ACADEMIC.getId());
Response createPetition = WANNTE.doLoginAndThen().createPetition(petitionRequest);
assertThat(createPetition.statusCode()).isEqualTo(HttpStatus.CREATED.value());
assertThat(createPetition.header(HttpHeaders.LOCATION)).contains("/petitions/");
}
@AfterEach
void tearDown() {
TUser.clearAll();
petitionRepository.deleteAllInBatch();
}
}
가장 처음 보이는 문제점은 필요한 API 콜 메서드를 그때그때 구현해야 한다는 점과, 메서드간 given()
으로 시작하는 호출 구문이 겹친다는 점이다. 이를 해결하기위한 테스트 클래스 계층화가 필요하다고 생각한다.
또 하나, 테스트 후 데이터 클리너 부분이다. 원칙상으론 한 테스트에서 회원가입된 TUser를 데이터베이스에서 지워버려야 하는데, 이를 위해 UserRepository.deleteAllInBatch()
를 사용하면 T_ADMIN 까지 지워진다는 문제가 있다. 이미 존재하는 사용자가 다시 회원가입을 하면 테스트가 터지는것은 아니고 결국 같은 결과를 만들어내긴 하지만, 테스트간 데이터 정합성을 지키기 위해선 해결방안을 강구해야한다.
처음으로 인수테스트에 대해 공부하고, 구현해보았다. 잘 만든 테스트의 필요성이 느껴졌고, 그에 대한 갈망이 생기더라. 리팩토링해야돼 리팩토링...