[테스트 자동화 1] service 테스트에서 롤백 목적으로 @Transactional 사용을 지양하자! 그런데 EntityManager를 활용해서 truncate를 시켜보자
프로덕션 코드에서 게시글 조회 로직이 있었습니다. 게시글을 조회할 때, 조회수 증가(update)가 있었지만 단순히 조회하는 로직이라고 생각했고 @Transactional(readOnly=true)옵션을 설정해주었습니다. 그리고 서비스 통합 테스트에서는 @Transactional을 통한 롤백을 사용했었습니다. 그래서 테스트에서는 정상적으로 조회수가 증가해서 이상이 없었지만 실제 QA할 때, 조회수가 증가하지 않는 버그를 발견했습니다. 이러한 문제를 어떻게 해결할까 고민을 했었고, 서비스 통합테스트에서 @Transactional을 제거해서 이와 같은 실수가 재발하는 것을 방지했습니다.
문제 상황
해당 메서드를 보고 단순히 조회니깐 readOnly=true를 붙이면 되겠다고 생각했다.
그리고 Service 테스트에서 @SpringBootTest와 @Transactional을 걸었다.
실제 테스트에서 조회수가 2번 증가하는 것을 확인했다. 하지만 프로덕션에서 돌렸을 때는 조회수가 증가하지 않았다!!!
왜냐하면 처음 위에 메서드는 내부에 조회수를 올리는 로직이 있는데 readOnly가 걸려있어서 더티체킹이 제대로 되지 않는 이슈였기 때문이다.
따라서 서비스 테스트에서 @Transactional을 걸면 Transactional에 대한 제대로 된 테스트가 어렵다는 것을 확인했다.
정리
프로덕션 코드에서 조회수 증가 로직에 Transactional을 잘못걸었지만 테스트시엔 serviceTest위에 @Transactional이 걸려있어서 테스트가 통과되었다.
하지만 실제 QA를 진행해보니 버그가 있다는 것을 발견했고, 테스트코드의 정확성을 높이기 위해 @Transactional을 제거하려고 한다.
추가
테스트를 어느 범위까지 하느냐가 @Transactional 설정 여부를 결정할수 있을 것 같다. 만약 Service layer의 테스트에서 '@Transactional 어노테이션의 동작까지 함께 테스트한다'를 목표로 한다면 @Transactional을 제거하고 하는 게 맞고,
Transactional을 잘 걸엇다고 자신이 있다면 @Trasanctional로 쉽게 테스트를 해도 될 것이다.
또한 @DataJpaTest할 때는 Transactional이 default로 걸려있는데 해당 부분은 단순히 JpaRepository의 기능을 테스트하기 위함이므로 @DataJpaTest를 이용하여 @Transactional의 rollback 기능을 사용해도 무관하다고 생각한다.
해결 방법
해결 방법은 여러 가지가 있을 것 같다.
그중 첫 번째는 @AfterEach 혹은 @BeforeEach로 모든 Repository에 대해서 deleteAll()을 실행해주는 것이다. 해당 방법은 Repository가 늘어날 때마다 추가해야 하는 단점이 있다.
두 번째로는 인수 테스트 격리에서 사용한 방법처럼 EntityManager를 이용한 Truncate 하는 방법이 있다.
@Component
public class DatabaseCleaner {
@PersistenceContext
private EntityManager entityManager;
private List<String> tableNames;
@PostConstruct
public void afterPropertiesSet() {
tableNames = entityManager.getMetamodel().getEntities().stream()
.filter(entityType -> entityType.getJavaType().getAnnotation(Entity.class) != null)
.map(entityType -> changeNaming(entityType.getName()))
.collect(Collectors.toList());
}
private String changeNaming(String entityName) {
StringBuilder tableName = new StringBuilder();
for (int index = 0; index < entityName.length(); index++) {
char ch = entityName.charAt(index);
if (index > 0 && Character.isUpperCase(ch)) {
tableName.append("_");
}
tableName.append(Character.toLowerCase(ch));
}
return tableName.toString();
}
@Transactional
public void tableClear() {
entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 0").executeUpdate();
for (String tableName : tableNames) {
entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
}
entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 1").executeUpdate();
}
해당 엔티티를 확인하면서 table이름으로 truncate를 시켜준다. 위와 같이 설정해준다면 Repository가 늘어날 때마다 코드를 수정해줄 일이 없어서 편해진다.
JPA만 국한된 기술이 아닌 JDBC를 이용할때도 아래처럼 metaData를 통해 truncate를 할수있다.
@Component
public class DatabaseCleaner {
private static final String TRUNCATE_QUERY = "TRUNCATE TABLE %s";
@Autowired
private DataSource dataSource;
private final List<String> tableNames = new ArrayList<>();
@PostConstruct
public void afterPropertiesSet() {
try (Connection connection = dataSource.getConnection();) {
DatabaseMetaData metaData = connection.getMetaData();
final ResultSet tables = metaData.getTables(null, null, null, new String[]{"TABLE"});
while (tables.next()) {
tableNames.add(tables.getString("TABLE_NAME"));
}
} catch (SQLException e) {
throw new NoSuchElementException();
}
tableNames.remove("flyway_schema_history");
}
@Transactional
public void clear() {
try (final Connection connection = dataSource.getConnection()) {
connection.prepareStatement("SET REFERENTIAL_INTEGRITY FALSE").execute();
for (String tableName : tableNames) {
connection.prepareStatement(String.format(TRUNCATE_QUERY, tableName)).execute();
}
connection.prepareStatement("SET REFERENTIAL_INTEGRITY TRUE").execute();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
Reference
https://junit.org/junit5/docs/5.0.3/api/org/junit/jupiter/api/extension/BeforeEachCallback.html
https://tecoble.techcourse.co.kr/post/2020-08-31-jpa-transaction-test/