
백엔드 개발자를 준비하면서 생긴 궁금증을 정리한 포스트입니다.
TDD란?
TDD(Test-Driven Development)는 테스트를 먼저 작성한 뒤, 그 테스트를 통과할 수 있도록 최소한의 코드를 작성하고, 이후 코드를 개선해 나가는 개발 방식입니다.
TDD는 보통 다음과 같은 흐름으로 반복됩니다.
이 방식을 사용하면 기능을 추가하거나 수정할 때마다
코드가 의도대로 동작하는지 빠르게 확인할 수 있고,
변경 이후 기존 기능이 깨지지 않았는지도 함께 검증할 수 있습니다.
즉, 단순히 테스트를 위한 방식이라기보다 안전하게 코드를 개선해 나가기 위한 개발 습관에 가깝습니다. (martinfowler.com)
테스트 코드는 작성한 코드가 의도한 대로 동작하는지 확인하고, 예상하지 못한 문제가 없는지 검증하기 위해 작성하는 코드입니다.
스프링 부트에서는 보통 src/test 디렉터리에서 테스트 코드를 작성합니다.
그리고 테스트 종류에 따라 단위 테스트, 통합 테스트, 슬라이스 테스트처럼 여러 방식으로 나누어 작성할 수 있습니다. (Home)
테스트 코드를 작성하는 방식은 다양하지만,
가독성을 높이기 위해 자주 사용하는 구조 중 하나가 Given-When-Then 패턴입니다.
GWT의 의미Given : 테스트를 위한 조건을 준비하는 단계When : 실제 동작을 실행하는 단계Then : 실행 결과를 검증하는 단계 이 구조를 사용하면 테스트의 목적이 분명해지고,
나중에 다시 보더라도 어떤 조건에서 어떤 결과를 기대했는지 쉽게 파악할 수 있습니다.

요구사항을 만족하지 못하는 테스트를 먼저 작성하면,
무엇을 구현해야 하는지 목표가 더 분명해집니다. (martinfowler.com)
처음부터 복잡하게 구현하기보다,
테스트를 통과할 정도의 최소 코드만 작성하는 것이 중요합니다. (martinfowler.com)
테스트가 통과했다면 중복을 줄이고 구조를 개선하면서
더 읽기 쉽고 유지보수하기 좋은 코드로 다듬습니다. (martinfowler.com)
spring-boot-starter-test스프링 부트는 테스트를 위한 유틸리티와 어노테이션을 제공합니다.
일반적으로는 spring-boot-starter-test를 추가해 기본적인 테스트 환경을 구성합니다.
이 스타터는 스프링 부트의 테스트 지원 모듈을 포함하고, 테스트 작성에 필요한 여러 도구를 함께 제공합니다. (Home)
testImplementation("org.springframework.boot:spring-boot-starter-test")
JUnitJUnit은 자바와 JVM 환경에서 널리 사용하는 테스트 프레임워크입니다.
스프링 부트 테스트에서도 많이 사용되며, assertEquals, assertThrows 같은 assertion 함수로 테스트 결과를 검증할 수 있습니다.
JUnit Jupiter의 assertion 함수는 org.junit.jupiter.api.Assertions 클래스에서 제공합니다. (docs.junit.org)
spring-boot-starter-test를 사용하면 기본적인 JUnit 기반 테스트 환경이 함께 구성되므로,
별도로 설명할 때는 “JUnit 기반 테스트를 사용한다” 정도로 정리하면 자연스럽습니다. (Home)
Kotest (선택 사항)Kotest는 Kotlin 스타일에 맞춘 테스트 DSL을 제공하는 라이브러리입니다.
꼭 필요한 것은 아니지만, 좀 더 Kotlin다운 테스트 문법을 쓰고 싶을 때 선택해서 사용할 수 있습니다.
원문에 있던 의존성은 JUnit 자체라기보다 Kotest 러너에 가깝기 때문에, 제목과 설명을 분리하는 편이 더 자연스럽습니다.
testImplementation("io.kotest:kotest-runner-junit5:5.9.0")
MockKMockK는 Kotlin 친화적인 mocking 라이브러리입니다.
가짜 객체(mock)를 만들고, 특정 메서드가 어떤 값을 반환할지 지정하거나, 실제로 호출되었는지를 검증할 수 있습니다.
공식 사이트에서도 Kotlin에 맞춘 DSL과 코루틴, 확장 함수 mocking 등을 지원한다고 설명합니다. (MockK)
testImplementation("io.mockk:mockk:1.13.5")
사람 정보를 저장하고 조회하는 Human 서비스를 만들었다고 가정해보겠습니다.
이제 이 서비스가 제대로 동작하는지 검증하기 위해 테스트 코드를 작성해보겠습니다.
// controller
@RestController
@RequestMapping("/humans")
class HumanController(
private val humanService: HumanService
) {
@GetMapping
private fun getHumans(): ResponseEntity<List<HumanResponseDto>> {
val result = humanService.getHuman()
return ResponseEntity.status(HttpStatus.OK).body(result)
}
@PostMapping
private fun createHuman(@RequestBody humanRequestDto: HumanRequestDto): ResponseEntity<HumanResponseDto> {
val result = humanService.createHuman(humanRequestDto)
return ResponseEntity.status(HttpStatus.CREATED).body(result)
}
}
// dto
data class HumanRequestDto(
val id: Long,
val name: String,
val age: Int
) {
fun toEntity(): Human = Human(
id = id,
name = name,
age = age
)
}
data class HumanResponseDto(
val name: String,
val age: Int
)
// entity
@Entity
data class Human(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
@Column(nullable = false)
val name: String,
@Column(nullable = false)
val age: Int,
) {
fun toResponse(): HumanResponseDto = HumanResponseDto(
name = name,
age = age
)
}
// repository
interface HumanRepository : JpaRepository<Human, Long>
// service
@Transactional
@Service
class HumanService(
private val humanRepository: HumanRepository
) {
fun getHuman(): List<HumanResponseDto> {
val result = humanRepository.findAll()
return result.map { it.toResponse() }
}
fun createHuman(humanRequestDto: HumanRequestDto): HumanResponseDto {
val result = humanRepository.save(humanRequestDto.toEntity())
return result.toResponse()
}
}
여기서 HumanService는 HumanRepository에 의존하고 있습니다.
지금처럼 Repository를 MockK로 가짜 객체로 바꿔 테스트할 경우에는 단위 테스트에 가깝고, 이 경우 @SpringBootTest로 스프링 컨텍스트를 띄우지 않아도 됩니다.
반대로 실제 스프링 빈과 설정을 포함해 테스트하고 싶다면 @SpringBootTest가 적절합니다. (Home)
class HumanServiceTest {
private val humanRepository: HumanRepository = mockk()
private val humanService = HumanService(humanRepository)
@Test
fun `사람 생성 테스트`() {
// given
val request = HumanRequestDto(id = 1L, name = "DH", age = 24)
val savedHuman = Human(id = 1L, name = "DH", age = 24)
every { humanRepository.save(any()) } returns savedHuman
// when
val result = humanService.createHuman(request)
// then
assertEquals("DH", result.name)
assertEquals(24, result.age)
verify(exactly = 1) { humanRepository.save(any()) }
}
@Test
fun `사람 전체 조회 테스트`() {
// given
val humans = listOf(
Human(id = 1L, name = "짱구", age = 5),
Human(id = 2L, name = "철수", age = 5),
Human(id = 3L, name = "맹구", age = 5),
Human(id = 4L, name = "유리", age = 5),
Human(id = 5L, name = "훈이", age = 5),
)
every { humanRepository.findAll() } returns humans
// when
val result = humanService.getHuman()
// then
assertEquals(5, result.size)
assertEquals("짱구", result[0].name)
assertEquals("철수", result[1].name)
assertEquals("맹구", result[2].name)
assertEquals("유리", result[3].name)
assertEquals("훈이", result[4].name)
verify(exactly = 1) { humanRepository.findAll() }
}
}
이 테스트는 Given-When-Then 구조를 따르고 있습니다.
Given에서는 테스트에 필요한 요청값과 mock 동작을 준비하고,
When에서는 실제 서비스를 실행하며,
Then에서는 assertEquals()로 결과값을 검증하고 verify()로 의존 객체의 호출 여부까지 확인합니다. (docs.junit.org)
이런 방식의 테스트를 작성해두면
서비스를 직접 실행하지 않아도 핵심 비즈니스 로직이 의도대로 동작하는지 빠르게 확인할 수 있습니다.
특히 코드 수정 이후에도 기존 동작이 유지되는지 확인하기 쉬워서 유지보수에 큰 도움이 됩니다. (Home)
| 함수 | 설명 | |
|---|---|---|
assertEquals(expected, actual) | 두 값이 같은지 비교 | |
assertNotEquals(expected, actual) | 두 값이 다른지 비교 | |
assertTrue(condition) | 조건이 true인지 확인 | |
assertFalse(condition) | 조건이 false인지 확인 | |
assertNull(value) | 값이 null인지 확인 | |
assertNotNull(value) | 값이 null이 아닌지 확인 | |
assertIterableEquals(expected, actual) | 두 리스트/컬렉션이 같은지 비교 | |
assertThrows<예외타입> { } | 특정 예외가 발생하는지 검증 | (docs.junit.org) |
| 함수 | 설명 | |
|---|---|---|
verify { 함수호출() } | 해당 함수가 호출됐는지 확인 | |
verify(exactly = n) { 함수호출() } | 정확히 n번 호출됐는지 검증 | |
verify(atLeast = n) { 함수호출() } | 최소 n번 호출됐는지 검증 | |
verify(atMost = n) { 함수호출() } | 최대 n번 호출됐는지 검증 | |
verifyOrder { 호출1(); 호출2() } | 호출 순서대로 실행됐는지 검증 | |
verifyAll { 호출1(); 호출2() } | 나열한 모든 호출이 일어났는지 검증 | |
confirmVerified(mock) | 더 이상 호출이 없어야 통과 | (MockK) |
TDD는 테스트를 먼저 작성하고, 그 테스트를 통과시키는 방식으로 개발을 진행하는 접근입니다.
그리고 지금 예시처럼 서비스 로직을 검증하는 테스트 코드를 잘 작성해두면
배포 전에 기능을 빠르게 확인할 수 있고,
코드 수정 이후에도 기존 기능이 정상 동작하는지 안전하게 검증할 수 있습니다.
결국 테스트 코드는 단순한 확인용 코드가 아니라, 신뢰할 수 있는 코드를 만들기 위한 안전장치라고 볼 수 있습니다. (martinfowler.com)