📝

테스트 작성 기준

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

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
복사