우아한테크코스 레벨3에서 저희 팀이 인수테스트를 격리한 방식을 개선해온 과정을 소개하겠습니다.
가장 초기의 인수테스트에서는 아래와 같이 DirtiesContext
를 통해서 매 테스트 메서드가 동작하기 전
에 인메모리 DB를 초기화하여 격리 환경을 구축했습니다.
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@DirtiesContext(classMode = ClassMode.BEFORE_EACH_TEST_METHOD)
public class AcceptanceTest {
@LocalServerPort
int port;
@BeforeEach
public void setUp() {
RestAssured.port = port;
}
}
@DisplayName("게시글 관련 인수테스트")
class PostAcceptanceTest extends AcceptanceTest {
// tests...
}
Test annotation which indicates that the
[ApplicationContext](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/context/ApplicationContext.html)
associated with a test isdirty and should therefore be closed and removed from the context cache.
Use this annotation if a test has modified the context — for example, by modifying the state of a singleton bean, modifying the state of an embedded database, etc. Subsequent tests that request the same context will be supplied a new context.
테스트와 관련된 ApplicationContext가 Dirty하기 때문에 Context Cache에서 종료되고 삭제되어야 함을 나타내는 Test Annotation입니다. Singleton Bean, Embedded DB의 상태를 변경하는 등의 컨텍스트 수정이 일어나는 경우에 annotation을 사용하세요. 동일한 Context를 요구하는 이후의 테스트들도 새로운 Context에서 진행됩니다.
먼저 Spring의 Context Caching에 대해 알아보겠습니다.
3.2.1. Context Management and Caching
The Spring TestContext Framework provides consistent loading of Spring ApplicationContext instances and WebApplicationContext instances as well as caching of those contexts. Support for the caching of loaded contexts is important, because startup time can become an issue — not because of the overhead of Spring itself, but because the objects instantiated by the Spring container take time to instantiate. For example, a project with 50 to 100 Hibernate mapping files might take 10 to 20 seconds to load the mapping files, and incurring that cost before running every test in every test fixture leads to slower overall test runs that reduce developer productivity.
Spring TestContext Framework는 ApplicationContext와 WebApplicationContext 인스턴스의 지속적인 로딩과 해당 컨텍스트들의 캐싱을 제공합니다.
Spring Container에 의해 초기화되는 **객체들을 초기화하는데 시간이 걸리기 때문에 로드된 Context를 캐싱하는 것이 중요**
합니다.…
By default, once loaded, the configured
ApplicationContext
is reused for each test. Thus, the setup cost is incurred only once per test suite, and subsequent test execution is much faster.
기본적으로 한번 configure, load된 ApplicationContext는 각 테스트에서 재사용
됩니다. 그러므로, setup 비용이 전체 테스트에서 한번만 발생됩니다. 그리고 이후의 테스트 실행은 훨씬 빠릅니다.
Spring에서는 위와 같이 동일한 ApplicationContext를 재사용하는 Context Caching을 지원합니다. 특정 테스트가 필요로 하는 ApplicationContext가 이미 load가 되어 있다면 재사용한다는 뜻입니다.
이런 좋은 Context Caching을 Spring에서 제공해주는데, DirtiesContext를 사용하면 매번 새로운 ApplicationContext를 Load하기 때문에 테스트 성능이 저하될 수 밖에 없는 환경이었습니다.
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Sql(scripts = {"classpath:truncate.sql"}, executionPhase = BEFORE_TEST_METHOD)
public class AcceptanceTest {
@LocalServerPort
int port;
@BeforeEach
public void setUp() {
RestAssured.port = port;
}
}
-- truncate.sql
TRUNCATE TABLE post;
TRUNCATE TABLE auth_code;
TRUNCATE TABLE ticket;
TRUNCATE TABLE member;
-- ...
따라서, DirtiesContext를 제거를 통해, 테스트 메서드마다 ApplicationContext가 새로 load되지 않도록 했습니다. 그리고 테스트의 격리를 위해 @sql을 통해 테스트 메서드가 실행되기 전마다 테이블들을 TRUNCATE
하기로 했습니다. 따라서, 테스트에 소요되는 시간을 비약적으로 줄일 수 있었습니다.
하지만, 저희는 기능 개발이 한창이었고 새로운 테이블이 지속적으로 추가되고 있는 상황이었습니다. 테이블이 추가될 때마다 truncate.sql에 TABLE을 추가하고 PK를 1로 만들어주는 작업이 번거로웠습니다. 또한, 디버깅이 어렵지 않겠지만 truncate.sql에 새로운 TABL을 TRUNCATE하는 sql을 작성해주지 않으면 테스트가 실패할 수 있다고 생각했습니다.
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class AcceptanceTest {
@LocalServerPort
int port;
@Autowired
private DatabaseCleaner databaseCleaner;
@BeforeEach
public void setUp() {
RestAssured.port = port;
databaseCleaner.clear();
databaseCleaner.insertInitialData();
}
}
@Component
@ActiveProfiles("test")
public class DatabaseCleaner implements InitializingBean {
@PersistenceContext
private EntityManager entityManager;
private List<String> tableNames;
@Override
public void afterPropertiesSet() throws Exception {
this.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 clear() {
entityManager.flush();
entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate();
for (String tableName : tableNames) {
entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
entityManager
.createNativeQuery("ALTER TABLE " + tableName + " ALTER COLUMN " + tableName + "_id RESTART WITH 1")
.executeUpdate();
}
entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate();
}
@Transactional
public void insertInitialData() {
entityManager.createNativeQuery(
"insert into member (username, nickname, password, role_type) values ('25f43b1486ad95a1398e3eeb3d83bc4010015fcc9bedb35b432e00298d5021f7', 'admin1Nickname', '6297d64078fc9abcfe37d0e2c910d4798bb4c04502d7dd1207f558860c2b382e', 'ADMIN');")
.executeUpdate();
// 데이터 삽입
}
}
따라서, 위와 같이 InitializingBean
를 구현하는 DatabaseCleaner라는 객체를 만들었습니다. afterPropertiesSet
메서드에서 EntityManager를 통해 Table 이름들을 추출
하였습니다. 그 후, table들을 초기화해주고 테스트에 필요한 Data들을 삽입해주었습니다.
Interface to be implemented by beans that need to react once all their properties have been set by a BeanFactory:
모든 bean properties가 BeanFactory에 의해 세팅되자마자 해당 bean들에 의해 시행되는 interface
This method allows the bean instance to perform validation of its overall configuration and final initialization when all bean properties have been set.
모든 bean properties가 set 되었을 때, bean 인스턴스가 전반적인 configuration의 검증과 최종 초기화를 하도록 허용하는 메서드입니다.
최종적으로, DirtiesContext를 제거해서 테스트의 성능을 향상시켰고 truncate.sql를 직접 작성하지 않아도 되는 테스트 격리 환경을 구축했습니다.
끗.
https://tecoble.techcourse.co.kr/post/2020-09-15-test-isolation/
감사하빈다!!