스프링이 제공하는 테스트 관련 기능과 설정 중,
테스트 격리에 관련하여 헷갈리는 부분들을 정리해봤습니다!
학습 결과 요약은 다음과 같습니다!
- @Sql 애너테이션을 클래스 레벨에 선언하면 매 테스트 메서드 실행 전에 수행된다
- @Sql 애너테이션을 메서드 레벨에 선언하면 해당 테스트 메서드 실행 전에 수행된다
- 클래스 레벨에 선언하고 메서드 레벨에도 선언하면, 메서드 레벨에 선언한 스크립트로 오버라이드 된다.
둘 다 실행하거나 하려면 merge 관련 다른 설정을 찾아봐야할듯. - @Transactional 애너테이션을 클래스 레벨에 선언하면 매 테스트 메서드에 트랜잭션을 생성하고, 테스트 수행 후 롤백한다
- 테스트 메서드에 @Rollback(false) 또는 @Commit을 선언해서 롤백하지 않고 반영해버릴 수도 있다
- @SpringBootTest 애너테이션에는 @Transactional 애너테이션이 포함되어 있지 않지만, @JdbcTest 애너테이션에는 @Transactional 애너테이션이 포함되어 있다 (@JdbcTest 선언 시에는 @Transactional을 중복선언할 필요 없다)
- @TestConstructor(autowireMode = AutowireMode.*ALL*) 테스트 클래스에 이 내용을 선언하면 final 필드 및 생성자 주입을 통해 프로덕션 코드처럼 테스트를 위한 Repository 등을 생성해두고 재사용할 수 있다.
- JdbcTestUtils 라는 정적 유틸성 클래스에 테스트 격리를 위해 필요한 유용한 메서드들이 있다.
🗯 @Sql("/truncate.sql")
테스트 이전에 명시된 파일 내의 SQL Statement 들을 실행합니다
1. 클래스 레벨에 선언할 경우, 해당 클래스 내 모든 테스트 메서드 수행 전에 명시된 .sql파일 내 문장을 수행합니다.
2. 메서드 레벨에 선언할 경우, 해당 테스트 메서드 수행 전에 명시된 .sql파일 내 문장을 수행합니다.
3. 클래스 레벨 + 메서드 레벨 모두 선언되어 있다면,
메서드 레벨 설정이 오버라이드하여, 메서드 레벨에 명시된 .sql파일만 내 문장만 수행됩니다.
4. 테스트 패키지 내 resources 폴더를 생성하고 그 안에 .sql 파일을 배치했다면,
모든 테스트 클래스에서 @Sql("/파일명.sql") 명시할 수 있습니다.
@Test
@Sql({"/test-schema.sql", "/test-user-data.sql"})
void userTest() {
// run code that relies on the test schema and test data
}
5. 위와 같은 모습으로 여러 .sql 파일을 실행할 수도 있습니다.
6. sql 파일 내 아무 문장도 없을 경우 다음과 같은 예외가 발생하며 테스트가 수행되지 않습니다.
org.springframework.jdbc.datasource.init.UncategorizedScriptException:
Failed to execute database script from resource [class path resource [doNothing.sql]];
nested exception is java.lang.IllegalArgumentException: 'script' must not be null or empty
사용할 일이 많을진 모르겠지만, 클래스 레벨에 선언된 Sql 애너테이션을 통한 동작을
특정 테스트 메서드에서만 수행하지 않길 원한다면,
메서드 레벨에 @Sql 애너테이션을 재정의하고, 해당 파일에 SELECT 1 FROM DUAL 과 같이
다른 영향을 미치지 않으면서도 무언가 수행을 하는 문장을 담아두면 될 것 같습니다.
Sql 애너테이션은 트랜잭션과는 무관합니다.
테스트 격리를 위해 테이블 내 데이터를 지우는 등의 문장을 담아서 사용할 수 있지만,
트랜잭션 롤백을 통한 테스트 격리가 수행되는 것은 아닙니다.
클래스 레벨에 선언 시 모든 테스트 메서드 수행 전에 한 번,
메서드 레벨에 선언 시 해당 테스트 메서드 수행 전에 한 번 수행된다는 점이 핵심입니다.
↩️ @Transactional
트랜잭션을 생성해 테스트 메서드 완료 후 Rollback 을 수행합니다.
테스트코드에서 Transactional 애너테이션의 동작이 어떻게 다른지에 대해
많이 헷갈렸었는데요, 이번 포스팅에서 완벽하진 않지만,
당장 사용하기엔 충분한 지식 정도로 정리해보고자 합니다!
Annotating a test method with @Transactional
causes the test to be run within a transaction
that is, by default,
automatically rolled back after completion of the test.
1. 클래스 레벨에 @Transactional 을 선언하면,
해당 클래스 내 모든 테스트 메서드에 트랜잭션이 적용되고,
각 테스트 메서드가 수행 완료된 후 롤백이 됩니다.
2. 메서드 레벨에 @Transactional 을 선언하면,
해당 테스트 메서드에 트랜잭션이 적용되어 수행 완료 후 롤백 처리됩니다.
3. @BeforeEach, @BeforeAll 과 같은 테스트 라이프 사이클 메서드에는 지원되지 않습니다.
4. @Transactional(propagation = ) propagation 속성 설정이 NOT_SUPPORTED 또는 NEVER 일 경우,
Transaction을 생성하지 않고 테스트 메서드가 수행됩니다. 즉 롤백되지 않습니다.
5. @JdbcTest 애너테이션 내부에는 @Transactional 애너테이션이 포함되어 있으므로 중복 선언할 필요 없습니다.
6. @SpringBootTest 애너테이션 내부에는 @Transactional 애너테이션이 포함되지 않으므로, 트랜잭션이 필요할 경우 선언해줘야 합니다.
7. RANDOM_PORT 설정을 이용한 RestAssured 테스트의 경우, @Transactional 애너테이션을 통한 트랜잭션 처리가 불가하므로
@Sql 애너테이션을 활용한 테스트 격리 처리가 필요할 수 있습니다.
8. DROP TABLE 문장은 데이터베이스 레벨에서 롤백이 지원되지 않기에
Transactional 애너테이션을 붙여도 테스트 메서드 수행 후 복구되지 않습니다.
🔧 @TestConstructor(autowireMode = AutowireMode.ALL)
테스트 코드에서도 생성자 주입을 해봅시다!
생성자 주입을 사용할 경우,
테스트 클래스에서도 재사용되는 필드를 final로 선언할 수 있으며,
BeforeEach 를 통해 매번 인스턴스를 새로 생성하지 않아도 됩니다.
다만 JdbcTest에서 자동으로 생성되는 빈을 생성자 매개변수로 받아서,
구현체를 직접 조립해야하는 과정이 필요하기 때문에,
경우에 따라 생성자 내부 가독성이 안 좋아질 수도 있습니다.
저는 비록 생성자 내부가 다소 어지러워지더라도,
매 테스트마다 인스턴스를 재생성하지 않아도 된다는 점,
BeforeEach 와 같은 테스트 생명 주기 메서드를 활용하지 않아도 된다는 점에서
생성자 주입에 매력을 느껴 생성자 주입만 사용하고 있습니다.
생성자 주입 없이 필드 주입을 사용할 경우
@JdbcTest
class StationRepositoryTest {
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private DataSource dataSource;
@Autowired
private NamedParameterJdbcTemplate namedParameterJdbcTemplate;
private StationRepository stationRepository;
@BeforeEach
void setupRepository() {
this.stationRepository = new JdbcStationRepository(
new StationDao(jdbcTemplate, dataSource, namedParameterJdbcTemplate)
);
}
// tests...
}
생성자 주입을 사용할 경우
@TestConstructor(autowireMode = AutowireMode.ALL)
@JdbcTest
class StationRepositoryTest {
private final StationRepository stationRepository;
public StationRepositoryTest(JdbcTemplate jdbcTemplate, DataSource dataSource,
NamedParameterJdbcTemplate namedParameterJdbcTemplate) {
final StationDao stationDao = new StationDao(jdbcTemplate, dataSource, namedParameterJdbcTemplate);
this.stationRepository = new JdbcStationRepository(stationDao);
}
// tests...
}
🏹 EnabledIf, DisabledIf
테스트를 조건부로 수행하거나 수행하지 않으려 할 때 사용되는 애너테이션입니다
프로파일에 설정된 값 또는 시스템 속성값 또는 true/false 리터럴을 이용해
조건부 테스트를 수행 혹은 무시하고자 할 때 사용되는 애너테이션 입니다.
위 캡쳐의 예시는 시스템 속성값 중 os.name 속성에 mac 이라는 글씨가 포함되면 테스트를 수행하지 않는다 입니다.
reason 필드에는 무시될 경우 콘솔에 출력될 내용을 입력할 수 있습니다.
주의사항은 임포트 시 패키지 명이 스프링 프레임워크에 속해야 한다는 점입니다.
org.springframework.test.context.junit.jupiter
🔥 JdbcTestUtils
테스트 격리에 사용되는 유용한 정적 유틸 메서드를 가지고 있는 JdbcTestUtils 클래스 입니다!
The org.springframework.test.jdbc package contains JdbcTestUtils,
which is a collection of JDBC-related utility functions intended to simplify standard database testing scenarios. Specifically, JdbcTestUtils provides the following static utility methods.
- countRowsInTable(..): 주어진 테이블의 Row 를 카운팅
- countRowsInTableWhere(..): 주어진 테이블 + 주어진 조건에 해당하는 Row 를 카운팅
- deleteFromTables(..): 주어진 테이블의 모든 Row 제거
- deleteFromTableWhere(..): 주어진 테이블 + 주어진 조건에 해당하는 모든 Row 제거
- dropTables(..): 주어진 테이블들을 Drop 처리
데이터베이스와 연관된 테스트 시나리오를 수월하게 수행할 수 있게 하기 위해서
JDBC 관련 유틸성 클래스인 JdbcTestUtils가 이미 제공되고 있었는 줄 미처 몰랐습니다!
기존 미션을 수행하며 프로덕션에서 사용되지 않는 Repository#deleteAll() 과 같은 메서드를 구성하기도 했었는데요,
행의 수 카운트, 행의 삭제, 테이블 내 데이터 전체 삭제 등 테스트 격리에 필요한 기능은 대부분 갖추고 있습니다.
이하는 테스트해본 내용입니다.
@DisplayName("JdbcTestUtils 테스트")
@Test
void jdbcTestUtils() {
// given
final int 테스트_시작_전_TRUNCATE = JdbcTestUtils.deleteFromTables(jdbcTemplate, "station");
final Station 차드역 = stationRepository.save(new Station("차드역"));
final Station 리차역 = stationRepository.save(new Station("리차역"));
final Station 리치역 = stationRepository.save(new Station("리치역"));
final List<Station> 지하철역3개담은리스트 = stationRepository.findAll();
// when
final int 지하철역전체조회갯수 = JdbcTestUtils.countRowsInTable(jdbcTemplate, "station");
final int 리_가포함된지하철역수 = JdbcTestUtils.countRowsInTableWhere(jdbcTemplate, "station", "name like '%리%'");
final int 치_가포함된지하철역제거_제거된행의수 = JdbcTestUtils.deleteFromTableWhere(jdbcTemplate, "station", "name like '%치%'");
final int 지하철역테이블전체제거_제거된행의수 = JdbcTestUtils.deleteFromTables(jdbcTemplate, "station");
final List<Station> TRUNCATE_후_지하철역행의수 = stationRepository.findAll();
JdbcTestUtils.dropTables(jdbcTemplate, "station");
// then
assertAll(
() -> assertThat(테스트_시작_전_TRUNCATE).isEqualTo(232),
() -> assertThat(차드역.getName()).isEqualTo("차드역"),
() -> assertThat(리차역.getName()).isEqualTo("리차역"),
() -> assertThat(리치역.getName()).isEqualTo("리치역"),
() -> assertThat(지하철역3개담은리스트.size()).isEqualTo(3),
() -> assertThat(지하철역전체조회갯수).isEqualTo(3),
() -> assertThat(리_가포함된지하철역수).isEqualTo(2),
() -> assertThat(치_가포함된지하철역제거_제거된행의수).isEqualTo(1),
() -> assertThat(지하철역테이블전체제거_제거된행의수).isEqualTo(2),
() -> assertThat(TRUNCATE_후_지하철역행의수).isEmpty(),
() -> assertThatThrownBy(() -> stationRepository.findAll())
.isInstanceOf(BadSqlGrammarException.class)
);
}
소회
물론 정독은 아니었지만, 스프링 공식 문서를 이번 처럼 자세히 오랜시간을 들여 살펴본 건 처음이었습니다.
대단히 친절하게 작성된 문서라고 생각이 들었습니다.
테스트 격리 관련 Sql 애너테이션, Trnsactional 애너테이션 등이 어떻게 동작하는지
답답해하면서도 꾹꾹 참으면서 그냥 개발하다가 도저히 더이상은 안되겠다 싶어서 문서를 봤습니다.
그런데.. 생각보다 문서 읽는 게 할만 한 것 같습니다.
주요한 주제가 나올 때마다 발췌독으로라도 공식문서를 한 번 훑는 건 시도해봄직 한 것 같습니다.
학습 출처
https://docs.spring.io/spring-framework/docs/current/reference/html/testing.html#integration-testing
'우아한테크코스 4기' 카테고리의 다른 글
🪪 출입증 (8) | 2022.06.04 |
---|---|
🪙JWT vs 🍪Session, 그리고 실제 사례 살짝 분석 (찜꽁, 프롤로그, LMS) (8) | 2022.06.01 |
find vs get (네이밍 컨벤션과 JPA에서의 내부 동작 차이) (2) | 2022.05.24 |
🆙 테스트 어려운 부분 끌어올리기 (feat. Spring 체스 게임방) (2) | 2022.05.15 |
우당탕탕 Repository 제작기 (feat. Reflection) (2) | 2022.05.06 |