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

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();
    }