1. 인수테스트(Acceptance Test)
•
사용자의 관점에서 올바르게 작동하는지 테스트하는 것으로, 인수 요건이 모두 충족되었는지 확인하는 테스트입니다.
•
여기서 이야기하는 인수테스트란 백엔드 API 전구간 테스트를 의미합니다. 실제 데이터베이스에 값을 저장하고 조회하는 것까지 테스트 범위입니다.
A. 인수조건을 테스트로 옮기기
Feature: 간략한 기능 서술
Scenario: 시나리오(예시) 제목
Given: 사전조건
When: 발생해야하는 이벤트
Then: 사후조건
--
And: 앞선 내용에 추가적인 내용 기술
Plain Text
복사
Feature: Access Token 갱신 기능
Scenario: Access Token 만료일을 연장한다.
Given 만료된 Access Token을 생성한다.
When Access Token의 만료일을 갱신한다.
Then 유효한 Access Token이 조회된다.
Plain Text
복사
•
구체적인 행위를 검증하기보다는 비즈니스 규칙을 검증해야 합니다.
•
비즈니스 규칙이 명확히 드러나도록 테스트 시나리오를 작성하면, 구현과 관련 기술이 변경되어도 테스트 시나리오를 변경하지 않아도 됩니다.
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();
}
}
Java
복사
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();
}
}
Java
복사
•
실제 서버가 아닌 테스트를 위한 서버를 띄우기 위한 설정으로, 실제 요청이 아닌 인수테스트의 요청을 받기 위한 구성입니다.
•
@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);
}
}
}
Java
복사
•
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();
}
}
Java
복사
•
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();
}
Java
복사
•
테스트를 위한 서버에 요청을 보내는 클라이언트 객체를 설정해야 합니다. (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);
}
Java
복사
•
테스트의 문서화 기능, 메소드 재사용 등의 목적으로 인수테스트 메소드는 한글로 표기하는 것도 좋은 대안입니다.
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());
}
Java
복사
@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);
}
Java
복사
•
Controller 테스트는 MockMvc 기반으로도 작성할 수 있어요. MockBean 을 주입하여 필요한 요청에 대해서만 테스트하도록 작성해봅니다.
@WebMvcTest(BusinessController.class)
class BusinessControllerTest extends ControllerTest {
@MockBean
private BusinessController businessController;
@MockBean
private BusinessRepository businessRepository;
Java
복사
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);
}
Java
복사
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);
}
Java
복사
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY)
public class RepositoryTest {}
Java
복사
3. 단위 테스트
•
도메인 모델의 비즈니스 로직을 테스트합니다.
class BusinessTest {
@DisplayName("Access Token을 만료시킬 수 있다.")
@Test
public void expire() {
final var token = Business.of("nextstep");
token.expire();
assertThat(token.isExpired()).isTrue();
}
Java
복사