JDBC를 사용해 단위 테스트 코드를 작성하자

비구름·2025년 4월 5일

Spring

목록 보기
7/10

어떤 정규화된 방법을 사용하는 것이 아닌 제 마음대로 작성한 것이라서 기존에 사용하시던 분들은 좀 다를 수 있습니다. 혹시 부족한 점이 보일 경우 댓글로 알려주시면 정말 감사드립니다.

JDBC로 테스트 코드를 작성하는 이유

JPA로만 개발을 진행하다가 JDBC로 개발도 경험해보는 것이 좋을 것 같아 개발을 진행하던 중, TDD라는 개념이 떠올라 가짜 TDD도 경험해보면 좋을 것 같아서 TDD를 공부하기 전에 한번 진행해보았습니다.

프로젝트 환경

  • JDK : 17
  • SpringBoot : 3.4.0

개발 환경

  • RDS : MariaDB

테스트 환경

  • RDS : H2
  • gson : 2.11.0
  • JUnit 5

테스트 환경 조성

트랜잭션을 사용하지 않는 환경 - 실패

프로젝트가 Controller - Service - Repository로 구성되어 기존 트랜잭션은 Repository에 구성할 생각이였습니다. 따라서 Repository의 트랜잭션 또한 제대로 구성이 되어있는지 확인을 거쳐야하여 테스트 환경에 따로 트랜잭션을 넣으면 안되는 상황이였습니다.

클래스 어노테이션

  • @JdbcTest - Jdbc로 테스트 환경을 구축하기에 알맞은 의존성을 주입받을 수 있도록 도와줍니다.
  • @Import(TodoDaoImpl.class) - TodoDaoImpl을 단위 테스트하는 클래스로 TodoDaoImpl을 가져올 수 있게합니다.
  • @TestInstance(TestInstance.Lifecycle.PER_CLASS) - 테스트 단위를 메서드가 아닌 클래스로 지정합니다. (사용한 이유를 추후 설명드리겠습니다)

필드

  • DataSource - 데이터베이스 테이블 생성등 초기 설정을 위해 사용되었습니다.
  • TodoDaoImpl - 단위 테스트 대상입니다.

BeforeAll

  • 기존 SQL문을 사용하기에 H2, MariaDB간의 문법 차이가 존재하였습니다. 따라서 기존 테이블과 유사하게 CREATE문을 제작하였습니다.
  • DataSource를 사용하여 Connection을 만들어 데이터베이스와 연결하고 테이블 생성을 하는 코드입니다.
  • static 변수가 아닌 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

  • 데이터 추가를 하는 코드입니다. 각 테스트를 진행하기 전 데이터를 추가하여 테스트 환경을 제작합니다.
@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

  • 테스트 환경을 초기화하기 위해서 기존 모든 데이터를 제거합니다.
@AfterEach
void delete() throws SQLException {
    try (Connection conn = dataSource.getConnection()) {
        conn.createStatement().execute("DELETE FROM todo");
    }
}

AfterAll

  • H2는 메모리를 사용하는 데이터베이스로 굳이 테이블을 삭제하지 않아도 괜찮지만 찝찝하기에 처리하였습니다.
@AfterAll
void end() throws SQLException {
    try (Connection conn = dataSource.getConnection()) {
        conn.createStatement().execute("DROP TABLE todo");
    }
}

실패 why?

실력이 있으신 분들은 쉽게 찾으셨을 것입니다.

  • ID 통일 X : 각 테스트를 진행하며 5개의 데이터를 넣으며 순서에 따라 1~5가 들어가는 테스트와 6 ~ 10으로 들어가는 테스트가 있습니다. 이러한 ID의 불규칙성은 테스트 환경에 악영향을 끼칠 것입니다.
  • 엇갈리는 순서 : 트랜잭션 처리가 제대로 되지않아 데이터가 없는 상태에서 진행을 하는 등 다양한 예외 요소가 발생하였습니다.

이러한 이유는 트랜잭션 처리, 테이블 Auto_Increase 번호 초기화 등 다양한 방법으로 해결할 수 있습니다. 하지만 테스트가 다양해지고 많아지면서 테스트를 하는 시간이 길어지고 이러한 테스트 환경이 무거워지는 것은 H2 데이터베이스를 사용한 이유에서 멀어졌습니다.

트랜잭션을 통한 일관된 환경 - 성공

테스트에서 트랜잭션은 테스트 후 기존 변경사항을 모두 원래대로 되돌려주게 됩니다. 저는 이를 통해 코드를 정말 간단하게 바꿀 수 있다는 것을 깨달았습니다.

클래스 어노테이션

Transactional이 추가가 되었습니다.

  • @JdbcTest - JDBC 테스트 환경을 만들어주는 어노테이션입니다.
  • @Import(TodoDaoImpl.class) - 테스트할 대상을 가져오는 어노테이션입니다.
  • @Transactional - 트랜잭션을 통해 테스트 과정에서 생긴 변경사항을 초기화시킵니다.
  • @TestInstance(TestInstance.Lifecycle.PER_CLASS) - DataSource를 가져오기 위한 어노테이션입니다. (위에서 설명함)

필드

필드는 기본적으로 달라진 것이 없습니다.

  • DataSource - 데이터베이스 테이블 생성등 초기 설정을 위해 사용되었습니다.
  • TodoDaoImpl - 단위 테스트 대상입니다.

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" +
                ");");

        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

똑같이 찝찝해서 테이블을 제거하고 테스트를 완료시켰습니다.

@AfterAll
void end() throws SQLException {
    try (Connection conn = dataSource.getConnection()) {
        conn.createStatement().execute("DROP TABLE todo");
    }
}

동작 과정

  1. 모든 테스트를 하나의 인스턴스에서 진행합니다.
  2. 테스트가 시작되기 전 테이블을 생성하고 데이터를 추가합니다.
  3. 트랜잭션이 시작됩니다.
  4. 테스트를 진행합니다.
  5. 트랜잭션 리롤을 통해 기존 2번의 단계로 되돌리고 다른 테스트로 3번으로 반복합니다.
  6. 모든 테스트가 끝났다면 테이블을 제거하고 테스트를 종료합니다.

단위 테스트 만들기

조회

모든 Todo 받기

  • 5개의 데이터를 넣고 5개 모두 조회가 성공하였는지 확인합니다.
@Test
void 전체_조회() {
    // when
    List<TodoTitleDto> todos = todoDao.findAll();

    // then
    assertEquals(5, todos.size());
}

조건을 통해서 검색 - 상태

  • 5개의 데이터를 조회하고 각 데이터 개수가 맞는지 확인합니다.
@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());
}

생성

Todo 생성

  • Repeatation을 통해 여러번 생성하여 테스트 환경이 제대로 구성되었는지 확인
  • 기존 5개에서 5개를 추가로 제작하여 10개가 조회가 되는지 확인
@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와 같은 경우
    );
}

코드

깃허브 - JDBC 테스트 코드

느낀점

인터넷에 검색해도 Jpa를 사용한 테스트만 나와서 정말 다양한 시행착오를 겪은 것 같습니다. 테스트 환경을 제작하는 것에 있어 단순히 테스트를 만드는 것이 아닌 테스트 동작 과정과 트랜잭션 등 다양한 시도를 하는 과정에서 뜻깊은 경험을 할 수 있었던 것 같습니다.

profile
기본부터 정리해나가는 기록용 블로그

0개의 댓글