Test Code

1hyung·2025년 5월 23일
post-thumbnail

테스트 코드란?

우리가 어떤 프로그램을 만들었다고 생각해 보세요. 예를 들어, 회원가입 기능을 만들었다면, 이 기능이 제대로 작동하는지 확인해야겠죠? 아이디가 중복될 때는 어떻게 되는지, 비밀번호가 너무 짧으면 어떻게 되는지 등 여러 상황을 직접 해보면서 확인해야 합니다.

실생활 비유:
요리사가 새로운 레시피로 음식을 만들었다고 가정해 봅시다. 이 요리사는 음식을 손님에게 내놓기 전에 맛을 보거나, 재료가 제대로 익었는지 확인하거나, 간이 맞는지 확인하는 과정을 거칩니다. 이때 요리사가 직접 맛을 보는 행위나 확인하는 과정이 바로 테스트입니다.

프로그래밍에서도 마찬가지입니다. 개발자가 만든 프로그램(기능)이 의도한 대로 정확하게 동작하는지, 예상치 못한 문제가 없는지 확인하는 과정이 필요합니다. 이 확인 과정을 자동화하기 위해 작성하는 코드가 바로 테스트 코드입니다.

즉, 테스트 코드는 우리가 만든 프로그램이 올바르게 동작하는지 검증하기 위해 작성하는 또 다른 코드입니다.

테스트 코드를 작성하는 이유 (왜 필요할까요?):

  1. 버그 조기 발견: 문제를 미리 찾아내 고치는 데 도움이 됩니다. 사람이 직접 테스트하면 놓칠 수 있는 부분도 테스트 코드는 놓치지 않을 가능성이 높습니다.
  2. 코드 품질 향상: 테스트를 고려하며 코드를 작성하면 더 깔끔하고 유지보수하기 좋은 코드를 만들게 됩니다. (클래스나 함수의 역할을 명확히 나누게 됨)
  3. 리팩토링(Refactoring)의 안전성: 코드를 개선하거나 변경할 때, 기존 기능이 망가지지 않았는지 테스트 코드를 통해 빠르게 확인할 수 있어 안심하고 변경할 수 있습니다.
  4. 개발 속도 향상: 처음에는 테스트 코드 작성에 시간이 걸리는 것 같지만, 장기적으로는 버그를 잡는 시간을 줄여주므로 전체 개발 속도를 높여줍니다.
  5. 새로운 기능 추가 용이: 기존 기능이 잘 작동하는지 알기 때문에 새로운 기능을 추가할 때 부담이 줄어듭니다.
  6. 협업 효율 증대: 다른 개발자가 내 코드를 이해하고 변경할 때, 테스트 코드를 통해 이 코드가 어떤 역할을 하는지 파악하기 쉬워집니다.

테스트 코드의 종류

테스트 코드는 그 목적과 범위에 따라 여러 종류로 나눌 수 있습니다. 주로 백엔드 개발에서 많이 사용하는 세 가지 주요 종류를 설명해 드릴게요. 피라미드 형태로 많이 비유되는데, 아래로 갈수록 더 넓은 범위를 테스트하고, 테스트 실행 속도는 느려지는 경향이 있습니다.

단위 테스트 (Unit Test)

  • 설명: 가장 작고 독립적인 코드 조각(단위)을 테스트하는 것입니다. 여기서 '단위'는 보통 함수(메서드) 하나나 클래스 하나가 됩니다. 다른 부분에 의존하지 않고, 해당 단위가 예상대로 작동하는지 검증하는 데 초점을 맞춥니다.

  • 실생활 비유: 붕어빵을 만드는 공장에서 붕어빵 틀 하나하나가 제대로 작동하는지, 밀가루 반죽 기계가 밀가루를 제대로 반죽하는지, 팥앙금 주입기가 팥앙금을 정확히 넣는지 각각의 부품을 따로따로 검사하는 것과 같습니다. 다른 부품과는 상관없이 이 부품 자체의 기능만 확인합니다.

  • 특징:

    • 빠른 실행 속도: 다른 시스템(데이터베이스, 네트워크 등)에 의존하지 않으므로 실행 속도가 매우 빠릅니다.
    • 오류 위치 파악 용이: 특정 단위에서 문제가 발생하면, 그 단위의 코드에 문제가 있음을 쉽게 알 수 있습니다.
    • 높은 격리성: 테스트 대상 코드가 다른 코드에 영향을 받지 않도록 격리(Isolation)하여 테스트합니다. 이를 위해 때로는 Mock(목) 객체를 사용하기도 합니다. (Mock 객체는 나중에 따로 설명해 드릴게요!)
  • 예시:

    • 사용자 정보를 저장하는 함수가 특정 문자열을 받았을 때 제대로 저장하는지
    • 계산기 앱에서 덧셈 함수가 1 + 2를 했을 때 3을 반환하는지
// 예시: 간단한 계산기 클래스
class Calculator {
    fun add(a: Int, b: Int): Int {
        return a + b
    }

    fun subtract(a: Int, b: Int): Int {
        return a - b
    }
}
// Calculator 클래스에 대한 단위 테스트 (JUnit 5 사용 예시)
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test

class CalculatorTest {

    private val calculator = Calculator() // 테스트 대상 객체 생성

    @Test // 이 메서드가 테스트 메서드임을 나타냅니다.
    @DisplayName("두 숫자의 덧셈을 올바르게 수행해야 한다") // 테스트의 목적을 설명
    fun testAdd() {
        // given (준비): 테스트를 위한 초기 값 설정
        val num1 = 5
        val num2 = 3
        val expected = 8 // 예상되는 결과 값

        // when (실행): 테스트 대상 메서드를 호출
        val actual = calculator.add(num1, num2) // Calculator의 add 메서드 실행

        // then (검증): 실제 결과가 예상 결과와 같은지 확인
        assertEquals(expected, actual) // 예상 결과(8)와 실제 결과(actual)가 같은지 단언(assert)
    }

    @Test
    @DisplayName("두 숫자의 뺄셈을 올바르게 수행해야 한다")
    fun testSubtract() {
        val num1 = 10
        val num2 = 4
        val expected = 6

        val actual = calculator.subtract(num1, num2)

        assertEquals(expected, actual)
    }
}

주석 상세 설명:

  • @Test: 이 메서드가 JUnit 테스트 메서드임을 나타냅니다.
  • @DisplayName: 테스트 이름. 테스트 결과를 보고서로 볼 때 이 이름으로 표시됩니다.
  • private val calculator = Calculator(): 테스트할 Calculator 객체를 만듭니다.
  • // given (준비): 테스트를 시작하기 위한 환경을 준비하는 부분입니다. 입력 값이나 예상 결과를 설정합니다.
  • // when (실행): 실제로 테스트하려는 코드(메서드)를 실행하는 부분입니다.
  • // then (검증): when에서 실행한 결과가 given에서 예상한 결과와 일치하는지 확인하는 부분입니다. assertEquals는 JUnit에서 제공하는 '단언(Assertion)' 메서드 중 하나로, 두 값이 같은지 비교합니다.

통합 테스트 (Integration Test)

  • 설명: 여러 개의 단위(클래스, 모듈)들이 함께 작동할 때 올바르게 통신하고 협력하는지 확인하는 테스트입니다. 예를 들어, 웹 애플리케이션에서 컨트롤러(Controller)가 서비스(Service)를 호출하고, 서비스가 데이터베이스(Database)에 접근하는 전체 흐름을 테스트할 수 있습니다.
  • 실생활 비유: 붕어빵 공장에서 밀가루 반죽 기계, 팥앙금 주입기, 굽는 기계 등 여러 부품을 조립해서 실제로 붕어빵을 만들어 보는 과정입니다. 각 부품은 개별적으로 잘 작동하더라도, 서로 연결되었을 때 문제가 발생할 수 있습니다. (예: 팥앙금이 너무 많이 주입되어 반죽이 터지는 경우)
  • 특징:
    • 실제 환경에 가깝게 테스트: 데이터베이스, 외부 API 등 실제 의존성을 사용하거나, 적어도 실제와 유사한 환경을 구성하여 테스트합니다.
    • 실행 속도 느림: 단위 테스트보다 훨씬 느립니다. 데이터베이스 연결, 네트워크 통신 등이 포함되기 때문입니다.
    • 오류 위치 파악 어려움: 여러 단위가 엮여 있기 때문에 테스트 실패 시 어디에서 문제가 발생했는지 파악하는 데 시간이 더 걸릴 수 있습니다.
  • 예시:
    • 회원가입 API를 호출했을 때, 사용자 정보가 데이터베이스에 성공적으로 저장되는지
    • 상품 주문 시 재고가 감소하고 결제가 정상적으로 처리되는지
// 예시: 간단한 사용자 서비스와 리포지토리
// User.kt
data class User(val id: Long, val name: String, val email: String)

// UserRepository.kt (데이터베이스와 상호작용하는 역할이라고 가정)
interface UserRepository {
    fun save(user: User): User
    fun findById(id: Long): User?
}

// MemoryUserRepository.kt (데이터베이스를 Mocking했다고 가정)
class MemoryUserRepository : UserRepository {
    private val users = mutableMapOf<Long, User>()
    private var nextId: Long = 1

    override fun save(user: User): User {
        val userToSave = user.copy(id = nextId++)
        users[userToSave.id] = userToSave
        return userToSave
    }

    override fun findById(id: Long): User? {
        return users[id]
    }
}

// UserService.kt (비즈니스 로직 처리)
class UserService(private val userRepository: UserRepository) {
    fun registerUser(name: String, email: String): User {
        // 실제로는 이메일 중복 확인 등의 로직이 있을 수 있습니다.
        val newUser = User(0, name, email) // ID는 DB에서 부여한다고 가정
        return userRepository.save(newUser)
    }

    fun findUser(id: Long): User? {
        return userRepository.findById(id)
    }
}
// UserService와 UserRepository를 사용하는 통합 테스트 예시 (JUnit 5 사용)
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test

class UserServiceIntegrationTest {

    private lateinit var userRepository: MemoryUserRepository // MockDB 역할을 하는 리포지토리
    private lateinit var userService: UserService             // 테스트 대상 서비스

    @BeforeEach // 각 테스트 메서드가 실행되기 전에 호출되는 메서드
    fun setUp() {
        userRepository = MemoryUserRepository() // 매 테스트마다 새로운 MockDB 인스턴스 생성
        userService = UserService(userRepository) // 새로운 UserService 인스턴스 생성
    }

    @Test
    @DisplayName("사용자 등록 후 ID로 조회 시 해당 사용자가 반환되어야 한다")
    fun testRegisterAndFindUser() {
        // given
        val userName = "홍길동"
        val userEmail = "hong@example.com"

        // when
        val registeredUser = userService.registerUser(userName, userEmail) // 사용자 등록
        val foundUser = userService.findUser(registeredUser.id)           // 등록된 사용자 조회

        // then
        assertNotNull(registeredUser.id) // 등록된 사용자의 ID가 null이 아니어야 함
        assertNotNull(foundUser)         // 조회된 사용자가 null이 아니어야 함
        assertEquals(userName, foundUser?.name)   // 조회된 사용자의 이름이 예상과 같아야 함
        assertEquals(userEmail, foundUser?.email) // 조회된 사용자의 이메일이 예상과 같아야 함
    }
}

주석 상세 설명:

  • @BeforeEach: 이 메서드는 각 @Test 메서드가 실행되기 직전에 항상 호출됩니다. 테스트 간의 독립성을 보장하기 위해 새로운 객체를 생성하는 데 많이 사용됩니다.
  • MemoryUserRepository: 실제 데이터베이스 대신 메모리에 데이터를 저장하는 '가짜' 리포지토리입니다. 통합 테스트에서는 실제 데이터베이스를 연결하는 경우가 많지만, 예시를 위해 가볍게 만들었습니다.
  • userService.registerUser(...), userService.findUser(...): UserService와 UserRepository가 함께 작동하는 흐름을 테스트합니다.
  • assertNotNull: 해당 값이 null이 아닌지 확인합니다.

E2E (End-to-End) 테스트 / 인수 테스트 (Acceptance Test)

  • 설명: 사용자 관점에서 애플리케이션의 처음부터 끝까지 전체 흐름을 테스트하는 것입니다. 프론트엔드부터 백엔드, 데이터베이스까지 모든 구성 요소가 실제 환경에서처럼 상호작용하는지 확인합니다. 웹 애플리케이션의 경우, 사용자가 웹 브라우저를 통해 서비스를 이용하는 것과 동일하게 테스트합니다.
  • 실생활 비유: 붕어빵 공장에서 실제로 붕어빵을 만들어 손님에게 판매하는 과정을 테스트하는 것입니다. 주문이 들어오면 제대로 만들어서 포장하고, 손님에게 전달하는 전체 서비스 흐름이 원활한지 확인합니다.
  • 특징:
    • 가장 실제와 유사: 실제 사용자 경험을 가장 잘 반영합니다.
    • 실행 속도 매우 느림: 전체 시스템을 가동하고 상호작용해야 하므로 시간이 가장 오래 걸립니다.
    • 환경 구축 복잡: 실제 운영 환경과 유사한 환경을 구축해야 하므로 설정이 복잡할 수 있습니다.
    • 오류 위치 파악 가장 어려움: 전체 시스템에서 문제가 발생한 것이므로 어디가 원인인지 찾기 어렵습니다.
  • 예시:
    • 사용자가 웹사이트에서 회원가입 버튼을 클릭하고, 정보 입력 후 회원가입을 완료했을 때, 로그인까지 성공하는지
    • 장바구니에 상품을 담고, 결제까지 완료했을 때, 주문 내역에 해당 상품이 제대로 표시되는지

참고: E2E 테스트는 시스템 전체를 테스트하는 것을 의미하고, 인수 테스트는 사용자의 요구사항(Acceptance Criteria)이 충족되는지를 검증하는 것에 초점을 맞춥니다. E2E 테스트가 인수 테스트의 한 종류가 될 수 있습니다.

테스트 코드의 방식 (TDD, DDD)

테스트 코드를 작성하는 방법론적인 접근 방식도 있습니다.

TDD (Test-Driven Development) - 테스트 주도 개발

  • 설명: TDD는 코드를 작성하기 전에 테스트 코드를 먼저 작성하는 개발 방법론입니다. "실패하는 테스트를 먼저 만들고, 그 테스트를 통과하기 위한 최소한의 코드를 작성한 다음, 코드를 리팩토링(개선)하는" 반복적인 사이클을 따릅니다.
  • 단계:
    1. Red (실패): 새로 만들 기능에 대한 테스트 코드를 작성합니다. 아직 기능이 없으므로 이 테스트는 당연히 실패합니다.
    2. Green (성공): 테스트를 통과할 수 있는 최소한의 코드를 작성합니다. 이 코드는 완벽하지 않아도 됩니다.
    3. Refactor (개선): 테스트가 통과했다면, 작성된 코드를 더 깔끔하고 효율적으로 리팩토링합니다. 리팩토링 후에도 테스트가 여전히 통과하는지 확인합니다.
  • 실생활 비유:
    • 어떤 기계를 만들려고 하는데, "이 기계는 A라는 입력이 들어오면 B라는 결과가 나와야 한다"는 검사 기준(테스트)을 먼저 정의해 놓는 것입니다.
    • 그리고 그 검사 기준을 통과하는 가장 간단한 기계를 먼저 만들고(Green),
    • 마지막으로 이 기계를 더 효율적이고 예쁘게 다듬는(Refactor) 과정입니다.
  • 장점:
    • 명확한 목표 설정: 무엇을 만들어야 할지 명확해집니다.
    • 코드 품질 향상: 테스트하기 쉬운 코드를 만들게 되어 자연스럽게 모듈화가 잘 됩니다.
    • 버그 감소: 테스트를 통해 지속적으로 기능을 검증하므로 버그 발생률이 낮아집니다.
    • 문서화 효과: 테스트 코드 자체가 해당 기능의 사용법을 알려주는 문서 역할을 합니다.
  • 단점:
    • 초기 개발 속도 저하: 테스트 코드를 먼저 작성해야 하므로 초반에는 개발 속도가 느려질 수 있습니다.
    • 러닝 커브: TDD에 익숙해지는 데 시간이 필요합니다.

BDD (Behavior-Driven Development) - 행위 주도 개발

  • 설명: BDD는 TDD에서 한 단계 더 나아가, 비즈니스 관점에서 사용자의 행동(Behavior)을 중심으로 테스트를 작성하는 방법론입니다. 개발자뿐만 아니라 기획자, PM 등 비기술 직군도 이해할 수 있는 자연어 형식으로 테스트 시나리오를 작성하는 데 중점을 둡니다.
  • 형식 (Given-When-Then): BDD 테스트는 주로 Given-When-Then 구조를 따릅니다.
    • Given (주어진 상황): 테스트를 수행하기 위한 초기 상태 또는 전제 조건
    • When (특정 행동 발생): 사용자가 수행하는 행동 또는 시스템에 가해지는 이벤트
    • Then (예상되는 결과): 해당 행동 또는 이벤트 후에 시스템이 보여야 하는 예상 결과
  • 실생활 비유:
    • "Given 내가 온라인 서점에서 책을 구매하려고 할 때,
    • When 장바구니에 '클린 코드' 책을 담고 '구매하기' 버튼을 누르면,
    • Then 결제 페이지로 이동하고, 총 결제 금액에 '클린 코드' 책의 가격이 표시되어야 한다." 이런 식으로 실제 사용자의 행동을 시나리오화하여 테스트합니다.
  • 장점:
    • 비즈니스 요구사항 명확화: 개발자와 비즈니스 담당자 간의 소통을 돕고, 요구사항을 명확히 합니다.
    • 더 나은 기능 정의: 어떤 기능을 만들어야 할지 사용자 관점에서 더 잘 이해하게 됩니다.
    • 높은 가독성: 자연어 형식으로 작성되므로 테스트 시나리오를 이해하기 쉽습니다.
  • 단점:
    • TDD보다 더 넓은 범위의 이해와 팀원 간의 합의가 필요합니다.
    • 자동화 도구(Cucumber, Spock 등)의 학습이 필요할 수 있습니다.

백엔드 개발에서 추천하는 테스트 방식 및 전략

처음 백엔드 개발을 시작하는 분이라면, 현실적인 접근 방식으로 테스트를 시작하는 것이 중요합니다.

추천하는 전략: 테스트 피라미드 (Test Pyramid)
가장 이상적이고 효과적인 테스트 전략으로 "테스트 피라미드" 모델을 추천합니다.

  • 가장 아래층 (가장 넓고 많음): 단위 테스트 (Unit Tests)
    • 가장 많이 작성하고, 가장 빠르게 실행되어야 합니다.
    • 대부분의 비즈니스 로직(Service 레이어)과 유틸리티 클래스 등은 단위 테스트로 커버합니다.
    • 외부 의존성(DB, 외부 API)은 Mocking하여 테스트 대상 코드만 독립적으로 검증합니다.
  • 중간층: 통합 테스트 (Integration Tests)
    • 단위 테스트보다는 적게 작성하지만, 여전히 중요한 부분을 차지합니다.
    • 주요 컴포넌트(Controller-Service, Service-Repository) 간의 연동, 데이터베이스 연동 등을 테스트합니다.
    • 실제 데이터베이스나 메시지 큐 등과의 연동을 포함할 수 있습니다. Spring Boot에서는 @SpringBootTest 같은 어노테이션으로 쉽게 통합 테스트 환경을 구성할 수 있습니다.
  • 가장 위층 (가장 좁고 적음): E2E/인수 테스트 (E2E/Acceptance Tests)
    • 가장 적게 작성합니다. 핵심적인 사용자 흐름(Critical User Journeys)만 테스트합니다.
    • 실행 속도가 느리고 유지보수 비용이 높기 때문에 꼭 필요한 핵심 기능에 대해서만 작성하는 것이 효율적입니다.

이 전략의 핵심은:

  1. 빠른 피드백: 단위 테스트를 많이 작성하여 개발 중에 빠르고 자주 피드백을 받습니다.
  2. 비용 효율: 느린 테스트(통합, E2E)의 수를 최소화하여 전체 테스트 실행 시간을 줄이고, 유지보수 비용을 절감합니다.
  3. 정확한 문제 파악: 단위 테스트에서 오류를 발견하면 문제의 원인을 바로 찾을 수 있습니다.

Mocking (목킹)이란? (단위 테스트 보조 개념)

단위 테스트를 할 때 자주 등장하는 개념이 Mocking (목킹)입니다.

  • 설명: 테스트 대상이 되는 코드가 다른 외부 시스템(데이터베이스, 외부 API, 다른 클래스)에 의존하는 경우, 이 외부 시스템을 실제처럼 작동하지만 실제로는 가짜(Mock) 객체로 대체하는 것을 Mocking이라고 합니다.
  • 실생활 비유: 자동차의 엔진을 테스트하고 싶다고 가정해 봅시다. 실제 자동차에 엔진을 장착하고 테스트하는 것은 번거롭고 위험할 수 있습니다. 대신, 엔진의 입력(연료)과 출력(바퀴 구동)을 그대로 흉내 내는 가짜 바퀴나 가짜 연료 공급 장치를 연결해서 엔진만 따로 테스트하는 것이 Mocking입니다. 엔진 자체의 기능만 확인하는 데 집중할 수 있죠.
  • 프로그래밍에서의 Mocking:
    • UserService가 UserRepository(데이터베이스와 상호작용)에 의존한다고 할 때, UserService의 registerUser 메서드를 단위 테스트하고 싶으면 실제 데이터베이스에 연결할 필요가 없습니다.
    • 대신 UserRepository를 흉내 내는 MockUserRepository를 만들어서, save 메서드를 호출하면 특정 사용자 객체를 반환하도록 미리 '정의'해 줍니다. 이렇게 하면 UserService의 비즈니스 로직만 독립적으로 테스트할 수 있습니다.
  • 주요 라이브러리: Kotlin/Java 생태계에서는 Mockito나 MockK 같은 라이브러리를 사용하여 Mocking을 쉽게 구현할 수 있습니다.
// UserServiceTest.kt (Mocking을 사용한 단위 테스트 예시)
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito.* // Mockito 라이브러리 임포트

class UserServiceUnitTest {

    // Mock 객체 선언: UserRepository는 실제가 아닌 Mock 객체를 사용합니다.
    // @Mock 어노테이션은 MockitoJUnit.rule() 또는 MockitoAnnotations.openMocks(this)와 함께 사용
    private lateinit var mockUserRepository: UserRepository
    private lateinit var userService: UserService

    @BeforeEach
    fun setUp() {
        // Mockito를 사용하여 UserRepository의 Mock 객체를 생성합니다.
        mockUserRepository = mock(UserRepository::class.java)
        userService = UserService(mockUserRepository)
    }

    @Test
    @DisplayName("사용자 등록 시 UserRepository의 save 메서드가 호출되어야 한다")
    fun testRegisterUserCallsSave() {
        // given
        val userName = "류테스트"
        val userEmail = "test@example.com"
        // UserRepository의 save 메서드가 호출될 때 어떤 객체를 반환할지 정의합니다.
        // `any()`는 어떤 User 객체가 들어와도 매칭되도록 합니다.
        // `User(1L, userName, userEmail)`은 save가 리턴할 가짜 User 객체입니다.
        `when`(mockUserRepository.save(any(User::class.java)))
            .thenReturn(User(1L, userName, userEmail))

        // when
        val registeredUser = userService.registerUser(userName, userEmail)

        // then
        // verify: mockUserRepository의 save 메서드가 정확히 1번 호출되었는지 검증합니다.
        verify(mockUserRepository, times(1)).save(any(User::class.java))
        assertNotNull(registeredUser.id)
        assertEquals(userName, registeredUser.name)
    }

    @Test
    @DisplayName("존재하는 사용자 ID로 조회 시 해당 사용자 정보를 반환해야 한다")
    fun testFindUserById() {
        // given
        val userId = 1L
        val dummyUser = User(userId, "류조회", "find@example.com")
        // mockUserRepository의 findById 메서드가 userId로 호출될 때 dummyUser를 반환하도록 설정
        `when`(mockUserRepository.findById(userId)).thenReturn(dummyUser)

        // when
        val foundUser = userService.findUser(userId)

        // then
        assertNotNull(foundUser)
        assertEquals(userId, foundUser?.id)
        assertEquals("류조회", foundUser?.name)
        // verify: mockUserRepository의 findById 메서드가 userId로 정확히 1번 호출되었는지 검증합니다.
        verify(mockUserRepository, times(1)).findById(userId)
    }
}

주석 상세 설명:

  • mock(UserRepository::class.java): UserRepository 인터페이스의 가짜 객체(mockUserRepository)를 만듭니다. 이 객체는 실제 UserRepository처럼 보이지만, 실제 데이터베이스에 접근하지 않습니다.
  • when(mockUserRepository.save(any(User::class.java))).thenReturn(User(1L, userName, userEmail)):
    • mockUserRepository.save() 메서드가 어떤 User 객체(any(User::class.java))를 받아서 호출될 때,
    • thenReturn(User(1L, userName, userEmail)) 여기에 정의된 가짜 User 객체를 반환하도록 미리 행동을 정의(Stubbing)해주는 것입니다.
  • verify(mockUserRepository, times(1)).save(any(User::class.java)):
    • 테스트가 실행된 후, mockUserRepository의 save 메서드가 예상대로 정확히 한 번(times(1)) 호출되었는지 검증합니다.

테스트 코드를 언제 작성하면 좋을까요?

테스트 코드를 언제 작성해야 하는지에 대한 정답은 없지만, 가장 효과적인 시점과 일반적인 권장 사항은 있습니다. 앞서 설명드렸던 TDD(테스트 주도 개발)처럼 코드를 작성하기 전에 테스트를 먼저 작성하는 방식도 있고, 코드를 다 만들고 나서 테스트를 추가하는 방식도 있죠. 개발 상황과 팀 문화에 따라 다를 수 있지만, 제가 추천하는 몇 가지 시점은 다음과 같습니다.

새로운 기능 개발 시 (TDD를 추천)

새로운 기능을 개발할 때는 TDD (Test-Driven Development) 방식으로 테스트 코드를 먼저 작성하는 것을 가장 추천합니다.

  • 진행 방식:
    1. 새로 만들 기능에 대한 실패하는 테스트 코드를 먼저 작성합니다.
    2. 이 테스트를 통과시킬 수 있는 최소한의 기능 코드를 작성합니다.
    3. 테스트가 통과하면, 작성한 기능 코드를 리팩토링하여 개선합니다.
  • 장점:
    • 요구사항 명확화: 테스트를 먼저 작성하면서 해당 기능이 정확히 무엇을 해야 하는지 명확하게 정의할 수 있습니다. 마치 기능을 구현하기 위한 상세한 설계도를 먼저 그리는 것과 같아요.
    • 설계 개선: 테스트하기 쉬운 코드를 만들게 되므로, 자연스럽게 모듈화가 잘 되고 결합도가 낮은 좋은 설계를 유도합니다.
    • 버그 감소: 개발 과정에서 지속적으로 테스트를 실행하므로 버그를 초기에 발견하고 수정할 수 있습니다.
    • 안정적인 개발: 코드를 변경하거나 리팩토링할 때, 테스트 코드가 안전망 역할을 해주어 기능이 망가질 걱정을 덜 수 있습니다.

버그 수정 시

버그를 발견하여 수정할 때도 테스트 코드를 작성하는 것이 매우 중요합니다.

  • 진행 방식:
    1. 발견된 버그를 재현하는 실패하는 테스트 코드를 먼저 작성합니다. 이 테스트는 현재 버그 때문에 실패할 것입니다.
    2. 버그를 수정합니다.
    3. 수정이 완료되면, 작성한 테스트 코드를 다시 실행하여 테스트가 성공하는지 확인합니다.
  • 장점:
    • 재발 방지: 이전에 발생했던 버그가 다시 발생하지 않도록 방지합니다. 작성한 테스트 코드는 미래에 같은 버그가 재발할 경우 즉시 알려줄 것입니다.
    • 수정 검증: 버그가 올바르게 수정되었음을 객관적으로 증명할 수 있습니다.
    • 문제 이해: 버그를 재현하는 테스트를 작성하는 과정에서 버그의 근본 원인을 더 잘 이해하게 됩니다.

기존 코드 리팩토링 시

오래된 코드나 복잡한 코드를 개선(리팩토링)해야 할 때도 테스트 코드가 필수적입니다.

  • 진행 방식:
    1. 리팩토링할 기존 코드에 대한 테스트 코드가 없다면, 먼저 현재 동작을 검증할 수 있는 테스트 코드를 충분히 작성합니다. (이때는 기존 코드가 이미 있으므로 TDD의 Red-Green 사이클과는 조금 다를 수 있습니다.)
    2. 테스트가 모두 성공하는 것을 확인한 후, 안심하고 리팩토링을 진행합니다.
    3. 리팩토링 후에도 모든 테스트가 여전히 성공하는지 확인합니다.
  • 장점:
    • 안전한 변경: 기존 기능이 망가지지 않았는지 실시간으로 확인할 수 있으므로, 두려움 없이 코드를 개선할 수 있습니다.
    • 신뢰성 향상: 리팩토링된 코드가 여전히 올바르게 작동함을 보장합니다.
    • 미래 유지보수성: 리팩토링 과정에서 작성된 테스트 코드는 이후에도 해당 코드의 안정성을 보장하는 데 기여합니다.

코드 리뷰 전/배포 전

작성한 코드를 다른 개발자에게 리뷰 받기 전이나, 실제로 서비스에 배포하기 전에 테스트 코드를 실행하여 최종적으로 문제가 없는지 확인해야 합니다. CI/CD (Continuous Integration/Continuous Delivery) 파이프라인에 테스트 코드 실행을 자동화하는 것이 일반적입니다.

결론적으로
가장 이상적인 것은 새로운 기능을 만들 때부터 TDD처럼 테스트 코드를 먼저 작성하는 습관을 들이는 것입니다. 처음에는 시간이 더 걸리는 것처럼 느껴질 수 있지만, 장기적으로는 개발 속도, 코드 품질, 그리고 프로젝트의 안정성 측면에서 훨씬 더 큰 이점을 가져다줄 것입니다.

개발 초기부터 테스트 코드 작성을 병행하는 것이 중요하며, 테스트를 나중에 작성하는 것은 흔히 절대 안 하는 것으로 이어질 수 있다는 점을 기억해주세요. 처음에는 작은 단위의 테스트부터 시작하여 점차 범위를 넓혀나가는 연습을 해보시는 것을 추천합니다.

profile
이유가 많은 사람보다 (자기)개발자가 되고싶은 1hyung입니다.

0개의 댓글