Kotlin mockk와 TDD 적용기

jinhan han·2025년 3월 16일
post-thumbnail

TDD 테스트 주도개발 일반적인 구성과 개념

TDD가 필요한 이유와 역할

  • 테스트 가능성을 고려한 설계는 클래스간 지나친 결합도를 점검 가능
  • 가독성과 유지보수성을 높이기 위한 전략
  • 코드의 결과에 대해 빠른 피드백 가능
  • 반복 테스트를 주기적으로 할 수 있는 구조로 개발을 이루어 가는 패턴

일반적인 설계 후, TestCode 작업 플로우

유닛 테스트 구성 -> 통과 -> 운영 코드 구성 -> 통과 -> 최소한의 코드로 원래 설계 재구성 -> 유닛 테스트와 운영 코드 구성 -> 통과

큰 틀에서 본 테스트 주도 설계 프로세스

Red(실패하는 추상적 테스트 작성) → Green(빠르게 구현 후 테스트 통과) → Refactor(코드 리팩토링) 3단계
위 사이클은 회귀 반복하는 사이클

  • Arrange (준비) → 필요한 객체 생성 및 초기화
  • Act (실행) → 실제 테스트할 메서드 실행
  • Assert (검증) → 기대한 결과와 실제 결과 비교

테스트코드 함수의 구성은?
Given(주어진 정보) When(함수의 적용) then(이행된 함수의 평가) Verified(참,거짓 등의 입증)

아래는 예시 코드

@Test
    fun `findByTaskName() - 존재하는 Task 반환`() = runTest {
        // Given
        val taskName = "Test Task"
        val mockTask = Task(id = 1L, taskName = taskName, difficulty = 3)
        coEvery { taskJpaRepository.findByTaskName(taskName) } returns mockTask
        // When
        val result = taskRepository.findByTaskName(taskName)
        // Then
        assertNotNull(result)
        assertEquals(taskName, result?.taskName)
        // Verify
        coVerify(exactly = 1) { taskJpaRepository.findByTaskName(taskName) }
    }

이상적이라 생각하는 테스트 주도 개발의 TDD의 구체적인 프로세스

  1. 기본적인 Service에 해당하는 TestCode 구성 -> 2. 단위별로 테스트구성 -> 3. DAO 구성 -> 4. DTO 구성 -> 5. service 구성 -> 6. 서비스에 관련된 운영 테스트 코드 구성 -> 7. DAO 재구성 -> 8. DTO 재구성 -> 9. service 재구성 -> 1. 번으로 돌아가 반복하며 재구성

TDD(테스트주도 개발)의 생산성 저하를 보완하려면?

TDD(테스트주도 개발)의 단점 : 첫 구성에선 다소 난해할 수 있으며 정작 핵심 코드 이전에 테스트코드로 틀을 짠후에 구성하는 부분에서 생산성이 떨어지며 구체적인 구성이 안된상태에서 짠다면 여러모로 개발 시간에 타격을 준다. 개발하는 사람의 관점에서 본다면 개발자의 성취감에 영향을 주는 지루한 작업이기도 하다. 따라서 최대한 반복작업을 줄이고 성취감을 빨리 이룰수 있는 전략을 시행해야한다.

1. ATDD는 시스템 전 구간을 테스트하여 TDD 의존성에 대한 장애를 극복 가능


Acceptance Test 란?
시스템 내부 코드를 가능한 한 직접 호출하지 말고 시스템 전 구간을 테스트해야 한다. 즉, end-to-end 테스트
인수 테스트는 보통 기능 테스트, 고객 테스트, 시스템 테스트와 같은 용어로도 사용된다.

2. 도메인에 따라 간단한 유닛테스트들로 구성하여 점차 통합 테스트로 발전

  • 빠르게 생각나는 방향에 맞춰 유닛 테스트코드들을 도메인에 맞게 구성
  • 유닛 테스트를 기반으로 DAO 구성
  • 유닛 테스트를 기반으로 DTO 구성
  • DAO와 DTO 리패토링
  • DAO와 DTO의 통합 테스트 코드 작성
  • 위와 같은 방식으로 service 다음 controller(추천은 안함) 순으로 TDD 순환을 돌리며 작업

3. 하나의 테스트 코드의 함수로 여러가지 데이터를 테스트하지 않도록 최대한 분리

  • 의존성이 강한 코드가 나오는 것을 예방하기 위해 최대한 분리하여 테스트 코드 함수 개발
  • 테스트 코드의 특징상 연쇄적인 코드이기에 의존성이 강해지는 것을 예방하기위해 데이터를 분리
  • 혼동되는 코드나 비슷한 개념의 함수의 중복을 예방하기 위하여 분리

아래는 같은 컨셉의 데이터의 다른 특징을 열거하며 한번에 분리된 코드 작성

class RequirementsTest {
    @Nested
    @DisplayName("Requirements 변환 테스트")
    inner class ConversionTests {
        @Test
        fun `문자열을 리스트로 변환할 수 있다`() {
            // given
            val requirements = Requirements("A, B, C")  // 입력에 공백 포함
            // when
            val result = requirements.requirementsList
            // then
            assertThat(result).containsExactly("A", "B", "C")  // 결과는 trim된 값
        }
        @Test
        fun `공백이 있는 문자열도 올바르게 처리된다`() {
            // given
            val requirements = Requirements(" A ,B  ,  C ")
            // when
            val result = requirements.requirementsList
            // then
            assertThat(result).containsExactly("A", "B", "C")
        }
        @Test
        fun `빈 문자열은 빈 리스트로 변환된다`() {
            // given
            val requirements = Requirements("")
            // when
            val result = requirements.requirementsList
            // then
            assertThat(result).isEmpty()
        }
    }

mockk는 사용의 이유

Mock은 언제 사용하는가?

Mock은 테스트 대상 객체가 외부 의존성(DB, API, 파일 시스템 등)에 의존할 때, 실제 동작을 모방하여 독립적인 테스트를 가능하게 하는 기법

Mock을 사용하는 주요 상황

외부 시스템(데이터베이스, API, 파일 시스템 등)과 연동이 필요한 경우

  • 실제 API 호출 없이 응답을 시뮬레이션
  • DB 연동 없이 데이터 조회를 테스트 가능

테스트 환경에서 직접 제어하기 어려운 경우

  • 예를 들어, 현재 시간을 반환하는 함수(Mock을 이용하여 특정 시간을 설정 가능)
  • 랜덤 값을 생성하는 함수(Mock을 이용하여 고정된 값 반환 가능)

비용이 큰 연산이 포함된 경우

  • 대용량 데이터 처리, 복잡한 연산을 수행하는 로직이 있을 때 가짜 객체(Mock)를 사용하여 빠른 테스트 가능

독립적인 단위 테스트(Unit Test)를 수행해야 할 때

  • 네트워크, DB와 같은 외부 의존성이 있는 객체 없이 순수한 비즈니스 로직만 테스트 가능

아래는 mockk를 활용한 전체적인 테스트 코드 구성

@SpringBootTest
class TaskServiceTest {
    private lateinit var taskService: TaskService
    private val taskRepository: TaskRepository = mockk()
    private val taskMapper: TaskMapper = mockk()
    private val tasksHistoryRepository: TasksHistoryRepository = mockk()
    @BeforeEach
    fun setUp() {
        taskService = TaskService(taskRepository, taskMapper, tasksHistoryRepository)
    }
    /** 공통 테스트 데이터를 생성하는 함수 */
    private fun setupMockTask(id: Long, name: String, role: String, difficulty: Int, requirement: String): Task {
        return Task(
            id = id,
            taskName = name,
            employeeRoles = EmployeeRoles(role),
            difficulty = difficulty,
            requirements = Requirements(requirement)
        )
    }
    private fun createResponseDTO(task: Task): TaskResponseDTO {
        return TaskResponseDTO(
            id = task.id,
            taskName = task.taskName,
            difficulty = task.difficulty,
            employeeRoles = task.employeeRoles.roles.map { it.displayName },
            requirements = task.requirements?.requirementsList ?: emptyList()
        )
    }
    @Test
    fun `findAllTasks should return list of TaskResponseDTO`() = runBlocking {
        // Given
        val tasks = listOf(
            setupMockTask(1L, "Task 1", "Manager", 3, "Requirement 1"),
            setupMockTask(2L, "Task 2", "Developer", 5, "Requirement 2")
        )
        val taskDtos = tasks.map { createResponseDTO(it) }
        coEvery { taskRepository.findAll() } returns tasks
        every { taskMapper.toResponseDto(any()) } answers { createResponseDTO(firstArg()) }
        // When
        val result = taskService.findAllTasks()
        // Then
        assertEquals(taskDtos, result)
        coVerify { taskRepository.findAll() }
    }
    @Test
    fun `createTask should save and return TaskResponseDTO`() = runBlocking {
        // Given
        val request = TaskRequestDTO("New Task", 4, listOf(RoleType.DEVELOPER.toString()), listOf("Requirement A"))
        val task = setupMockTask(1L, "New Task", "Developer", 4, "Requirement A")
        val taskResponseDTO = createResponseDTO(task)
        every { taskMapper.toEntity(request) } returns task
        coEvery { taskRepository.save(task) } returns task
        every { taskMapper.toResponseDto(task) } returns taskResponseDTO
        // When
        val result = taskService.createTask(request)
        // Then
        assertEquals(taskResponseDTO, result)
        coVerify { taskRepository.save(task) }
    }
    @Test
    fun `updateTask should update and return updated TaskResponseDTO`() = runBlocking {
        // Given
        val request = TaskRequestDTO("Updated Task", 5, listOf(RoleType.DEVELOPER.toString()), listOf("Updated Requirement"))
        val existingTask = setupMockTask(1L, "Old Task", "Developer", 3, "Old Requirement")
        val updatedTask = setupMockTask(1L, "Updated Task", "Manager", 5, "Updated Requirement")
        val taskResponseDTO = createResponseDTO(updatedTask)
        coEvery { taskRepository.findById(1L) } returns existingTask
        coEvery { taskRepository.save(any()) } returns updatedTask
        every { taskMapper.toResponseDto(updatedTask) } returns taskResponseDTO
        // When
        val result = taskService.updateTask(1L, request)
        // Then
        assertEquals(taskResponseDTO, result)
        coVerify { taskRepository.save(any()) }
    }
    @Test
    fun `deleteTask should remove task`() = runBlocking {
        // Given
        val task = setupMockTask(1L, "Task to delete", "Tester", 3, "Test Req")
        coEvery { taskRepository.findById(1L) } returns task
        coEvery { taskRepository.delete(task) } just Runs
        // When
        taskService.deleteTask(1L)
        // Then
        coVerify { taskRepository.delete(task) }
    }
    @Test
    fun `findTaskById should return TaskResponseDTO if found`() = runBlocking {
        // Given
        val task = setupMockTask(1L, "Find Task", "Manager", 2, "Req A")
        val taskResponseDTO = createResponseDTO(task)
        coEvery { taskRepository.findById(1L) } returns task
        every { taskMapper.toResponseDto(task) } returns taskResponseDTO
        // When
        val result = taskService.findTaskById(1L)
        // Then
        assertEquals(taskResponseDTO, result)
        coVerify { taskRepository.findById(1L) }
    }
    @Test
    fun `findTaskByName should return TaskResponseDTO if found`() = runBlocking {
        // Given
        val task = setupMockTask(1L, "Task Name", "Engineer", 2, "Req B")
        val taskResponseDTO = createResponseDTO(task)
        coEvery { taskRepository.findByTaskName("Task Name") } returns task
        every { taskMapper.toResponseDto(task) } returns taskResponseDTO
        // When
        val result = taskService.findTaskByName("Task Name")
        // Then
        assertEquals(taskResponseDTO, result)
        coVerify { taskRepository.findByTaskName("Task Name") }
    }
}

TDD를 만들면서 피해야한다고 느낀점들

TDD(Test-Driven Development)는 강력한 개발 방법론이지만, 잘못된 방식으로 적용하면 오히려 비효율적인 개발 프로세스를 초래 합니다.

여기서 부터는 전적으로 이런 저런 글을 보고 배우며 느낀 제 개인 생각 입니다.

  • 모든 코드에 대해 테스트를 작성하려는 강박, 단순한 코드는 테스트 코드까지 만들 이유는 없다.
  • 지나치게 상세한 구현에 의존하는 테스트 작성은 피한다.
  • Mock을 과도하게 사용하는 것을 피하여 코드의 일치성을 유지한다.
  • 추상적인 테스트 코드부터 접근하여 발전 시킨다.

TDD는 코드의 균형과 올바른 개발 도입부를 구성하기 위한 시작으로써 너무 많은 시간을 들여 짜기보다도 간단히 만들며 사이클을 돌리는 도구로써만 보면 된다고 생각이 된다. 켄트백이 말한 TDD가 많은 개발자들에게 TDD 강박증을 심은게 아닌가하는 생각도 든다. 우리는 도구로써 TDD를 접근하는데 옳다고 생각된다.

profile
개발자+분석가+BusinessStrategist

0개의 댓글