우리가 어떤 프로그램을 만들었다고 생각해 보세요. 예를 들어, 회원가입 기능을 만들었다면, 이 기능이 제대로 작동하는지 확인해야겠죠? 아이디가 중복될 때는 어떻게 되는지, 비밀번호가 너무 짧으면 어떻게 되는지 등 여러 상황을 직접 해보면서 확인해야 합니다.
실생활 비유:
요리사가 새로운 레시피로 음식을 만들었다고 가정해 봅시다. 이 요리사는 음식을 손님에게 내놓기 전에 맛을 보거나, 재료가 제대로 익었는지 확인하거나, 간이 맞는지 확인하는 과정을 거칩니다. 이때 요리사가 직접 맛을 보는 행위나 확인하는 과정이 바로 테스트입니다.
프로그래밍에서도 마찬가지입니다. 개발자가 만든 프로그램(기능)이 의도한 대로 정확하게 동작하는지, 예상치 못한 문제가 없는지 확인하는 과정이 필요합니다. 이 확인 과정을 자동화하기 위해 작성하는 코드가 바로 테스트 코드입니다.
즉, 테스트 코드는 우리가 만든 프로그램이 올바르게 동작하는지 검증하기 위해 작성하는 또 다른 코드입니다.
테스트 코드는 그 목적과 범위에 따라 여러 종류로 나눌 수 있습니다. 주로 백엔드 개발에서 많이 사용하는 세 가지 주요 종류를 설명해 드릴게요. 피라미드 형태로 많이 비유되는데, 아래로 갈수록 더 넓은 범위를 테스트하고, 테스트 실행 속도는 느려지는 경향이 있습니다.
설명: 가장 작고 독립적인 코드 조각(단위)을 테스트하는 것입니다. 여기서 '단위'는 보통 함수(메서드) 하나나 클래스 하나가 됩니다. 다른 부분에 의존하지 않고, 해당 단위가 예상대로 작동하는지 검증하는 데 초점을 맞춥니다.
실생활 비유: 붕어빵을 만드는 공장에서 붕어빵 틀 하나하나가 제대로 작동하는지, 밀가루 반죽 기계가 밀가루를 제대로 반죽하는지, 팥앙금 주입기가 팥앙금을 정확히 넣는지 각각의 부품을 따로따로 검사하는 것과 같습니다. 다른 부품과는 상관없이 이 부품 자체의 기능만 확인합니다.
특징:
예시:
// 예시: 간단한 계산기 클래스
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)
}
}
주석 상세 설명:
// 예시: 간단한 사용자 서비스와 리포지토리
// 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) // 조회된 사용자의 이메일이 예상과 같아야 함
}
}
주석 상세 설명:
참고: E2E 테스트는 시스템 전체를 테스트하는 것을 의미하고, 인수 테스트는 사용자의 요구사항(Acceptance Criteria)이 충족되는지를 검증하는 것에 초점을 맞춥니다. E2E 테스트가 인수 테스트의 한 종류가 될 수 있습니다.
테스트 코드를 작성하는 방법론적인 접근 방식도 있습니다.
처음 백엔드 개발을 시작하는 분이라면, 현실적인 접근 방식으로 테스트를 시작하는 것이 중요합니다.
추천하는 전략: 테스트 피라미드 (Test Pyramid)
가장 이상적이고 효과적인 테스트 전략으로 "테스트 피라미드" 모델을 추천합니다.
이 전략의 핵심은:
단위 테스트를 할 때 자주 등장하는 개념이 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)
}
}
주석 상세 설명:
when(mockUserRepository.save(any(User::class.java))).thenReturn(User(1L, userName, userEmail)):테스트 코드를 언제 작성해야 하는지에 대한 정답은 없지만, 가장 효과적인 시점과 일반적인 권장 사항은 있습니다. 앞서 설명드렸던 TDD(테스트 주도 개발)처럼 코드를 작성하기 전에 테스트를 먼저 작성하는 방식도 있고, 코드를 다 만들고 나서 테스트를 추가하는 방식도 있죠. 개발 상황과 팀 문화에 따라 다를 수 있지만, 제가 추천하는 몇 가지 시점은 다음과 같습니다.
새로운 기능을 개발할 때는 TDD (Test-Driven Development) 방식으로 테스트 코드를 먼저 작성하는 것을 가장 추천합니다.
버그를 발견하여 수정할 때도 테스트 코드를 작성하는 것이 매우 중요합니다.
오래된 코드나 복잡한 코드를 개선(리팩토링)해야 할 때도 테스트 코드가 필수적입니다.
작성한 코드를 다른 개발자에게 리뷰 받기 전이나, 실제로 서비스에 배포하기 전에 테스트 코드를 실행하여 최종적으로 문제가 없는지 확인해야 합니다. CI/CD (Continuous Integration/Continuous Delivery) 파이프라인에 테스트 코드 실행을 자동화하는 것이 일반적입니다.
결론적으로
가장 이상적인 것은 새로운 기능을 만들 때부터 TDD처럼 테스트 코드를 먼저 작성하는 습관을 들이는 것입니다. 처음에는 시간이 더 걸리는 것처럼 느껴질 수 있지만, 장기적으로는 개발 속도, 코드 품질, 그리고 프로젝트의 안정성 측면에서 훨씬 더 큰 이점을 가져다줄 것입니다.
개발 초기부터 테스트 코드 작성을 병행하는 것이 중요하며, 테스트를 나중에 작성하는 것은 흔히 절대 안 하는 것으로 이어질 수 있다는 점을 기억해주세요. 처음에는 작은 단위의 테스트부터 시작하여 점차 범위를 넓혀나가는 연습을 해보시는 것을 추천합니다.