일반적으로 테스트 코드를 작성할 때, given-when-then 형식으로 많이 작성하는데, BDD에서는 각각을 주어진 환경, 행위, 기대결과로 서술
given : 주어진 환경 (ex. 본인 인증된 사용자가 로그인된 상황에서)
when : 행위 (ex. 검수 정보를 입려갛고 검수 등록 버튼을 누르면)
then : 기대결과 (ex. 등록 결과가 포함된 검수 진행 화면으로 이동한다)
BDD는 행위에 초점을 맞춰 테스트를 작성하기 때문에 그 자체로 기획서와 동기화가 되며, 자연스럽게 서비스에 대한 이해도 높아지게 된다는 장점이 존재한다.
TDD (Test Driven Development) | BDD (Behavior Driven Development) | |
---|---|---|
테스트 코드의 목적 | 기능 동작의 검증 | 서비스 유저 시나리오 동작의 검증 |
테스트 코드의 설계중심 | 제공할 모듈의 기능 중심 | 서비스 사용자 행위 중심 |
테스트 코드 설계 재료 | 모듈 사양 문서 (개발자가 작성) | 서비스 기획서 (서비스 기획자가 작성) |
적합한 프로젝트 | 모듈/라이브러리 프로젝트 | 서비스 프로젝트 |
장점 | 설계 단계에서 예외 케이스들을 확인할 수 있다. | 설계 단계에서 누락된 기획을 확인할 수 있다. |
And
블록class RegisterEmoticonFeature : BehaviorSpec() {
...
init {
Given("본인 인증된 사용자가 로그인된 상황에서") {
...
When("검수 정보를 입력란에") {
...
And("검수 정보를 입력하고 검수 등록 버튼을 누르면") {
...
Then("등록 결과가 포함된 검수 진행 목록 화면으로 이동한다") {
...
}
}
And("검수 정보를 입력하지 않고 검수 등록 버튼을 누르면") {
...
Then("검수 등록 실패 사유가 화면에 표시되어야 한다") {
....
}
}
}
}
}
}
class EmoticonFeature : FeatureSpec() {
init {
...
feature("이모티콘 검수 이메일 발송") {
...
scenario("""
2020-08-05 11:00:00(KST)에 전날 생성된 이모티콘 목록이
검수자에게 이메일 발송되어야 한다.
""".trimIndent()) {
...
}
}
}
}
class AccountServiceSpec : AnnotationSpec() {
@BeforeAll
fun setupStub() {
...
}
@AfterAll
fun clearStub() {
...
}
@Test
fun taskAccountIfExistByToken() {
...
}
@Test
fun takeNullIfNotExistByToken() {
...
}
}
class EmotionServiceSpec : ExpectSpec() {
...
init {
context("이모티콘 생성을 할 때") {
...
expect("계정과 이모티콘 정보, 이미지가 있으면 이모티콘이 생성된다.") {
...
}
}
}
}
class RegisterEmoticonFeature(
_emoticonRepository: EmoticonRepository
) : BehaviorSpec() {
private val accountService = mockk<AccountService>()
Given("본인 인증된 사용자가 로그인된 상황에서") {
val account = Mock.account(identified = true)
every { accountService.take(ofType(String::class)) } returns account
...
}
}
}
class RegisterEmoticonFeature(
_emoticonRepository: EmoticonRepository
) : BehaviorSpec() {
And("검수 정보를 입력하고 검수 등록 버튼을 누르면") {
val request = request()
val response = emoticonHandler.register(token, request)
Then("등록 결과가 포함된 검수 진행 목록 화면으로 이동한다") {
response.authorId shouldBe account.id
response.title shouldBe request.title
response.description shouldBe request.description
response.choco shouldBe request.choco
response.images shouldContainAll request.images
verify(exactly = 1) {
accountService.take(token = token)
emoticonRepository.save(match<EmoticonEntity> {
it.accountId == account.id
})
}
}
}
@ContextConfiguration(classes = [SpringDataConfig::class])
class RegisterEmoticonFeature(
_emoticonRepository: EmoticonRepository
) : BehaviorSpec() {
private val emoticonRepository = spyk(_emoticonRepository)
private val accountService = mockk<AccountService>() // MSA
private val emoticonService = EmoticonService(repository = emoticonRepository)
private val emoticonHandler = EmoticonHandler(
accountService = accountService,
emoticonService = emoticonService,
emailService = mockk()
)
override fun isolationMode(): IsolationMode = InstancePerLeaf
init {
Given("본인 인증된 사용자가 로그인된 상황에서") {
val token = Arb.stringPattern("([a-zA-Z0-9]{20})").single()
val account = Mock.account(identified = true)
every { accountService.take(ofType(String::class)) } returns account
When("검수 정보를 입력란에") {
And("검수 정보를 입력하고 검수 등록 버튼을 누르면") {
val request = request()
val response = emoticonHandler.register(token, request)
Then("등록 결과가 포함된 검수 진행 목록 화면으로 이동한다") {
response.authorId shouldBe account.id
response.title shouldBe request.title
response.description shouldBe request.description
response.choco shouldBe request.choco
response.images shouldContainAll request.images
verify(exactly = 1) {
accountService.take(token = token)
emoticonRepository.save(match<EmoticonEntity> {
it.accountId == account.id
})
}
}
}
And("검수 정보를 입력하지 않고 검수 등록 버튼을 누르면") {
val ex = shouldThrowExactly<DeniedRegisterEmoticonException> {
emoticonHandler.register(token, null)
}
Then("검수 등록 실패 사유가 화면에 표시되어야 한다") {
ex.message.isNotBlank() shouldBe true
verify(exactly = 0) {
accountService.take(any())
emoticonRepository.save(ofType<EmoticonEntity>())
}
}
}
}
}
}
private fun request() = RegisterEmoticon(
title = Arb.string(10..100).single(),
description = Arb.string(250..300).single(),
choco = Arb.int(100..500).single(),
images = Arb.stringPattern("([a-zA-Z0-9]{1,10})/([a-zA-Z0-9]{1,10})\\.jpg")
.chunked(1..10)
.single()
)
}
@ContextConfiguration(classes = [SpringDataConfig::class])
class EmoticonServiceSpec(
private val emoticonRepository: EmoticonRepository
) : ExpectSpec() {
private val emoticonService = EmoticonService(repository = emoticonRepository)
init {
context("이모티콘 생성을 할 때") {
val account = Mock.account(identified = true)
val information = information()
val images = images()
expect("계정과 이모티콘 정보, 이미지가 있으면 이모티콘이 생성된다.") {
val emoticon = emoticonService.create(account, information, images)
emoticon.id shouldBeGreaterThan 0
emoticon.authorId shouldBe account.id
emoticon.title shouldBe information.title
emoticon.description shouldBe information.description
emoticon.choco shouldBe information.choco
emoticon.images shouldContainAll images
}
}
context("이모티콘을 조회할때") {
val saved = Arb.localDate(
minDate = LocalDate.of(2020, 1, 1),
maxDate = LocalDate.of(2020, 1, 10)
).flatMap {
Mock.emoticonEntity(it, ZoneId.systemDefault()).chunked(1..10).single()
}.let {
emoticonRepository.saveAll(it.chunked(100..1000).single())
}
expect("생성 시작 시간의 범위로 조회하면 해당 구간에 생성된 이모티콘을 조회 할 수 있다.") {
val from = LocalDate.of(2020, 1, 5).atStartOfDay().atZone(ZoneId.systemDefault())
val to = LocalDate.of(2020, 1, 8).atStartOfDay().atZone(ZoneId.systemDefault())
val target = with(saved) {
val fromInstant = from.toInstant()
val toInstant = to.toInstant()
filter {
fromInstant <= it.createdAt && toInstant >= it.createdAt
}.map { it.id }
}
val emoticons = emoticonService.getAllCreatedAt(from, to)
emoticons shouldHaveSize target.count()
emoticons.map { it.id } shouldContainAll target
}
}
}
private fun information() = EmoticonInformation(
title = Arb.string(10..100).single(),
description = Arb.string(100..300).single(),
choco = Arb.int(100..500).single()
)
private fun images() = Arb.stringPattern("([a-zA-Z0-9]{1,10})/([a-zA-Z0-9]{1,10})\\.jpg")
.chunked(1..10)
.single()
}
@ContextConfiguration(classes = [SpringDataConfig::class])
를 통해 Repository의 의존성만 가져오는 Slice Test 방식으로 코드를 작성