Test Isolation
μ ν리μΌμ΄μ κ°λ°μ μμ΄μ ν μ€νΈμ½λλ κ°λ° μμ°μ±κ³Ό μ ν리μΌμ΄μ μ λ’°λ ν₯μμ ν° λμμ μ€λλ€. μ΄λ° ν μ€νΈμ½λλ μμμ μκ΄μμ΄ λ 립μ μΌλ‘ μνλμ΄μΌ λΉλ‘μ μ λ’°μ± μκ³ μμ μ μΌ μ μλλ°μ. μ무리 ν μ€νΈμ½λλ₯Ό κΌΌκΌΌν μμ±νλλΌλ λμΌν μ λ ₯κ°μ λν΄ νμ κ°μ κ²°κ³Όλ₯Ό μΆλ ₯νμ§ μλ λΉκ²°μ μ ν μ€νΈκ° λλ€λ©΄ ν μ€νΈμ½λμ μλ―Έλ₯Ό μμ€ν μ μμ΅λλ€. λλ¬Έμ μΆ©λΆν ν μ€νΈ 격리λ₯Ό ν΅ν΄ μμ μ μΈ ν μ€νΈκ° μνλ μ μλλ‘ νλ κ²μ΄ λ§€μ° μ€μν©λλ€.
ν
μ€νΈ κ²©λ¦¬κ° μ΄λ£¨μ΄μ§μ§ μλ κ·Όλ³Έμ μΈ μμΈμ κ°κ°μ ν
μ€νΈκ° νλμ μμμ 곡μ νκΈ° λλ¬Έμ
λλ€. JUnit κ³Ό Spring μμ @AfterEach
μ @Transactional
λ±μ ν΅ν΄ ν
μ€νΈ κ²©λ¦¬κ° κ°λ₯ν©λλ€. λΏλ§ μλλΌ Mock νλ μμν¬λ₯Ό νμ©νμ¬ ν
μ€νΈ 격리μ μ κ²½μ°μ§μκ³ κ³μΈ΅ ꡬ쑰μμ λ¨μ ν
μ€νΈλ₯Ό μνν μ μλ λ°©λ² μμ μ‘΄μ¬ν©λλ€.
νμ§λ§ μ΄μ νκ²½κ³Ό κ°μ 쑰건μμ ν μ€νΈλ₯Ό μννλ μΈμ ν μ€νΈμ κ²½μ° Mock νλ μμν¬λ₯Ό μ¬μ©νμ§ μκ³ μ€μ DB λ₯Ό μ¬μ©νμ¬ ν μ€νΈλ₯Ό μνν΄μΌ ν©λλ€. μ¦, ν μ€νΈκ° μ§νλ¨μ λ°λΌ DB μνκ° μ§μμ μΌλ‘ λ³ν΄ λμΌν μ΄κΈ° μνλ₯Ό 보μ₯νμ§ μκΈ° λλ¬Έμ μΆ©λΆν ν μ€νΈ κ²©λ¦¬κ° μ΄λ£¨μ΄μ‘λ€κ³ λ³Ό μ μμ΅λλ€.
μ΄λ² κΈμμ μΈμ ν μ€νΈλ₯Ό μνν λ ν μ€νΈλ₯Ό 격리νλ λ€μν λ°©λ²μ λν΄ μμλ³΄κ² μ΅λλ€.
Acceptance Test Isolation
@Transactional
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@Transactional
class AcceptanceTest {
}
ν΅ν© ν
μ€νΈλ₯Ό μμ±νλ―μ΄ @Transactional
μ λΆμ¬ μΈμ ν
μ€νΈλ₯Ό μμ±ν΄λ³΄λ©΄ λ‘€λ°±μ΄ μνλμ§ μλ κ²μ νμΈν μ μμ΅λλ€. μ΄λ μΈμ ν
μ€νΈλ₯Ό μνν λ @SpringBootTest
μ port
λ₯Ό μ§μ νμ¬ ν
μ€νΈλ₯Ό μννκΈ° λλ¬ΈμΈλ°μ. port
λ₯Ό μ§μ νμ¬ μλ²λ₯Ό ꡬλνκ² λλ©΄ @Transactional
μ΄ μ μ©λ Test Thread μλ λ³κ°λ‘ ꡬλμ€μΈ μλ²κ° λ³λμ Thread μμ Spring Container λ₯Ό μ€νμμΌ νΈλμμ
μ 컀λ°νκΈ° λλ¬Έμ λ‘€λ°±μ΄ μνλμ§ μμ΅λλ€.
@DirtiesContext
@DirtiesContext
class AcceptanceTest {
}
κΈ°λ³Έμ μΌλ‘ Spring μ Context Caching μ ν΅ν΄ Bean μ μ¬μ¬μ©ν©λλ€. @DirtiesContext
λ μ΄λ° Bean λ€μ λν΄ Dirties λ₯Ό νμΈνκ³ Context λ₯Ό μλ‘ λ‘λν©λλ€. μ¦, Context κ° μ¬κ΅¬μ±λ λλ§λ€ ν
μ΄λΈλ μ¬κ΅¬μ±νμ¬ μ΄κΈ° μνλ₯Ό 보μ₯νλ λ°©λ²μ
λλ€.
μ΄λ Έν μ΄μ μΆκ°λ§μΌλ‘ ν μ€νΈ κ²©λ¦¬κ° μ΄λ£¨μ΄μ Έ μ¬μ©μ΄ κ°νΈνμ§λ§ λ§€λ² Context λ₯Ό μλ‘ λ‘λνκΈ° λλ¬Έμ μκ°μ΄ μ€λκ±Έλ¦°λ€λ λ¨μ μ΄ μ‘΄μ¬νμ¬ ν¬κ² μΆμ²νμ§ μλ λ°©λ²μ λλ€.
Context λ₯Ό λ§€λ² μλ‘ λ‘λν νμ μμ΄ DB λ§ μ΄κΈ°ν μμΌμ£Όλ κ²μ΄ λͺ©μ μ΄κΈ° λλ¬Έμ @DirtiesContext
λ³΄λ¨ λ€λ₯Έ λ°©λ²μ΄ λ μ’μ μ νμ§μΌ μ μμ΅λλ€.
ν μ€νΈ μν ν μ§μ μμ
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
class AcceptanceTest {
@Autowired
private UserRepository userRepository;
@BeforeEach
void beforeEach() {
if (RestAssured.port == UNDEFINED_PORT) {
RestAssured.port = port;
}
userRepository.save(new User("quaritch"));
}
// ...
@AfterEach
void afterEach() {
userRepository.deleteAll();
}
}
DB λ₯Ό μ΄κΈ°ννλ κ°μ₯ κΈ°λ³Έμ μΈ λ°©λ²μ 맀 ν
μ€νΈλ§λ€ μ΄κΈ° λ°μ΄ν°λ₯Ό μΈν
ν΄μ£Όκ³ ν
μ€νΈκ° μ’
λ£λ λ λ§λ€ λ°μ΄ν°λ₯Ό μ κ±°ν΄μ£Όλ λ°©λ²μ
λλ€. JUnit μ @BeforeEach
μ @AfterEach
λ₯Ό μ¬μ©νμ¬ λ°μ΄ν° μμ± λ° μμ μμ²μ 보λ΄λ λ°©μμ μκ°ν΄ λ³Ό μ μμ΅λλ€.
ν μ€νΈμ νμν λ°μ΄ν°κ° μ μ κ²½μ°, κ°λ¨νκ² μνν μ μλ λ°©λ²μ΄κΈ° λλ¬Έμ λ§μ΄ μ¬μ©λλ λ°©μμ λλ€. νμ§λ§ μμ±ν λ°μ΄ν°κ° λ§μ κ²½μ° μμ±ν λ§νΌ μμ μμ²μ΄ μΆκ°λλ©° μ°κ΄κ΄κ³ 맀νμ΄ λμ΄μμΌλ©΄ λλ©μΈ μ§μμ΄ μλ μ¬λμ΄ ν μ€νΈλ₯Ό μμ±ν λ μ°κ΄κ΄κ³λ‘ μμ±λλ μν°ν°λ₯Ό μΆμ νκΈ° μ΄λ €μ°λ©° μΈλν€ λ± μ μ½ μ‘°κ±΄μ λ°λΌ μμ κ° μ΄λ¬μΈ μ μλ λΉν¨μ¨μ΄ μ‘΄μ¬ν©λλ€.
ν μ€νΈ μν ν TRUNCATE λ‘ ν μ΄λΈ μ΄κΈ°ν
TRUNCATE
λͺ
λ Ήμ΄λ₯Ό μ¬μ©νλ©΄ μμ μκ°λ λ°©λ²λ€ λ³΄λ€ ν¨μ¬ κ°νΈνκ² DB λ₯Ό μ΄κΈ°νν μ μμ΅λλ€.
API μμ²μ λ³΄λΌ νμλ μκ³ DELETE
μ λ€λ₯΄κ² SELECT
κ° νμνμ§ μκΈ° λλ¬Έμ μλμ μΌλ‘ μ μ μκ°μ ν
μ΄λΈμ μ΄κΈ°νν μ μλ μ₯μ μ΄ μμ΅λλ€.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@Sql("truncate.sql")
class AcceptanceTest {
}
λ¨Όμ @Sql
μ΄λ
Έν
μ΄μ
μ ν΅ν΄ TRUNCATE
쿼리λ₯Ό μ€ννλ λ°©μμ
λλ€. ν
μ€νΈ ν΄λμ€κ° μ€νλκΈ° μ μ @Sql
μ μ μΈλ κ²½λ‘μ μλ SQL μ΄ λ¨Όμ μ€νλ©λλ€. ν΄λΉ νμΌ μμ λͺ¨λ ν
μ΄λΈμ λν TRUNCATE
λͺ
λ Ήμ΄λ₯Ό μμ±ν΄ λμΌλ©΄ νμΌ νλλ§μΌλ‘ ν
μ€νΈ 격리λ₯Ό μ΄λ€λΌ μ μμ΅λλ€.
νμ§λ§ μν°ν° νΉμ ν μ΄λΈμ΄ μΆκ°λ λ λ§λ€ νμΌμ μ§μ μμ ν΄μ£Όμ΄μΌ νλ€λ λ¨μ μ΄ μ‘΄μ¬ν©λλ€.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
class AcceptanceTest {
@Autowired
private EntityManager entityManager;
@AfterEach
void afterEach() {
// μ°κΈ° μ§μ° μ μ₯μμ λ¨μ 쿼리 μ€ν
entityManager.flush();
// ν
μ΄λΈ μ΄λ¦ μΆμΆ
List<String> tableNames = getTableNames();
// μ°Έμ‘° λ¬΄κ²°μ± λΉνμ±ν
executeQuery("SET REFERENTIAL_INTEGRITY FALSE");
for (String tableName : tableNames) {
// TRUNCATE μ€ν
executeQuery(String.format("TRUNCATE TABLE %s", tableName));
// ID κ° 1λ‘ μ΄κΈ°ν
executeQuery(String.format("ALTER TABLE %s AUTO_INCREMENT = 1", tableName));
}
// μ°Έμ‘° λ¬΄κ²°μ± μ¬νμ±ν
executeQuery("SET REFERENTIAL_INTEGRITY TRUE");
}
private List<String> getTableNames() {
return entityManager.getMetamodel()
.getEntities()
.stream()
.map(e -> e.getJavaType()
.getAnnotation(Table.class)
.name()
.toUpperCase())
.collect(Collectors.toList());
}
private void executeQuery(final String sql) {
entityManager.createNativeQuery(sql)
.executeUpdate();
}
}
λ λ²μ§Έ λ°©μμ EntityManager
λ JdbcTemplate
μ Bean μΌλ‘ μ£Όμ
λ°μ λͺ¨λ ν
μ΄λΈ μ΄λ¦μ μ‘°ννμ¬ κ° ν
μ€νΈκ° μμλ λ TRUNCATE
쿼리λ₯Ό μ€ννλ λ°©λ²μ
λλ€.
TRUNCATE
λͺ
λ Ήμ΄λ μΈλν€ λ±μΌλ‘ μΈν΄ μ μμ μΌλ‘ μ€νλμ§ μμ μ μκΈ° λλ¬Έμ μ°Έμ‘° 무결μ±μ λΉνμ±νν λ€ μ€ννκ³ μΆκ°μ μΌλ‘ AUTO_INCREMENT
λ₯Ό μ¬μ©νλ€λ©΄ ID κ°μ μ΄κΈ°νν΄μ£Όλ μμ
μμ μΆκ°ν΄μ€ μ μμ΅λλ€.
ν λ² λ§λ€μ΄ λμΌλ©΄ μν°ν°κ° μΌλ§λ μΆκ°λκ³ μμ λλμ§μ κ΄κ³μμ΄ μλμΌλ‘ ν μ΄λΈμ μ‘°ννμ¬ λͺ¨λ μ΄κΈ°ννκΈ° λλ¬Έμ κ°μ₯ ν¨κ³Όμ μΌλ‘ μΈμ ν μ€νΈλ₯Ό 격리ν μ μλ λ°©λ²μ λλ€.
ν΄λΉ λ°©λ²μ ν΅ν΄ κ΄λ¦¬ ν¬μΈνΈλ₯Ό μ€μ΄λ©° μΈμ ν μ€νΈ μ μΆ©λΆν 격리μ±μ ν보νμ΅λλ€. νμ§λ§ μ¬μ ν ν μ€νΈ ν΄λμ€κ° μΆκ°λ λλ§λ€ ν΄λΉ μ½λλ₯Ό μ¬μ©ν΄μΌ νκΈ° λλ¬Έμ μ€λ³΅μ΄ λ°μν μ μμ΅λλ€. ν μ€νΈ μ½λλ₯Ό κ³ λννμ¬ λ¬Έμ μ μ ν΄κ²°ν΄λ³΄κ² μ΅λλ€.
Advancement
Spring μ TestExecutionListener μ¬μ©
public class AcceptanceTestExecutionListener implements TestExecutionListener {
private EntityManager entityManager;
@Override
public void afterTestMethod(final TestContext testContext) {
this.entityManager = testContext.getApplicationContext()
.getBean(EntityManager.class);
List<String> tableNames = getTableNames();
executeQuery("SET FOREIGN_KEY_CHECKS = 0");
for (String tableName : tableNames) {
executeQuery(String.format("TRUNCATE TABLE %s", tableName));
executeQuery(String.format("ALTER TABLE %s AUTO_INCREMENT = 1", tableName));
}
executeQuery("SET FOREIGN_KEY_CHECKS = 1");
}
// ...
}
Spring μ ν
μ€νΈμ νΉμ μμ μ κ°μ
ν μ μλ 리μ€λ μΈν°νμ΄μ€μΈ TestExecutionListener
λ₯Ό μ 곡ν©λλ€. afterTestExecution()
λλ afterTestMethod()
λ₯Ό ν΅ν΄ ν
μ€νΈ μ€ν ν κ°μ
ν μ μμ΅λλ€.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@Retention(RetentionPolicy.RUNTIME)
@TestExecutionListeners(value = {AcceptanceTestExecutionListener.class,}, mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS)
public @interface AcceptanceTest {
}
@TestExecutionListers
λ₯Ό ν΅ν΄ μμ κ°μ΄ λ±λ‘ν΄μ€ μ μμ΅λλ€. λͺ¨λ ν
μ€νΈ ν΄λμ€μ λΆμ΄λ λ²κ±°λ‘μμ μ€μ΄κΈ° μν΄ μ»€μ€ν
μ΄λ
Έν
μ΄μ
μ μ μΈν΄μ μ¬μ©ν΄μ£Όλ©΄ @AcceptanceTest
λ§μ λΆμ¬ κ°νΈνκ² μ¬μ©ν μ μμ΅λλ€.
JUnit5 μ Extension μ¬μ©
public class DatabaseCleanerExtension implements AfterEachCallback {
private EntityManager entityManager;
@Override
public void afterEach(final ExtensionContext context) {
this.entityManager = SpringExtension.getApplicationContext(context)
.getBean(EntityManager.class);
List<String> tableNames = getTableNames();
executeQuery("SET FOREIGN_KEY_CHECKS = 0");
for (String tableName : tableNames) {
executeQuery(String.format("TRUNCATE TABLE %s", tableName));
executeQuery(String.format("ALTER TABLE %s AUTO_INCREMENT = 1", tableName));
}
executeQuery("SET FOREIGN_KEY_CHECKS = 1");
}
// ...
}
JUnit5 μμ ν
μ€νΈμ νΉμ μμ μ κ°μ
ν μ μλ Extension
μΈν°νμ΄μ€λ₯Ό μ 곡ν©λλ€. Extension
μΈν°νμ΄μ€λ₯Ό νμ₯ν μΈν°νμ΄μ€ μ€ ν
μ€νΈ μ€ν μ§νμ κ°μ
ν μ μλ AfterEachCallback
μΈν°νμ΄μ€λ₯Ό ꡬννμ¬ DB μ μ΄κΈ° μνλ₯Ό 보μ₯ν μ μμ΅λλ€.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith({DatabaseCleanerExtension.class})
public @interface AcceptanceTest {
}
@ExtendWith
λ₯Ό ν΅ν΄ μμ κ°μ΄ λ±λ‘ν΄μ€ μ μμ΅λλ€. μμ 컀μ€ν
μ΄λ
Έν
μ΄μ
μ ν΅ν΄ κ°νΈνκ² μ¬μ©ν μ μλλ‘ λ§λ€μ΄μ€ μ μμ΅λλ€.
DatabaseCleaner μμ±
μμ§λ κ°μ μ μ΄ λ³΄μ
λλ€. ν
μ€νΈλ§λ€ μ΄λ£¨κ³ μΆμ κ²μ TRUNCATE
μΌ λΏμΈλ° λ§€λ² Context μμ EntityManager
λ₯Ό κ°μ Έμμ ν
μ΄λΈ μ΄λ¦μ μΆμΆνλ κ³Όμ μ΄ ν¬ν¨λμ΄ μμ΅λλ€.
Spring μ μ’ λ Spring λ΅κ² μΈ μ μλλ‘ DatabaseCleaner
λΌλ Component λ₯Ό λ§λ€μ΄ Bean μΌλ‘ λ±λ‘μμΌμ€μλ€.
@Component
public class DatabaseCleaner {
@Autowired
private EntityManager entityManager;
private List<String> tableNames;
@PostConstruct
public void afterPropertiesSet() {
this.tableNames = entityManager.getMetamodel()
.getEntities()
.stream()
.map(e -> e.getJavaType()
.getAnnotation(Table.class)
.name()
.toUpperCase())
.collect(Collectors.toList());
}
@Transactional
public void truncateTables() {
entityManager.flush();
executeQuery("SET REFERENTIAL_INTEGRITY FALSE");
for (String tableName : tableNames) {
executeQuery(String.format("TRUNCATE TABLE %s", tableName));
executeQuery(String.format("ALTER TABLE %s AUTO_INCREMENT = 1", tableName));
}
executeQuery("SET REFERENTIAL_INTEGRITY TRUE");
}
private void executeQuery(final String sql) {
entityManager.createNativeQuery(sql)
.executeUpdate();
}
}
DatabaseCleaner
λΌλ Component λ₯Ό μμ±ν¨μΌλ‘μ¨ Spring μ DI λ₯Ό νμ©νμ¬ EntityManager
λ₯Ό κ°μ Έμ¬ μ μκ² λμκ³ @PostConstruct
λ₯Ό ν΅ν΄ Bean μμ± μ ν
μ΄λΈ μ΄λ¦μ ν λ²λ§ μΆμΆνμ¬ μ¬μ©ν μ μκ² λμμ΅λλ€. λν, @Transactional
μ μΆκ°νμ¬ TRUNCATE
μ²λ¦¬μ μμμ± μμ 보μ₯ν μ μκ² λμμ΅λλ€.
public class DatabaseCleanerExtension implements AfterEachCallback {
@Override
public void afterEach(final ExtensionContext context) {
DatabaseCleaner databaseCleaner = SpringExtension.getApplicationContext(context)
.getBean(DatabaseCleaner.class);
databaseCleaner.truncateTables();
}
}
μ΄μ DatabaseCleaner
λ§ κ°μ Έμμ λ©μλ νλλ§ μ€ννλ κ²μΌλ‘ μΈμ ν
μ€νΈ κ²©λ¦¬κ° μμ±λ©λλ€.
λ§λ¬΄λ¦¬
μΈμ ν
μ€νΈλ μ€μ μ ν리μΌμ΄μ
μ΄ μλΉμ€ λλ κ²κ³Ό κ°μ₯ λΉμ·ν νκ²½μμ μ΄λ£¨μ§λ ν
μ€νΈμΈ λ§νΌ μμ μ±μλ ν
μ€νΈ μ½λ ꡬμΆμ΄ λ§€μ° μ€μν©λλ€. @DirtiesContext
λΆν° TRUNCATE
쿼리 μ¬μ©, Spring μ TestExecutionListener
μ JUnit5 μ Extension
κΉμ§ λ€μν λ°©λ²μ ν΅ν΄ μΈμ ν
μ€νΈλ₯Ό 격리νλ λ°©λ²μ λν΄ μμ보μμ΅λλ€. TestExecutionListener
μ Extension
μ μ°¨μ΄μ λν΄ μΆκ°μ μΌλ‘ νμ΅νμ¬ μ΄λ€μ μ΄ λ€λ₯΄κ³ μ΄λ€ μν©μ μ¬μ©νλ κ²μ΄ μ’μμ§ κΉμ§ μμ보면 μ’μ κ² κ°μ΅λλ€.