4. ATDD (인수 테스트 주도 개발)
이 글은 내가 우아한테크캠프에서 배웠던 내용들을 복습하기 위해 진행하는 토이프로젝트(https://github.com/minseokLim/woowahan-tech-camp-review)를 중심으로 작성되었다.
1. ATDD란?
TDD(Test Driven Development)는 많이 알려져 있는 개발 방법론이고, 나 또한 우테캠을 듣기 이전에 이에 대해 어느 정도는 알고 있었다. 하지만 ATDD라는 건 처음 들어봤다. ATDD란 'Acceptance Test Driven Development'의 약자로, 한국말로 하면 '인수 테스트 주도 개발'이다. ATDD는 TDD와 밀접한 관계가 있지만 차이가 있다. ATDD는 고객, 개발자, 테스터 간의 커뮤니케이션을 기반으로 하는 개발 방법론으로써, 고객-개발자-테스터 간의 협업을 강조한다는 점에서 TDD와는 다르다. TDD와 비슷하게, 개발자는 코딩을 시작하기 전 인수 테스트를 먼저 작성해야 한다. 그렇다면 인수테스트란 무엇일까?
인수테스트는 고객이 제품을 인수하기 전, 명세나 계약의 요구사항이 모두 충족되었는지 확인하기 위해 수행되는 테스트이다. 비단 소프트웨어뿐만 아니라 다른 분야에서도 사용되는 용어다. 보통 마지막 단계에서 수행되는 테스트를 의미하며, 이 테스트가 성공한다면 작업이 끝났다는 것을 의미하기도 한다.
그렇다면 통합 테스트나 E2E 테스트가 인수 테스트라고 생각할 수도 있겠지만 꼭 그렇진 않다. 이는 테스트의 의도나 구현 방법에 의해 얼마든지 달라질 수 있다.
2. ATDD의 장점
1) 개발을 시작하기 전, 요구사항을 명확히 함으로써 고객, 개발자, 테스터 간의 빠른 피드백을 받을 수 있다.
2) 개발을 시작하기 전, 인수 테스트 코드를 먼저 작성함으로써 구현할 대상에 대한 이해도를 높힐 수 있다.
3) 작업의 시작과 끝이 명확해진다. 인수 테스트가 통과한다면? -> 기능 개발이 끝난 것이다.
4) 리팩토링을 부담없이 할 수 있다. 많은 레거시 코드에서의 비즈니스 로직에는, 테스트하기 쉬운 부분과 어려운 부분이 뒤섞여있다. 따라서 레거시 코드를 리팩토링할 때 많은 어려움이 있다. 이때 인수 테스트 코드를 작성해놓는다면, 인수 테스트 코드의 보호 아래 기존 기능을 망가뜨리지 않고 리팩토링과 기능 추가를 자유롭게 할 수 있다.
3. 인수 테스트 규칙
인수 테스트 코드를 작성할 때는 아래의 규칙들을 준수해야 한다.
1) 코딩을 시작하기 전에 인수 테스트를 먼저 작성한다. 이후 TDD 사이클을 통해 개발을 진행한다.
2) 인수 테스트 코드는, 내부 구현이나 기술에 의존적이지 않는 블랙 박스 테스트로 작성한다. 쉽게 말하자면, 인수 테스트 코드 import 문에 프로덕션 코드가 없으면 된다.
4. 인수 테스트 구현
위에서 말했던 것 처럼, 인수 테스트의 방식은 상황이나 의도에 따라 여러 가지 형태를 가질 수 있다. 하지만 여기서는 고객은 프론트엔드 개발자 혹은 API 사용자로 설정하고, API 접점에서 테스트하는 E2E 테스트를 인수 테스트로 작성하였다. 테스트를 위한 라이브러리로는 RestAssured를 사용하였다.
먼저 모든 인수 테스트 코드에 사용할 공통 클래스인 AcceptanceTest 를 생성한다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
public class AcceptanceTest {
@LocalServerPort
int port;
@Autowired
private DatabaseCleanup databaseCleanup;
@BeforeEach
public void setUp() throws Exception {
RestAssured.port = port;
databaseCleanup.execute();
}
}
@Service
@ActiveProfiles("test")
public class DatabaseCleanup {
private final NamedParameterJdbcTemplate jdbcTemplate;
public DatabaseCleanup(final DataSource dataSource) {
this.jdbcTemplate = new NamedParameterJdbcTemplate(dataSource);
}
@Transactional
public void execute() {
final List<String> tableNames = extractTableNames();
execute("SET REFERENTIAL_INTEGRITY FALSE");
for (final String tableName : tableNames) {
execute("TRUNCATE TABLE " + tableName);
}
execute("SET REFERENTIAL_INTEGRITY TRUE");
}
private List<String> extractTableNames() {
return jdbcTemplate.query("SHOW TABLES", (resultSet, rowNumber) -> resultSet.getString(1));
}
private void execute(final String query) {
jdbcTemplate.update(query, Collections.emptyMap());
}
}
SpringBootTest는 ApplicationContext를 전부 올리는 테스트인데, 이는 꽤나 큰 부하이기 때문에 여러 개의 SpringBootTest가 있을 경우 ApplicationContext를 공유하게 된다. h2 데이터베이스도 마찬가지로 공유하게 되는데, 이렇게 되면 한 테스트가 다른 테스트에 영향을 줄 수 있다. 이를 방지하기 위해 DatabaseCleanup 클래스는, 모든 테스트가 시작하기 전, 데이터베이스의 모든 데이터를 삭제해주는 역할을 한다.
Transactional 어노테이션 붙혀주면 롤백되는 거 아닌가라고 생각할 수 있다. 나도 처음엔 그렇게 생각했다. 하지만 그렇지가 않더라... RestAssured는 테스트 시, 어플리케이션을 톰캣 위에 올려서 테스트 한다. 즉 실제와 똑같은 환경에서 테스트를 진행하기 때문에 테스트 코드에서 시작한 Transaction을 전파(Propagation)할 수가 없다.
인수 테스트를 적용해볼 기능 목록은 아래와 같다.
* 로또
* [ ] 로또를 구매한다.
* [ ] 입력 값으로 로또 구입 금액, 수동으로 입력된 로또의 리스트를 받는다.
* [ ] 구입 금액이 1000미만이거나 1000으로 나누어 떨어지지 않거나 숫자가 아닐 경우, 예외를 발생시킨다. (로또 1장 가격은 1000원)
* [ ] 수동으로 입력된 로또의 수가, 앞서 입력한 구입금액으로 살 수 있는 로또 수의 최댓값을 초과할 경우, 예외를 발생시킨다.
* [ ] 수동으로 입력된 로또들의 번호는 1~45까지의 중복되지 않는 숫자 6개로 이루어져 있어야 한다. 그렇지 않을 경우 예외를 발생시킨다.
* [ ] 구입 금액에서 수동으로 입력된 로또의 수를 제외한 나머지에 해당하는 수만큼 자동으로 로또가 생성되어 구매된다.
* [ ] 토요일 오후 8시 ~ 9시에는 구매가 불가능하다.
* [ ] 회차별 구매 내역과 구매한 로또들이 DB에 저장된다.
* [ ] 로또의 번호들은 ','를 구분자로 하는 오름차순으로 정렬된 문자열로 저장된다.
* [ ] 로또의 회차는 매주 토요일 오후 9시 자동으로 증가한다.
* [ ] 관리자는 회차별로 당첨번호를 입력할 수 있다.
* [ ] 당첨 번호는 1~45까지의 중복되지 않는 숫자 6개와 보너스 번호로 이루어져 있어야 한다. 그렇지 않을 경우 예외를 발생시킨다.
* [ ] 해당 회차의 당첨번호가 이미 입력되었을 경우 예외를 발생시킨다.
* [ ] 로또 당첨 결과를 조회한다.
* [ ] 결과에는 미추첨, 낙첨, 당첨번호 3개 일치, 당첨번호 4개 일치, 당첨번호 5개 일치, 당첨번호 5개 일치 & 보너스번호 일치, 당첨번호 6개 일치가 있다.
* [ ] 미추첨이란, 현재 시간이 아직 해당 회차의 당첨결과 조회 가능 시간(매주 토요일 오후 9시)이 되지 않았거나 아직 당첨번호가 입력되지 않은 상태이다.
이에 따른 구현 결과는 아래와 같다.
class LottoAcceptanceTest extends AcceptanceTest {
private String accessToken;
@Override
@BeforeEach
public void setUp() throws Exception {
super.setUp();
// given
final String loginId = "test1234";
final String password = "password1234";
final var user = new HashMap<String, Object>();
user.put("loginId", loginId);
user.put("password", password);
user.put("nickName", "테스트계정");
user.put("email", "test@test.com");
사용자_생성_요청(user);
accessToken = 로그인_요청(loginId, password).jsonPath().get("accessToken");
}
@Test
void 로또() {
// given
final var lotto = new HashMap<String, Object>();
lotto.put("payment", 10000);
lotto.put("manualLottos", List.of(List.of(1, 2, 3, 4, 5, 6), List.of(45, 44, 43, 42, 41, 40)));
// when
final var buyResponse = 로또_구매_요청(lotto, accessToken);
// then
로또_구입됨(buyResponse);
// given
final var winningResult = new HashMap<String, Object>();
winningResult.put("winningNumbers", List.of(1, 2, 3, 4, 5, 6));
winningResult.put("bonusNumber", 7);
// when
final var postWinningResultResponse = 관리자가_당첨번호_입력_요청(winningResult, ADMIN_TOKEN);
// then
당첨번호_입력됨(postWinningResultResponse);
// when
final var winningResultResponse = 당첨결과_조회_요청(accessToken);
// then
당첨결과_조회됨(winningResultResponse);
}
}
public interface LottoAcceptanceTestFixture {
static ExtractableResponse<Response> 로또_구매_요청(final Map<String, Object> lotto, final String accessToken) {
return RequestUtil.postWithAccessToken("/lottos", accessToken, lotto);
}
static void 로또_구입됨(final ExtractableResponse<Response> response) {
assertHttpStatusCode(response, HttpStatus.SC_CREATED);
assertThat(response.header("Location")).isNotNull();
}
static ExtractableResponse<Response> 관리자가_당첨번호_입력_요청(
final Map<String, Object> winningResult,
final String accessToken
) {
return RequestUtil.postWithAccessToken("/winning-results", accessToken, winningResult);
}
static void 당첨번호_입력됨(final ExtractableResponse<Response> response) {
assertHttpStatusCode(response, HttpStatus.SC_CREATED);
assertThat(response.header("Location")).isNotNull();
}
static ExtractableResponse<Response> 당첨결과_조회_요청(final String accessToken) {
return RequestUtil.getWithAccessToken("/lottos/me", accessToken);
}
static void 당첨결과_조회됨(final ExtractableResponse<Response> response) {
assertHttpStatusCode(response, HttpStatus.SC_OK);
}
}
코드를 보고 여러 의문점들이 생길 수 있다. 하나씩 정리해보자.
1) 하나의 테스트 코드가 여러개를 검증한다.
원래는 하나의 테스트 코드는 하나만 검증하는 게 원칙이다. 하지만 그렇게 되면 중복 코드가 많이 생기게 될텐데, SpringBootTest는 그렇게 구성하기엔 부하가 많이 걸리는 테스트이다. 따라서 사용자의 사용 시나리오를 기반으로 Happy Case에 대해서만 인수 테스트를 작성한다.
2) 기능 목록에는 예외 상황들이 많은데, 이에 대한 테스트 코드가 하나도 없다.
위와 같은 이유이다. 예외 상황에 대한 테스트까지 추가한다면 시간이 너무 오래 걸릴 것이다. 따라서 인수 테스트에서는 Happy Case에 대해서만 작성하고, 예외 상황에 대해서는 단위 테스트에서 검증하도록 한다.
3) 메서드가 모두 한글로 작성되어있다.
물론 테스트 코드에선 한글로 많이 작성하기도 한다. 인수 테스트에선 더욱 이렇게 하는 것이 좋은데, 그 이유는 인수 테스트는 오직 개발자만을 위한 것이 아니라 고객, 개발자, 테스터 간의 커뮤니케이션을 위한 것이기도 하기 때문이다.