📌 이 포스팅은 컨퍼런스 신청 플랫폼 테스트 코드를 작성할 때 세웠던 기준을 다루고 있어요.

1. 인수테스트(Acceptance Test)
사용자의 관점에서 올바르게 작동하는지 테스트하는 것으로, 인수 요건이 모두 충족되었는지 확인하는 테스트입니다.
여기서 이야기하는 인수테스트란 백엔드 API 전구간 테스트를 의미합니다. 실제 데이터베이스에 값을 저장하고 조회하는 것까지 테스트 범위입니다.
A. 인수조건을 테스트로 옮기기
Feature: 간략한 기능 서술
Scenario: 시나리오(예시) 제목
Given: 사전조건
When: 발생해야하는 이벤트
Then: 사후조건
--
And: 앞선 내용에 추가적인 내용 기술
Feature: Access Token 갱신 기능
Scenario: Access Token 만료일을 연장한다.
Given 만료된 Access Token을 생성한다.
When Access Token의 만료일을 갱신한다.
Then 유효한 Access Token이 조회된다.
구체적인 행위를 검증하기보다는 비즈니스 규칙을 검증해야 합니다.
비즈니스 규칙이 명확히 드러나도록 테스트 시나리오를 작성하면, 구현과 관련 기술이 변경되어도 테스트 시나리오를 변경하지 않아도 됩니다.
public class BusinessAcceptanceTest extends AcceptanceTest {
@Test
@DisplayName("Access Token 만료일을 연장한다")
public void renew() {
// given
final var client = 토큰_생성("NEXTSTEP", LocalDateTime.now());
// when
final var clientName = "다른이름";
final var response = 토큰_갱신(
client.getAccessToken(),
clientName,
LocalDateTime.now().plus(12, ChronoUnit.MONTHS)
);
// then
final Boolean status = 토큰_상태_확인(client.getAccessToken());
assertThat(status).isTrue();
assertThat(response.getAccessToken()).isEqualTo(client.getAccessToken());
assertThat(response.getClientName()).isEqualTo(clientName);
assertThat(response.getExpiredAt().isAfter(client.getExpiredAt())).isTrue();
}
}
B. 인수테스트 환경
a. 인수테스트 클래스 설정
@ActiveProfiles(value = "test")
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class AcceptanceTest extends AbstractContainerBase {
protected AcceptanceTest() {
}
@LocalServerPort
private int serverPort = 0;
@Autowired
private DatabaseCleanup databaseCleanup;
@Autowired
private BusinessRepositoryImpl businessRepository;
public static BusinessDto ADMIN;
@BeforeEach
void setUp() {
RestAssured.port = serverPort;
RestAssured.config = RestAssuredConfig.config()
.objectMapperConfig(getObjectMapperConfig());
databaseCleanup.execute();
}
}
실제 서버가 아닌 테스트를 위한 서버를 띄우기 위한 설정으로, 실제 요청이 아닌 인수테스트의 요청을 받기 위한 구성입니다.
@SpringBootTest 를 추가하여 테스트를 위한 웹 서버를 이용합니다. (모든 Bean 등록)
@Slf4j
public class AbstractContainerBase {
private static final DockerImageName MYSQL_DOCKER_IMAGE = DockerImageName
.parse("mysql:5.7")
.asCompatibleSubstituteFor("mysql:5.7");
static final MySQLContainer<?> MYSQL_CONTAINER;
static {
MYSQL_CONTAINER = new MySQLContainer<>(MYSQL_DOCKER_IMAGE)
.withDatabaseName("nextstep")
.withUsername("nextstep-local")
.withPassword("nextstep-local")
.withCommand("mysqld", "--character-set-server=utf8mb4");
MYSQL_CONTAINER.start();
log.info("mysql container: {}", MYSQL_CONTAINER);
}
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
if (MYSQL_CONTAINER.isRunning()) {
registry.add("spring.datasource.hikari.driver-class-name", MYSQL_CONTAINER::getDriverClassName);
registry.add("spring.datasource.hikari.jdbc-url", MYSQL_CONTAINER::getJdbcUrl);
registry.add("spring.datasource.hikari.username", MYSQL_CONTAINER::getUsername);
registry.add("spring.datasource.hikari.password", MYSQL_CONTAINER::getPassword);
}
}
}
Database는 h2를 사용해도 되나, 운영과 최대한 유사한 환경 구성을 위해 TestContainer를 활용하여 MySQL 컨테이너를 활용합니다.(사용자의 로컬 환경에 따라 비정상 동작되는 부분이 있어 제거)
@Service
public class DatabaseCleanup implements InitializingBean {
@Autowired
private EntityManager entityManager;
private List<String> tableNames;
@Override
public void afterPropertiesSet() {
tableNames = entityManager.getMetamodel().getEntities().stream()
.filter(e -> e.getJavaType().getAnnotation(Entity.class) != null)
.map(e -> CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, e.getName()))
.collect(Collectors.toList());
}
@Transactional
public void execute() {
entityManager.flush();
entityManager.createNativeQuery("SET foreign_key_checks = 0").executeUpdate();
for (String tableName : tableNames) {
entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
}
entityManager.createNativeQuery("SET foreign_key_checks = 1").executeUpdate();
}
}
F.I.R.S.T 원칙를 준수하기 위해 각 테스트는 격리되어야 합니다. 인수테스트 시나리오에 따라 DB의 값이 변경되면 다른 테스트에 영향을 줄 수 있습니다. 이에 각 시나리오마다 모든 테이블을 TRUNCATE 합니다.
b. 인수테스트 객체 설정
@UtilityClass
public class RestAssuredTemplate {
public static RequestSpecification givenAnonymous() {
return RestAssured.given()
.accept(ContentType.JSON)
.contentType(ContentType.JSON)
.log().all();
}
public static Response createResourceByAdmin(
String url,
Object body
) {
return createResourceWithToken(
givenAnonymous(),
url,
body,
ADMIN.getAccessToken()
).extract().response();
}
테스트를 위한 서버에 요청을 보내는 클라이언트 객체를 설정해야 합니다. (MockMvc, RestAssured, WebTestClient 중 여기서는 RestAssured 를 선택)
public class BusinessSteps {
public static void 토큰_만료(String accessToken) {
updateResourceByAdmin("/v1/auth/expire", accessToken);
}
public static BusinessDto 토큰_갱신(String accessToken, String clientName, LocalDateTime expiredAt) {
final var request = BusinessUpdateRequest.of(clientName, accessToken, expiredAt);
return updateResourceByAdmin("/v1/auth/renew", request).getBody().as(BusinessDto.class);
}
테스트의 문서화 기능, 메소드 재사용 등의 목적으로 인수테스트 메소드는 한글로 표기하는 것도 좋은 대안입니다.
2. Slice Test
A. Controller Test
Interceptor, HandlerArgumentResolver, 요청 파라미터 검증 등의 역할을 합니다.
@DisplayName("Access Token 생성시, ClientName이 필요하다")
@Test
public void clientNameValid() throws Exception {
postByAdmin("/v1/auth/sign-up",
BusinessCreateRequest.of(
null,
List.of(NO_AUTHORITY))
).andExpect(status().is4xxClientError());
postByAdmin("/v1/auth/sign-up",
BusinessCreateRequest.of(
"이름",
List.of(NO_AUTHORITY))
).andExpect(status().isOk());
}
@ActiveProfiles("test")
public class ControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private WebApplicationContext webApplicationContext;
@BeforeEach
void setUp() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
}
protected ResultActions postByAdmin(String url, Object request) throws Exception {
return this.mockMvc.perform(postRequest(url, request)
.header(HttpHeaders.AUTHORIZATION, "Bearer " + admin().getAccessToken()));
}
protected ResultActions post(String url, Object request) throws Exception {
return this.mockMvc.perform(postRequest(url, request));
}
protected MockHttpServletRequestBuilder postRequest(String url, Object request) throws Exception {
return RestDocumentationRequestBuilders.post(url)
.content(objectMapper().writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON);
}
Controller 테스트는 MockMvc 기반으로도 작성할 수 있어요. MockBean 을 주입하여 필요한 요청에 대해서만 테스트하도록 작성해봅니다.
@WebMvcTest(BusinessController.class)
class BusinessControllerTest extends ControllerTest {
@MockBean
private BusinessController businessController;
@MockBean
private BusinessRepository businessRepository;
B. Service Test
Service Layer는 목적에 따라 형태가 달라질 수 있습니다.
트랜잭션 확인을 위한 테스트
행위 검증 (BDD 패턴으로, 행위를 목킹한 후 예상한 결과가 나오는지 happy case)
필요한 Bean 만 주입하여 테스트합니다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK,
classes = {
CacheConfig.class,
BusinessService.class
})
class BusinessServiceTest {
@Autowired
private BusinessService businessService;
@MockBean
private BusinessRepository businessRepository;
@DisplayName("Access Token 조회시 캐싱 적용 여부 확인")
@Test
public void cacheable() {
given(businessRepository.findByAccessToken(any())).willReturn(businessDto());
// when
IntStream.range(0, 10)
.forEach(i -> businessService.findByAccessToken(token));
then(businessRepository).should().findByAccessToken(token);
}
C. Repository Test
@DataJpaTest 를 사용하여 Repository를 테스트합니다.
QueryDSL을 사용하는 경우, outter join, group by 등 검증이 필요한 경우, entity 설계 중 cascade 설정 시 전파 정도를 확인해야 하는 경우 등에 테스트를 합니다.
현재 h2 를 띄워 테스트를 진행합니다.
class BusinessRepositoryTest extends RepositoryTest {
@Autowired
private BusinessJpaRepository businessJpaRepository;
@Autowired
private BusinessAuthorityJpaRepository businessAuthorityJpaRepository;
private BusinesstRepository businessRepository;
@BeforeEach
void setUp() {
businessRepository = new BusinesstRepository(businessJpaRepository, businessAuthorityJpaRepository);
for (AuthorityCode code : AuthorityCode.values()) {
businessAuthorityJpaRepository.save(AuthorityCode.of(code, true));
}
}
@DisplayName("Access Token을 조회할 수 있다.")
@Test
public void authenticate() {
final var client = signUp();
final var accessToken = findBusinessDtoByAccessToken(client.getAccessToken());
assertThat(accessToken.getAccessToken()).matches(UUID_PATTERN);
}
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY)
public class RepositoryTest {}
3. 단위 테스트
도메인 모델의 비즈니스 로직을 테스트합니다.
class BusinessTest {
@DisplayName("Access Token을 만료시킬 수 있다.")
@Test
public void expire() {
final var token = Business.of("nextstep");
token.expire();
assertThat(token.isExpired()).isTrue();
}