BDD (Behavior Driven Development)

  • TDD에서 파생된 개발 방법론
  • 개발자와 비개발자간의 협업 과정을 녹여낸 방법
  • 사용자의 행위를 작성하고 결과를 검증한다.
  • BDD로 테스트 코드를 작성하면, 설계 역시 행위 중심이 되는 도메인 기반 설계가 된다.

일반적으로 테스트 코드를 작성할 때, given-when-then 형식으로 많이 작성하는데, BDD에서는 각각을 주어진 환경, 행위, 기대결과로 서술

given : 주어진 환경 (ex. 본인 인증된 사용자가 로그인된 상황에서)
when : 행위 (ex. 검수 정보를 입려갛고 검수 등록 버튼을 누르면)
then : 기대결과 (ex. 등록 결과가 포함된 검수 진행 화면으로 이동한다)

BDD는 행위에 초점을 맞춰 테스트를 작성하기 때문에 그 자체로 기획서와 동기화가 되며, 자연스럽게 서비스에 대한 이해도 높아지게 된다는 장점이 존재한다.

TDD와 BDD의 차이

  • 서로 상호보완적인 관계
  • BDD는 TDD에서 확인하기 어려운 유저 시나리오의 흐름을 알 수 있고, TDD는 각 모듈의 기능을 검증할 수 있다.
  • 즉, BDD의 테스트 케이스로 시나리오 검증을 하고, 해당 시나리오에서 사용되는 각 모듈들은 TDD 테스트 케이스로 검증을 함. -> 테스트 커버리지 상승

Example

  • 계산기를 예로 들어보면 BDD는 덧셈 서비스에 대한 검증을 진행하고, TDD는 덧셈 서비스 안에서 실제로 덧셈을 하는 모듈에 대해 검증을 진행한다.

정리

TDD (Test Driven Development)BDD (Behavior Driven Development)
테스트 코드의 목적기능 동작의 검증서비스 유저 시나리오 동작의 검증
테스트 코드의 설계중심제공할 모듈의 기능 중심서비스 사용자 행위 중심
테스트 코드 설계 재료모듈 사양 문서 (개발자가 작성)서비스 기획서 (서비스 기획자가 작성)
적합한 프로젝트모듈/라이브러리 프로젝트서비스 프로젝트
장점설계 단계에서 예외 케이스들을 확인할 수 있다.설계 단계에서 누락된 기획을 확인할 수 있다.

kotest

  • Kotlin을 위한 테스팅 툴로써, BDD 스타일과 TDD 스타일을 모두 지원함
    • TDD : AnnotationSpec, ExpectSpec
    • BDD : BehaviorSpec, FeatureSpec

BehaviorSpec

  • Given/When/Then 구조를 지원하는 BDD용 스타일
  • And 블록
    • 해당 컨텍스트 안에서 하위 분기를 할 수 있다.
    • 즉, 중복된 서술을 하지 않아도 상위에 서술된 내용을 테스트 결과에 출력할 수 있고, 같은 상위 코드 블록을 사용함으로써 변수 공유도 가능함.
class RegisterEmoticonFeature : BehaviorSpec() {
    ...
    init {
        Given("본인 인증된 사용자가 로그인된 상황에서") {
            ...
            When("검수 정보를 입력란에") {
                ...
                And("검수 정보를 입력하고 검수 등록 버튼을 누르면") {
                    ...
                    Then("등록 결과가 포함된 검수 진행 목록 화면으로 이동한다") {
                        ...
                    }
                }

                And("검수 정보를 입력하지 않고 검수 등록 버튼을 누르면") {
                    ...
                    Then("검수 등록 실패 사유가 화면에 표시되어야 한다") {
                        ....
                    }
                }
            }
        }
    }
}

FeatureSpec

  • Feature/Scenario 구조를 지원하는 BDD용 스타일
  • 시나리오의 행위자를 특정하기 어렵고, 기능에 대해서만 쓰여져있는 기획에서 적합한 테스트 스펙
class EmoticonFeature : FeatureSpec() {
    init {
        ...
        feature("이모티콘 검수 이메일 발송") {
            ...
            scenario("""
                2020-08-05 11:00:00(KST)에 전날 생성된 이모티콘 목록이
                검수자에게 이메일 발송되어야 한다.
            """.trimIndent()) {
                ...
            }
        }
    }
}

AnnotationSpec

  • JUnit 형태의 Testcase 작성을 하게 해주는 TDD용 스타일
class AccountServiceSpec : AnnotationSpec() {

    @BeforeAll
    fun setupStub() {
		...
    }

    @AfterAll
    fun clearStub() {
		...
    }

    @Test
    fun taskAccountIfExistByToken() {
		...
    }

    @Test
    fun takeNullIfNotExistByToken() {
        ...
    }
}

ExpectSpec

  • DSL로 TestCase 작성하는 TDD 스타일
class EmotionServiceSpec : ExpectSpec() {
    ...
    init {
        context("이모티콘 생성을 할 때") {
            ...
            expect("계정과 이모티콘 정보, 이미지가 있으면 이모티콘이 생성된다.") {
                ...
            }
        }
    }
}

Mockk

  • Mocking 지원 라이브러리

mocking

  • mock을 생성하여 어떠한 행위를 할 것인지에 대하여 사전 정의
  • 외부 서비스 사용 등
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
        ...
    	}
	}
}

verify

  • 해당하는 행위가 원하는대로 시행되었는지 검증
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
            })
        }
     }
}

코드 예시

1. BDD

@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()
    )
}

2. TDD

@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 방식으로 코드를 작성
  • BDD는 기획의 내용을 토대로 테스트가 작성된 반면, TDD는 우리가 코드에 작성한 Domain을 기준으로 테스트 코드가 작성이 되어있다.

Reference

profile
평범한 백엔드 개발자

0개의 댓글

Powered by GraphCDN, the GraphQL CDN