내편 프로젝트의 기능이 추가되면 추가될수록 점점 테스트 코드도 늘어가고 있다.
처음에는 기능 개발하고 테스트 돌리는 것들이 금방금방 끝났지만 테스트 코드가 늘어남에 따라 느려졌다. 또한, CI/CD 작업에서도 테스트 코드 때문에 기다리는 시간이 늘어나다 보니 불편함이 이만저만 아니었다.
그래서 문제가되는 부분을 찾아서 개선하려고 했다.
일단, 첫번째 문제는 DirtiesContext다.
매번 테스트 환경을 초기화 하기 위해서 DirtiesContext를 사용했다. 하지만 DirtiesContext는 스프링 테스트가 매 테스트마다 Application Context를 다시 Load 한다. 이러한 방식을 사용하게 된다면 스프링에서 제공해주는 Application Context의 캐싱의 이점을 전혀 가지지 못한다.
팀에서 처음 프로젝트를 시작할 때 테스트의 격리성을 확보하기 위해 DirtiesContext를 사용했다. 프로젝트 초반에는 큰 문제가 없었으나 프로젝트가 점점 커질수록 테스트 코드가 많아지고 문제가 발생했다.
테스트를 돌리는데 시간이 너무 오래 걸린다는 문제다.
수치상으로는 1분이 좀 넘는 시간이였지만 중간 중간 Application Context가 재시작 되는 시간은 누적되지 않기 때문에 실제로는 더 많은 시간이 걸렸을 것이다.
먼저 DirtiesContext를 제거하여 Application Context를 재활용 할 수 있도록 했다.
모든 AcceptanceTest의 DirtiesContext를 제거하고 공통으로 사용하는 애노테이션들을 추상클래스에 모아서 테스트 클래스들이 이를 상속하도록 했다. 하지만 문제가 또 존재했다.
바로 Application Context를 공유하다보니 테스트 격리성이 확보되지 않는 문제이다.@Sql
을 사용하여서 격리성을 확보할까도 생각했다. 아니면 따로 초기화하닌 코드를 작성할까도 생각했지만 이러한 방식을 택했을 때 아래와 같은 문제가 있었다.
위의 문제점들을 해결하기 위해서 DatabaseCleaner 클래스를 정의해서 매 테스트가 실행된 이후 DB를 초기화 하는 작업을 해주게 만들었다.
@Component
public class DatabaseCleaner {
private static final String FOREIGN_KEY_RULE_UPDATE_FORMAT = "SET REFERENTIAL_INTEGRITY %s";
private static final String TRUNCATE_FORMAT = "TRUNCATE TABLE %s";
private static final String ID_RESET_FORMAT = "ALTER TABLE %s ALTER COLUMN %s_id RESTART WITH 1";
private final EntityManager entityManager;
private final List<String> tableNames;
@Autowired
public DatabaseCleaner(final EntityManager entityManager) {
this.entityManager = entityManager;
this.tableNames = entityManager.getMetamodel()
.getEntities()
.stream()
.map(Type::getJavaType)
.map(javaType -> javaType.getAnnotation(Table.class))
.map(Table::name)
.collect(Collectors.toUnmodifiableList());
}
@Transactional
public void execute() {
entityManager.flush();
entityManager.createNativeQuery(String.format(FOREIGN_KEY_RULE_UPDATE_FORMAT, "FALSE"))
.executeUpdate();
for (final String tableName : tableNames) {
entityManager.createNativeQuery(String.format(TRUNCATE_FORMAT, tableName)).executeUpdate();
entityManager.createNativeQuery(String.format(ID_RESET_FORMAT, tableName, tableName)).executeUpdate();
}
entityManager.createNativeQuery(String.format(FOREIGN_KEY_RULE_UPDATE_FORMAT, "TRUE"))
.executeUpdate();
}
}
소스를 살펴보면 크게 2가지를 볼 수 있다.
@Table
로 정의된 클래스들을 tableNames로 정의한다.여기서 주목해야 할 점은 SET REFERENTIAL_INTEGRITY값을 FALSE하고 작업을 시작한다는 것이다.
무결성 제약조건을 OFF한다는 의미인데 이렇게 할 경우 테이블의 제거 순서를 신경쓰지 않아도 된다.
그렇다면 이 Component를 어떻게 사용할 수 있을까?
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestDatabase
public class AcceptanceTest {
//...
@Autowired
protected DatabaseCleaner databaseCleaner;
@BeforeEach
public void setUp() {
...
databaseCleaner.execute();
}
위에서 만든 AcceptanceTest 추상 클래스에 DatabaseCleaner를 주입받아서 @BeforeEach를 통해 DB를 초기화 작업을 진행해 주도록 했다.
1분이 넘게 걸리던 시간이 6초로 줄어들었다. 내편의 인수테스트만 거의 50개가 넘어가는데 이 테스트 모두 Application Context를 새로 띄워서 테스트 하려고 해서 많은 시간이 걸렸던 것이다!
그렇다면 이제 문제가 없을까? 전체 테스트를 돌려보면 중간 중간 테스트가 멈추는 구간들이 존재했다.
그 이유는 새로운 Application Context를 Load하기 때문이다. DirtiesContext를 제거함으로써 Application Context를 재사용 하는게 아니였을까?
Spring TestContext 프레임워크는 한번 Application Context가 만들어지면 이를 캐시에 저장한다. 그리고 다른 테스트를 돌릴 때 가능한 경우 재사용한다.
여기서 가능한 경우는 아래와 같다.
여기서 같은 bean의 조합을 필요로 하는지 어떻게 알 수 있을까?
공식문서를 찾아보니 아래와 같이 여러 configuration으로 Application Context를 구분하는 키를 생성한다.
여기서 주목할 점은 contextCustomizers에서 @MockBean, @SpyBean을 사용했냐가 Application Context 재사용 여부에 영향을 미친다는 것이다.
내편에서는 여러 MockBean을 사용했는데 이게 테스트마다 따로 떨어져있다보니 Application Context를 재사용하지 못했던 것이다.
즉 Repository, Service, Controller, 인수테스트 총 4개의 Application Context를 생성할 줄 알았지만 MockBean이 여기저기 퍼져 있어서 실질적으로 총 9번의 Application Context가 생성되었다.
Application Context가 새로 Load될 때는 테스트 속도가 측정되지는 않지만 실질적으로는 몇초의 시간들이 흐른다.
그래서 각 계층별로 흩어져 있는 Configuration 정보들을 통일 시키고 해당 계층에서 사용하는 @MockBean을 모아서 각 테스트 계층 별로 Configuration을 통일시켜주는 작업을 진행했다.
@SpringBootTest
@Transactional
public abstract class ServiceTest {
//....
@MockBean
protected KakaoPlatformUserProvider kakaoPlatformUserProvider;
@MockBean
protected GooglePlatformUserProvider googlePlatformUserProvider;
}
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ExtendWith(RestDocumentationExtension.class)
@Import(RestDocsConfiguration.class)
@AutoConfigureTestDatabase
@Transactional
public abstract class TestSupport {
//...
@MockBean
protected KakaoPlatformUserProvider kakaoPlatformUserProvider;
@MockBean
protected GooglePlatformUserProvider googlePlatformUserProvider;
@MockBean
protected NotificationService notificationService;
TestSupport부분은 RestDocs Test에 필요한 모든 Mock과 주입받는 것들을 abstract class에 몰아넣고 모든 RestDocs 테스트가 이를 상속받도록 했다.
이러한 방식처럼 다른 AcceptanceTest나 ServiceTest도 동일하기 처리해서 각각 하나의 Application Context를 이용해서 테스트 가능하도록 수정했다.
결론적으로 이 부분은 테스트코드를 돌렸을때 찍히는 시간의 차이는 없지만 Application Context Load하는 시간은 테스트 시간에 추가되지 않기때문에 CI/CD에서도 속도를 줄일 수 있었다.
Spring Context Caching에 대해 잘 알지 못했는데 이번 계기를 통해 꽤 많이 이해한거 같아서 얻는 것이 많다.
Certbot과 nginx로 https 적용하기! (0) | 2023.02.03 |
---|---|
[Spring + SSE + Redis] SSE를 사용한 실시간 알림 Scale out 시 문제점 해결하기 (0) | 2023.02.02 |