인수테스트 도입기

seokjin.han·2024년 8월 4일
post-thumbnail

개요

인수테스트란, 사용자의 스토리를 검증하는 기능 테스트입니다. 인수테스트의 장점은 오늘 내가 할 일이 무엇인지 확실하게 정할 수 있다는 것입니다. 또한 스토리 기반으로 검증하기 때문에 중간에 기능을 빠뜨리는 실수도 줄게 되며 표면적으로 확인할 수 있는 요소 자체가 됩니다.

이러한 이유로 저는 인수테스트를 도입하고자 하였고 이를 위해 많은 세팅과 에러를 만나야만 했습니다. 구현하면서 느낀 건데 이렇게 힘든 일이었다면 간단한 프로젝트에서는 굳이 하지 않는게 좋을 것 같습니다.

테스트 환경과 개발 환경의 분리

먼저 DB의 분리가 필요했습니다. 기본적으로 개발 환경에서는 MySQL을 띄워 사용하고 있었고, 테스트 환경에서는 h2 환경으로 설정했습니다. 따로 로직 상 DBMS에 따라 달라질 이유가 있는 코드가 없었기 때문입니다. 예를 들어 MySQL을 사용한다면 매우 긴 텍스트를 넣어야 할 때, @Column(columnDefinition="text") 로 두어야 합니다. 반면 h2를 사용한다면 대신 @Lob과 같은 것을 사용해야 할 겁니다. 아무튼 이러한 코드가 별로 없고 신경 쓰지 않아도 됬기 때문에(=귀찮았기 때문에) h2로 테스트 환경을 구성하게 되었습니다.

환경을 분리하는 법은 간단합니다. 결과적으로 application.yml만 손보면 그만이기 때문입니다. 일반적으로는 개발용, 테스트용, 배포용으로 따로 application.yml을 구축하며 저는 개발용과 테스트용 먼저 만들었습니다.

  • application-test.yml
  • application.yml

위와 같이 두 파일을 만들어줍니다. 물론 한 파일로 관리하고 그 안에서 관리하는 방법도 있으나, 저는 너무 파일이 길어지고 오히려 관리가 복잡해지는 것 같아 따로 분리했습니다.

application-test.yml

spring:
  datasource:
    url: jdbc:h2:mem:testDb
    username: sa
    password:
    driver-class-name: org.h2.Driver

  jpa:
    database-platform: org.hibernate.dialect.H2Dialect
    properties:
      hibernate:
        format_sql: true
        use_sql_comments: true
        highlight_sql: true
    hibernate:
      ddl-auto: create-drop

  h2:
    console:
      enabled: true
      path: /h2-console

이렇게 작성한 테스트용 설정은 아래와 같은 어노테이션을 붙인다면 사용 가능합니다.

  • ActiveProfiles("test")

만약 application-prod.yml으로 배포 버전도 따로 만들었다면, ActiveProfiles("prod")으로 두면 해당 설정으로 사용할 수 있습니다.

생각없는 상속과 메서드이름으로 인한 에러

저는 인수테스트를 위해 상속을 사용했습니다. 그 이유는 공통적으로 처리해야 할 부분에 대해서는 생각하지 않도록 하기 위함이었고 해당 부분은 다음과 같습니다.

  • RestAssured에서 Random Port를 사용하기 위한 세팅
  • 테스트 시 사용해야 할 빈들에 대한 주입
  • 테스트 시 데이터베이스 초기화
  • 그 외 공통 로직(인증, 인가와 관련된 부분)

위와 같은 부분을 모든 인수 테스트마다 생각하고 싶지 않았고 이를 위해 상속을 도입하였습니다.

@Import(RedisTestConfig.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
public class AcceptanceTest {

    @LocalServerPort
    protected int port;

    @BeforeEach
    protected void setUp() {
        RestAssured.port = port;
    }

    @MockBean
    protected OauthLoginService oauthLoginService;
    ...

}

위와 같이 작성 한 후에 실제 사용 시는 아래와 같이 AcceptanceTest를 상속받기만 해도 공통적으로 처리되어야 하는 부분은 잘 처리될 줄 알았습니다..

public class MemberAcceptanceTest extends AcceptanceTest {

	@BeforeEach
    public void setUp() {
    ...
    }
}

하지만,,
분명히 성공해야만 했던 테스트는 java.net.ConnectException: Connection refused 에러를 내놓았습니다;; 이게 근데 화나는 점이 어쩔 때는 또 그냥 404에러만 반환하기도 한다는 것입니다.. 아니 코드를 1도 안바꿨는데 에러 타입만 두 개가 발생하다니...

메서드 이름에서의 문제

Connection이 refuse된 경우에는 모르겠으나, 그나마 404에러만 반환할 때 해결책을 찾을 수 있었습니다.

자세히 보니 우선 포트가 8080이었습니다. 엥? 왜 8080일까요.. 분명히 RANDOM_PORT로 설정했는데.. 그래서 AcceptanceTest에서 setUp()메서드가 제대로 실행되는 지 디버깅 해보니 해당 함수 자체를 실행하지도 않는다는 것을 발견했습니다.

즉, setUp()함수가 실행되지 않는다는 것인데 그 이유를 곰곰히 생각해보았는데 원인으로 생각해볼 수 있는 것은 AcceptanceTest를 상속받은 MemberAcceptance에 있었습니다. MemberAcceptance에서도 똑같이 setUp()함수가 있고, 똑같이 @BeforeEach이 있었습니다. 이게 문제가 된 것이었습니다. 사실은 당연했습니다.

당연히 똑같이 상속받은 메서드를 똑같이 써준다면, 오버라이딩되어 상속해주는 부모 클래스의 setup()함수는 실행되지 않을 것입니다..

그래도 해결..

저는 해결방법을 아래 세 가지 방법을 떠올렸습니다. 물론 더 좋은 방안도 분명 있을 것입니다.

  • 하위 클래스에서의 setUp()메서드 명 변경해주기
  • 하위 클래스의 setUp()메서드안에서 명시적으로 부모 클래스의 setUp()를 호출해주기
  • 하위 클래스에서 inner static class를 만들어 내부에 setUp()메서드를 두기

특히나 3번째 방안은 DCI패턴(Describe-Context-It)을 사용하는 경우에는 유용할 듯 싶었습니다.

Database Cleaner 도입

이제 문제는 데이터베이스 초기화에 관련된 문제였습니다.
데이터베이스를 초기화하는 방법에는 여러 가지가 있었지만 저는 다음 글을 참고하였습니다.

https://mangkyu.tistory.com/264

참고로 SpringBootTest에서 RestAssured를 사용할 때 RANDOM_PORT나 DEFINED_PORT를 사용하게 되면 Transactional를 사용할 수 없습니다. 그 이유는 실행되는 어플리케이션과 테스트 코드는 서로 다른 스레드에서 실행되기 때문입니다. 이로 인해 하나의 트랜잭션으로 묶이지 않고 제대로 롤백되지 않습니다.

따라서 데이터를 Truncate하는 방법을 선택하였습니다. 데이터를 비우는 방법에는 repository.deleteAll()을 하는 방법도 있으나 이 방법은 외래키 제약 조건도 신경써야하고 Repository가 없는 테이블도 있을 수 있어, 다른 방법을 사용합니다.

public class DatabaseCleanerListener extends AbstractTestExecutionListener {

    @Override
    public void afterTestMethod(final TestContext testContext) {
        final JdbcTemplate jdbcTemplate = getJdbcTemplate(testContext);
        final List<String> truncateQueries = getTruncateQueries(jdbcTemplate);
        truncateTables(jdbcTemplate, truncateQueries);
    }

    private List<String> getTruncateQueries(final JdbcTemplate jdbcTemplate) {
        return jdbcTemplate.queryForList("SELECT Concat('TRUNCATE TABLE ', TABLE_NAME, ';') AS q FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'PUBLIC'", String.class);
    }

    private JdbcTemplate getJdbcTemplate(final TestContext testContext) {
        return testContext.getApplicationContext().getBean(JdbcTemplate.class);
    }

    private void truncateTables(final JdbcTemplate jdbcTemplate, final List<String> truncateQueries) {
        execute(jdbcTemplate, "SET REFERENTIAL_INTEGRITY FALSE");
        truncateQueries.forEach(v -> execute(jdbcTemplate, v));
        execute(jdbcTemplate, "SET REFERENTIAL_INTEGRITY TRUE");
    }

    private void execute(final JdbcTemplate jdbcTemplate, final String query) {
        jdbcTemplate.execute(query);
    }

}

저는 jdbcTemplate을 주입받아 직접 제약 조건을 무효화 시켜주고 테이블을 비우며, 제약 조건을 다시 걸어주는 방식을 택했습니다. 참고로 클래스 이름을 리스너로 해준 이유는 이러한 데이터베이스를 비워주는 과정 자체를 관심사에서 분리시켜주기 위함입니다.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Retention(RetentionPolicy.RUNTIME)
@ActiveProfiles("test")
@TestExecutionListeners(value = {DatabaseCleanerListener.class,}, mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS)
public @interface DatabaseCleaner {
}

저는 어노테이션으로 해당 리스너 클래스를 등록시켜주었습니다. 이렇게 하면 데이터베이스를 비우는 과정 자체를 캡슐화 시키는 게 가능해집니다.

@Import(RedisTestConfig.class)
@DatabaseCleaner
@ActiveProfiles("test")
public class AcceptanceTest {

    @LocalServerPort
    protected int port;

    @BeforeEach
    protected void setUp() {
        RestAssured.port = port;
    }
 
...

실제로 사용할 때는 @DatabaseClenaer를 붙여주면 알아서 코드를 실행시킬 수 있습니다.

0개의 댓글