어떤 정규화된 방법을 사용하는 것이 아닌 제 마음대로 작성한 것이라서 기존에 사용하시던 분들은 좀 다를 수 있습니다. 혹시 부족한 점이 보일 경우 댓글로 알려주시면 정말 감사드립니다.
JPA로만 개발을 진행하다가 JDBC로 개발도 경험해보는 것이 좋을 것 같아 개발을 진행하던 중, TDD라는 개념이 떠올라 가짜 TDD도 경험해보면 좋을 것 같아서 TDD를 공부하기 전에 한번 진행해보았습니다.
프로젝트가 Controller - Service - Repository로 구성되어 기존 트랜잭션은 Repository에 구성할 생각이였습니다. 따라서 Repository의 트랜잭션 또한 제대로 구성이 되어있는지 확인을 거쳐야하여 테스트 환경에 따로 트랜잭션을 넣으면 안되는 상황이였습니다.
@JdbcTest - Jdbc로 테스트 환경을 구축하기에 알맞은 의존성을 주입받을 수 있도록 도와줍니다.@Import(TodoDaoImpl.class) - TodoDaoImpl을 단위 테스트하는 클래스로 TodoDaoImpl을 가져올 수 있게합니다.@TestInstance(TestInstance.Lifecycle.PER_CLASS) - 테스트 단위를 메서드가 아닌 클래스로 지정합니다. (사용한 이유를 추후 설명드리겠습니다)DataSource를 사용하여 Connection을 만들어 데이터베이스와 연결하고 테이블 생성을 하는 코드입니다.DataSource를 사용해야하지만 BeforeAll어노테이션은 static 메서드를 받습니다. 하지만 테스트 단위를 메서드가 아닌 클래스 단위로 제작한다면 인스턴스가 클래스 단위로 생겨 static 메서드가 아닌 메서드에 BeforeAll어노테이션을 넣을 수 있게 됩니다.@BeforeAll
void init() throws SQLException {
try (Connection conn = dataSource.getConnection()) {
conn.createStatement().execute("CREATE TABLE todo (\n" +
" id INT AUTO_INCREMENT PRIMARY KEY,\n" +
" title VARCHAR(20) NOT NULL,\n" +
" content VARCHAR(200),\n" +
" state INT NOT NULL DEFAULT 0,\n" +
" create_at TIMESTAMP NOT NULL,\n" +
" update_at TIMESTAMP NOT NULL,\n" +
" delete_state INT NOT NULL DEFAULT 0\n" +
");");
}
}
@BeforeEach
void insert() {
try (Connection conn = dataSource.getConnection()) {
String sql = "INSERT INTO todo (title, content, state, create_at, update_at) VALUES (?, ?, ?, ?, ?)";
try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
for (Todo todo : testData) {
pstmt.setString(1, todo.getTitle());
pstmt.setString(2, todo.getContent());
pstmt.setInt(3, todo.getState());
pstmt.setTimestamp(4, Timestamp.valueOf(todo.getCreateAt()));
pstmt.setTimestamp(5, Timestamp.valueOf(todo.getUpdateAt()));
pstmt.addBatch();
}
pstmt.executeBatch();
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
@AfterEach
void delete() throws SQLException {
try (Connection conn = dataSource.getConnection()) {
conn.createStatement().execute("DELETE FROM todo");
}
}
@AfterAll
void end() throws SQLException {
try (Connection conn = dataSource.getConnection()) {
conn.createStatement().execute("DROP TABLE todo");
}
}
실력이 있으신 분들은 쉽게 찾으셨을 것입니다.
이러한 이유는 트랜잭션 처리, 테이블 Auto_Increase 번호 초기화 등 다양한 방법으로 해결할 수 있습니다. 하지만 테스트가 다양해지고 많아지면서 테스트를 하는 시간이 길어지고 이러한 테스트 환경이 무거워지는 것은 H2 데이터베이스를 사용한 이유에서 멀어졌습니다.
테스트에서 트랜잭션은 테스트 후 기존 변경사항을 모두 원래대로 되돌려주게 됩니다. 저는 이를 통해 코드를 정말 간단하게 바꿀 수 있다는 것을 깨달았습니다.
Transactional이 추가가 되었습니다.
@JdbcTest- JDBC 테스트 환경을 만들어주는 어노테이션입니다.@Import(TodoDaoImpl.class)- 테스트할 대상을 가져오는 어노테이션입니다.@Transactional- 트랜잭션을 통해 테스트 과정에서 생긴 변경사항을 초기화시킵니다.@TestInstance(TestInstance.Lifecycle.PER_CLASS)-DataSource를 가져오기 위한 어노테이션입니다. (위에서 설명함)
필드는 기본적으로 달라진 것이 없습니다.
- DataSource - 데이터베이스 테이블 생성등 초기 설정을 위해 사용되었습니다.
- TodoDaoImpl - 단위 테스트 대상입니다.
초기 환경 세팅이 중요해졌습니다. 매번 데이터를 제거하고 생성할 필요없이 처음 생성으로 계속 사용합니다.
따라서 테이블 생성, 데이터 추가를 한번에 진행했습니다.
@BeforeAll
void init() throws SQLException {
try (Connection conn = dataSource.getConnection()) {
conn.createStatement().execute("CREATE TABLE todo (\n" +
" id INT AUTO_INCREMENT PRIMARY KEY,\n" +
" title VARCHAR(20) NOT NULL,\n" +
" content VARCHAR(200),\n" +
" state INT NOT NULL DEFAULT 0,\n" +
" create_at TIMESTAMP NOT NULL,\n" +
" update_at TIMESTAMP NOT NULL,\n" +
" delete_state INT NOT NULL DEFAULT 0\n" +
");");
String sql = "INSERT INTO todo (title, content, state, create_at, update_at) VALUES (?, ?, ?, ?, ?)";
try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
for (Todo todo : testData) {
pstmt.setString(1, todo.getTitle());
pstmt.setString(2, todo.getContent());
pstmt.setInt(3, todo.getState());
pstmt.setTimestamp(4, Timestamp.valueOf(todo.getCreateAt()));
pstmt.setTimestamp(5, Timestamp.valueOf(todo.getUpdateAt()));
pstmt.addBatch();
}
pstmt.executeBatch();
}
}
}
똑같이 찝찝해서 테이블을 제거하고 테스트를 완료시켰습니다.
@AfterAll
void end() throws SQLException {
try (Connection conn = dataSource.getConnection()) {
conn.createStatement().execute("DROP TABLE todo");
}
}
@Test
void 전체_조회() {
// when
List<TodoTitleDto> todos = todoDao.findAll();
// then
assertEquals(5, todos.size());
}
@Test
void 상태_조회() {
// when
List<TodoTitleDto> ready = todoDao.findByState(0);
List<TodoTitleDto> progress = todoDao.findByState(1);
List<TodoTitleDto> done = todoDao.findByState(2);
// then
assertEquals(1, ready.size());
assertEquals(2, progress.size());
assertEquals(2, done.size());
}
@Test
void 기간을_정해서_조회() {
// given
LocalDate start = LocalDate.of(2025, 3, 28);
LocalDate end = LocalDate.of(2025, 3, 30);
PeriodDto periodDto = new PeriodDto(start, end);
// when
List<TodoTitleDto> todos = todoDao.findByPeriod(periodDto);
// then
assertEquals(3, todos.size());
}
Repeatation을 통해 여러번 생성하여 테스트 환경이 제대로 구성되었는지 확인@RepeatedTest(3)
void 할일_추가() {
//given
todoDao.postTodo(new PostRequestDto("테스트 1", "테스트 내용 1"));
todoDao.postTodo(new PostRequestDto("테스트 2", "테스트 내용 2"));
todoDao.postTodo(new PostRequestDto("테스트 3", "테스트 내용 3"));
todoDao.postTodo(new PostRequestDto("테스트 4", "테스트 내용 4"));
todoDao.postTodo(new PostRequestDto("테스트 5", "테스트 내용 5"));
// when
List<TodoTitleDto> todos = todoDao.findAll();
// then
assertEquals(10, todos.size());
}
ParameterizedTest를 통해 다양한 성공하는 경우의 수를 제작하여 테스트하였습니다.@ParameterizedTest
@MethodSource("parameterPatchState")
@DisplayName("상태 변경 - 성공")
void patchStateSuccess(int id, int state) {
// given
// when
int count = todoDao.patchState(id, state);
TodoDetailDto todoDetailDto = assertDoesNotThrow(() -> todoDao.findById(id).orElseThrow());
// then
assertEquals(1, count);
assertEquals(state, todoDetailDto.getState());
}
private static Stream<Arguments> parameterPatchState() {
return Stream.of(
Arguments.of(1, 2), // 성공적
Arguments.of(1, 0) // 기존 state와 같은 경우
);
}
인터넷에 검색해도 Jpa를 사용한 테스트만 나와서 정말 다양한 시행착오를 겪은 것 같습니다. 테스트 환경을 제작하는 것에 있어 단순히 테스트를 만드는 것이 아닌 테스트 동작 과정과 트랜잭션 등 다양한 시도를 하는 과정에서 뜻깊은 경험을 할 수 있었던 것 같습니다.