NEXT STEP/ATDD, 클린 코드 with Spring 5기

[ATDD] 인수 테스트 격리하기

샤아이인 2022. 7. 16.

 

이번 시간 들었던 재미있었던 내용 중 하나로 인수 테스트의 격리 에 대한 내용이 있었다.

재미있었던 내용이기에 간략하게 정리해본다.

 

1. 인수 테스트의 격리

1-1) Transactional 의 사용?

사실 내가 떠오른 맨 처음 방식이기도 하다.

어떤 테스트 코드 A, B, C 가 있을 때, 각각의 테스트 코드가 다른 테스트의 영향을 받지 않으려면 DB 또한 각각이 clean한 상태에서 실행되어야 한다.

 

하지만 우리의 인수테스트에서는 Transactional을 사용할 수가 없다...

아니, 정확히는 사용할 수 없는것은 아니지만 사용해봤자 의미가 없다.

 

모든 인수테스트에서 공통적으로 상속하는 Acceptance Class는 다음과 같다.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class AcceptanceTest {

    @LocalServerPort
    int port;

    @Autowired
    private DatabaseCleanup databaseCleanup;

    @BeforeEach
    public void setUp() {
        RestAssured.port = port;
        databaseCleanup.execute();
    }
}

여기서 SpringBootTest.WebEnvironment.RANDOM_PORT가 문제가 된다.

 

해당 설정은 실제 Tomcat WAS를 실행한다는 장점이 있어, 실제 환경과 유사한 인수 테스트를 작성하기 위해 사용한다.

하지만 RANDOM_PORT 를 적용하면 HTTP Client와 Server 가 다른 Thread에서 동작한다.

따라서 @Transactional 로 roll back 해봤자 무용지물인 것이다.

 

Client 쪽에서 아무리 roll back 해봤자... Server는 상관없이 자기 갈길을 가버린다...

 

1-2) DirtiesContext 사용?

Spring TestContext 프레임 워크는 한번 ApplicationContext 가 만들어지면 캐싱하는, context caching 기능을 지원합니다.

따라서 하나의 JVM에서 실행되는 테스트 클래스에 대해 동일한 형태라면 재사용하게 됩니다.

 

즉, A 테스트를 실행한 후 caching 된 context를 그대로 B라는 테스트에서 사용해버리니... 문제가 발생한다.

 

동일한 ApplicationContext 형태의 의미는

  1. 다른 테스트에서 같은 Bean의 조합을 요청 시
  2. 이전 테스트에서 ApplicationContext 가 오염되지 않은 경우

를 의미하며, 위의 조건은 cache key로 판단하게 됩니다.

(출처 https://gocheat.github.io/spring/spring_test-2/)

 

이때 @DirtiesContext를 사용하면 Bean을 오염시켜 캐시 기능을 사용하지 않게 만들 수 있습니다.

 

다만, 매번 테스트마다 Context를 새롭게 구성하다 보니 시간이 상당히 많이 걸리게 됩니다.

(테스트하는데 시간이 많이 걸린다면, TDD에서 테스트 코드의 즉각적인 피드백을 받기 어렵겠죠?)

 

1-3) @Sql을 활용한 쿼리 수행

테스트가 수행될 때마다 테이블을 truncate 해버리는 쿼리를 수행시킨다.

예를 들면 다음과 같은 truncate.sql문을 만들어서 

SET REFERENTIAL_INTEGRITY FALSE
EXECUTE IMMEDIATE 'TRUNCATE TABLE section'
EXECUTE IMMEDIATE 'TRUNCATE TABLE line'
EXECUTE IMMEDIATE 'TRUNCATE TABLE station'
SET REFERENTIAL_INTEGRITY TRUE

 

다음과 같이 @Sql 에노테이션을 통해 사용하면

이 방식은 그나마 합리적인 방법이라고 할 수 있다. 속도도 느리지 않다!

 

클래스 테스트가 실행되기 전에 @Sql이 가리키는 경로에 있는 SQL 실행이 먼저 일어난다.

따라서 이 파일 안에 모든 테이블에 대한 TRUNCATE SQL을 미리 작성해 놓으면, 파일 하나와 어노테이션만으로 테스트 격리를 이뤄낼 수 있으니 꽤나 획기적인 방식이라고 볼 수 있다.

 

다만, 테이블이 추가되면 해당 테이블에 대한 truncate 쿼리를 매번 추가해줘야 한다는 단점이 있다.

 

아!! Code Level에서 관리할 수는 없을까?

내가 추가적인 Table을 만들어도 Sql문을 수정하지 않고 사용하는 환경을 만들 수는 없을까?

 

이러한 고민 속에 다음과 같은 해결책이 나오게 되었다.

 

1-4) 코드로 Truncate 하기

@Service
public class DatabaseCleanup implements InitializingBean {

    @Autowired
    private DataSource dataSource;

    @Autowired
    private JdbcTemplate jdbcTemplate;

    private List<String> tableNames;

    @Override
    public void afterPropertiesSet() {
        tableNames = new ArrayList<>();
        try {
            DatabaseMetaData metaData = dataSource.getConnection().getMetaData();
            ResultSet tables = metaData.getTables(null, null, null, new String[]{"TABLE"});
            while (tables.next()) {
                String tableName = tables.getString("TABLE_NAME");
                tableNames.add(tableName);
            }
        } catch (Exception e) {
            throw new RuntimeException();
        }
    }

    @Transactional
    public void execute() {
        jdbcTemplate.execute("SET REFERENTIAL_INTEGRITY FALSE");
        for (String tableName : tableNames) {
            jdbcTemplate.execute("TRUNCATE TABLE " + tableName);
        }
        jdbcTemplate.execute("SET REFERENTIAL_INTEGRITY TRUE");
    }
}

 

이 방식은 EntityManager를 사용하지 않고, datasource를 직접 사용하기 때문에

특적 DB 기술에 의존적인 방법이 아니다. 범용적인 면에서 우수하다.

 

코드가 복잡해 보여도 단순한 의미를 전달하고 있다.

1) afterPropertiesSet()에서 datasource를 통해 metadata를 가져와 테이블의 이름을 추출하여 List에 저장한다.

2) 참조 무결성 문제가 발생할 수 있기 때문에 해당 옵션을 FALSE로 만든다

3) 각 테이블 이름마다 TRUNCATE sql문을 동적으로 실행시켜 준다.

4) 아까 FALSE로 바꾼 옵션을 다시 TRUE로 변경한다

댓글