057. (Getting Started with Test-Driven Development) 7. 대역

7. 대역

7.1. 대역의 필요성

테스트를 작성하다 보면 아래와 같이 외부 요인이 필요한 시점이 있다.

  • 테스트 대상에서 파일 시스템을 사용
  • 테스트 대상에서 DB로부터 데이터를 조회하거나 데이터를 추가
  • 테스트 대상에서 외부의 HTTP 서버와 통신

테스트 대상이 이런 외부 요인에 의존하면 테스트를 작성하고 실행하기 어려워진다.

만약 외부 API 서버가 장애로 인해 제대로 된 응답값을 주지않는 경우 해당 테스트도 실패할 것이기 때문이다.

데이터베이스도 마찬가지이다.

내부 서비스용 DB라고 하더라도 모든 상황에 맞게 데이터를 구성하는 것이 항상 가능한 것은 아니다.

무엇보다 TDD는 테스트의 작성, 통과시킬만큼의 구현, 리팩토링의 과정을 짧고 빠르게 반복해야하므로, 외부 요인으로 인해 진행할 수 없는 상황은 달갑지않다.

외부 요인은 테스트 작성을 어렵게 만들 뿐만 아니라 테스트의 결과도 예측할 수 없게 만든다.

실제 테스트 코드를 살펴보자.

자동이체 정보 등록 기능과 카드번호 검사기의 코드 구조가 아래와 같다고 하자.

classDiagram
    AutoDebitRegister ..> CardNumberValidator
    외부 카드 정보 API <.. CardNumberValidator

CardNumberValidator에서 외부 카드 정보 API로 API 호출을 수행하는 구조이다.

AutoDebitRegister 클래스는 자동이체 등록 기능을 구현했고 CardNumberValidator는 외부 API를 통해 카드 번호의 유효성을 확인한다.

AutoDebitRegister 클래스는 CardNumberValidator 클래스를 이용해서 카드 번호가 유효한지 검사한 뒤에 그 결과에 따라 자동이체 정보를 저장한다.

코드는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class AutoDebitRegister(
val validator: CardNumberValidator,
val repository: AutoDebitInfoRepository,
) {

fun register(req: AutoDebitReq): RegisterResult {
val validity: CardValidity = validator.validate(req.cardNumber)

if (validity != CardValidity.VALID) {
return RegisterResult.error(validity)
}
val info: AutoDebitInfo? = repository.findOne(req.userId)
if (info != null) {
info.changeCardNumber(req.cardNumber)
} else {
val newInfo = AutoDebitInfo(
userId = req.userId,
cardNumber = req.cardNumber,
time = LocalDateTime.now()
)
repository.save(newInfo)
}
return RegisterResult.success()
}
}

AutoDebitRegister 클래스에서 사용하는 CardNumberValidator 클래스는

외부 서비스에서 제공하는 HTTP URL을 이용해서 카드번호가 유효한지 검사하고 그 결과를 리턴한다.

코드는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class CardNumberValidator {

fun validate(cardNumber: String): CardValidity {
val httpClient = HttpClient.newHttpClient()
val request = HttpRequest.newBuilder()
.uri(URI.create("https://some-external-pg.com/card"))
.header("Content-Type", "text/plain")
.POST(BodyPublishers.ofString(cardNumber))
.build()

return runCatching {
val response = httpClient.send(request, BodyHandlers.ofString())
return when (response.body()) {
"ok" -> CardValidity.VALID
"bad" -> CardValidity.INVALID
"expired" -> CardValidity.EXPIRED
"theft" -> CardValidity.THEFT
else -> CardValidity.UNKNOWN
}
}.getOrDefault(CardValidity.ERROR)
}
}

AutoDebitRegister 클래스를 테스트하는 코드는 아래와 같이 작성할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class AutoDebitRegisterTest {

private lateinit var register: AutoDebitRegister

@BeforeEach
fun setUp() {
val validator = CardNumberValidator()
val repository = JpaAutoDebitInfoRepository()
register = AutoDebitRegister(
validator = validator,
repository = repository,
)
}

@Test
fun validCard() {
// 업체에서 받은 테스트용 유효한 카드번호 사용
val req = AutoDebitReq(
userId = "user1",
cardNumber = "1234123412341234"
)
val result = register.register(req)
assertEquals(CardValidity.VALID, result.validity())
}

@Test
fun theftCard() {
// 업체에서 받은 도난 테스트용 카드번호 사용
val req = AutoDebitReq(
userId = "user1",
cardNumber = "1234567890123456"
)
val result = register.register(req)
assertEquals(CardValidity.THEFT, result.validity())
}
}

validCard() 테스트를 통과시키려면 외부 업체에서 테스트 목적의 유효한 카드번호를 받아야 한다.

만약 이 카드번호가 한 달 뒤에 만료되는 경우, 한 달뒤에 테스트는 실패하게 된다.

theftCard() 테스트도 상황은 비슷하다.

도난된 카드의 정보를 업체에서 삭제해버리면, 이 테스트도 실패하게 된다.

이처럼 테스트 대상에서 의존하는 요인 때문에 테스트가 어려울 때는 대역을 써서 테스트를 진행할 수 있다.

7.2. 대역을 이용한 테스트

대역을 이용해서 AutoDebitRegister 클래스를 테스트하는 코드를 작성해보자.

먼저 CardNumberValidator를 대신할 대역 클래스를 작성하자.

먼저 CardNumberValidator를 대역으로 대체하기 위해 open 키워드를 부여한다.

1
2
3
4
5
6
open class CardNumberValidator {

open fun validate(cardNumber: String): CardValidity {
// ...
}
}

이제 대역 클래스 코드에 해당하는 StubCardNumberValidator 클래스는 아래와 같이 작성할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class StubCardNumberValidator : CardNumberValidator() {

private var invalidNo: String? = null

fun setInvalidNo(invalidNo: String) {
this.invalidNo = invalidNo
}

override fun validate(cardNumber: String): CardValidity {
return if (invalidNo != null && invalidNo == cardNumber) {
CardValidity.INVALID
} else {
CardValidity.VALID
}
}
}

StubCardNumberValidator 클래스는 실제 카드번호 검증 기능을 구현하지않고, 단순한 구현으로 실제 구현을 대체한다.

validate() 메서드는 invalidNo 필드와 동일한 카드번호면 결과로 INVALID를 동일하지 않으면 VALID를 반환한다.

StubCardNumberValidator 클래스를 이용하여 AutoDebitRegister를 테스트하는 코드를 작성해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class AutoDebitRegister_Stub_Test {
private lateinit var register: AutoDebitRegister
private lateinit var stubValidator: StubCardNumberValidator
private lateinit var stubRepository: StubAutoDebitInfoRepository

@BeforeEach
fun setUp() {
stubValidator = StubCardNumberValidator()
stubRepository = StubAutoDebitInfoRepository()
register = AutoDebitRegister(
validator = stubValidator,
repository = stubRepository,
)
}

@Test
fun invalidCard() {
stubValidator.setInvalidNo("111122223333")
val req = AutoDebitReq(
userId = "user1",
cardNumber = "111122223333"
)
val result = register.register(req)
assertEquals(CardValidity.INVALID, result.validity())
}
}

validator와 repository를 전부 stub으로 대치했기때문에, AutoDebitRegister 클래스는 실제 객체 대신에 stub 형태로 넘겨받은 클래스들을 대상으로 카드 번호가 유효한지 검사하게 된다.

즉, 외부 카드번호 API를 사용하지않고 임의의 데이터를 검증하여 테스트를 통과시키는 것이다.

이번엔 도난카드에 대한 테스트를 추가해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class StubCardNumberValidator : CardNumberValidator() {

private var invalidNo: String? = null
private var theftNo: String? = null // HERE

fun setInvalidNo(invalidNo: String) {
this.invalidNo = invalidNo
}

fun setTheftNo(theftNo: String) { // HERE
this.theftNo = theftNo
}

override fun validate(cardNumber: String): CardValidity {
if (invalidNo != null && invalidNo == cardNumber) {
return CardValidity.INVALID
}
if (theftNo != null && theftNo == cardNumber) { // HERE
return CardValidity.THEFT
}
return CardValidity.VALID
}
}

이제 대역을 이용해 아래와 같이 도난 카드 번호에 대한 자동이체 기능을 테스트할 수 있게 되었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class AutoDebitRegister_Stub_Test {
private lateinit var register: AutoDebitRegister
private lateinit var stubValidator: StubCardNumberValidator
private lateinit var stubRepository: StubAutoDebitInfoRepository

// ...

@Test
fun theftCard() {
stubValidator.setTheftNo("1234567890123456")
val req = AutoDebitReq(
userId = "user1",
cardNumber = "1234567890123456"
)
val result = register.register(req)
assertEquals(CardValidity.THEFT, result.validity())
}
}

이번엔 DB에 대하여 대역을 구현해보자.

먼저 AutoDebitInfoRepository에 대한 대역을 MemoryAutoDebitInfoRepository 클래스로 구현한다.

1
2
3
4
5
6
7
8
9
10
11
12
class MemoryAutoDebitInfoRepository : AutoDebitInfoRepository {

private val infos = hashMapOf<String, AutoDebitInfo>()

override fun findOne(userId: String): AutoDebitInfo? {
return infos[userId]
}

override fun save(info: AutoDebitInfo) {
infos[info.userId] = info
}
}

MemoryAutoDebitInfoRepository 클래스는 데이터베이스 대신 Map을 사용하여 메모리에만 데이터를 저장한다.

DB의 특성인 영속성을 제공하진 않지만, 테스트에 사용할 수 있을 만큼의 기능은 제공한다.

이제 테스트 코드를 작성해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
class AutoDebitRegister_Fake_Test {
private lateinit var register: AutoDebitRegister
private lateinit var cardNumberValidator: StubCardNumberValidator
private lateinit var repository: MemoryAutoDebitInfoRepository

@BeforeEach
fun setUp() {
cardNumberValidator = StubCardNumberValidator()
repository = MemoryAutoDebitInfoRepository()
register = AutoDebitRegister(
validator = cardNumberValidator,
repository = repository,
)
}

@Test
fun alreadyRegistered_InfoUpdated() {
repository.save(
info = AutoDebitInfo(
userId = "user1",
cardNumber = "111222333444",
time = LocalDateTime.now()
)
)

val req = AutoDebitReq(
userId = "user1",
cardNumber = "123456789012"
)
val result = register.register(req)

val saved = repository.findOne("user1")
assertEquals("123456789012", saved?.cardNumber)
}

@Test
fun notYetRegistered_newInfoRegistered() {
val req = AutoDebitReq(
userId = "user1",
cardNumber = "1234123412341234",
)

val result = register.register(req)

val saved = repository.findOne("user1")
assertEquals("1234123412341234", saved?.cardNumber)
}
}

7.3. 대역을 사용한 외부 상황 흉내와 결과 검증

대역을 사용한 테스트에서 주목할 점은 아래 두 가지없이 AutoDebitRegister 클래스에 대한 테스트를 수행했다는 점이다.

  • 외부 카드 정보 API 연동
  • 자동이체 정보를 저장한 DB

즉 대역은 이름 그대로 외부의 상황을 흉내 내며, 외부에 대한 결과를 검증할 수 있다

7.4. 대역의 종류

대역은 구현 방식에 따라 아래와 같이 구별할 수 있다.

대역 종류 설명
Stub 구현을 단순한 것으로 대체한다. 테스트에 맞게 단순히 원하는 동작을 수행한다.
Fake 제품에는 적합하지않지만, 실제 동작하는 구현을 제공한다.
Spy 호출된 내역을 기록한다. 기록한 내용은 테스트 결과를 검증할 때 사용한다. Spy는 Stub이기도 하다.
Mock 기대한 대로 상호작용하는지 행위를 검증한다. 기대한 대로 동작하지 않으면 예외를 발생시킬 수 있다. Mock은 Stub이자 Spy도 된다.

예시를 통해 대역을 살펴보자.

이번에 사용할 예시는 회원 가입 기능이다.

회원 가입 기능을 구현할 UserRegister 및 관련 타입은 아래와 같다.

classDiagram

    class WeakPasswordChecker
    <> WeakPasswordChecker
    class EmailNotifier
    <> EmailNotifier
    class UserRepository
    <> UserRepository

    WeakPasswordChecker <.. UserRegister
    EmailNotifier <.. UserRegister
    UserRepository <.. UserRegister

위의 구조도에서 각 타입은 아래와 같은 역할을 수행한다.

  • UserRegister : 회원 가입에 대한 핵심 로직을 수행한다.
  • WeakPasswordChecker : 암호가 약한지 검사한다.
  • UserRepository : 회원 정보를 조장하고 조회하는 기능을 제공한다.
  • EmailNotifier : 이메일 발송 기능을 제공한다.

이제 UserRegister에 대한 테스트를 작성해나가면서 스텁, 가짜, 스파이, 모의 객체를 차례대로 사용해보자.

7.4.1. 약한 암호 확인 기능에 스텁 사용

암호가 약한 경우 회원 가입에 실패하는 테스트부터 시작하자.

암호가 약한지를 검증하기 위해서 UserRegister를 직접 구현하지않고 WeakPasswordChecker를 사용해서 각 타입의 역할과 책임을 분리한다.

테스트 대상이 UserRegister이므로 WeakPasswordChecker는 대역을 사용할 것이다.

실제 동작은 필요없고, 약한 암호인지에 대한 반환값만 필요하므로 스텁이 적당하다.

테스트 코드는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class UserRegisterTest {
private lateinit var userRegister: UserRegister
private val stubPasswordChecker = StubWeakPasswordChecker()

@BeforeEach
fun setUp() {
userRegister = UserRegister(stubPasswordChecker)
}

@Test
@DisplayName("약한 암호면 가입 실패")
fun weakPassword() {
stubPasswordChecker.setWeak(true) // 암호가 약하다고 응답하도록 설정

assertThrows(WeakPasswordException::class.java) {
userRegister.register("id", "pw", "email")
}
}
}

현재로선 컴파일 에러가 발생하므로 테스트 코드의 성공을 위해 구현을 해보자.

1
2
class WeakPasswordException : RuntimeException() {
}

제일 간단한 런타임 예외를 먼저 생성한다.

이제 StubWeakPasswordChecker을 구현하기 위해 상위 타입인 WeakPasswordChecker 인터페이스를 생성한다.

1
2
interface WeakPasswordChecker {
}

위의 인터페이스를 구현한 StubWeakPasswordChecker을 아래와 같이 작성한다.

1
2
3
4
5
6
7
8
9
class StubWeakPasswordChecker : WeakPasswordChecker {

private var weak: Boolean = false

fun setWeak(weak: Boolean) {
this.weak = weak
}

}

그 다음으로 UserRegister 클래스를 생성한다.

1
2
3
4
5
6
7
8
9
class UserRegister(
val passwordChecker: WeakPasswordChecker
) {

fun register(id: String, pw: String, email: String) {
throw WeakPasswordException()
}

}

이제 컴파일 오류는 모두 제거되었다.

그 다음으로 구현을 좀 더 일반화해보자.

먼저 약한 암호임을 확인 후 예외를 발생시키도록 한다.

1
2
3
4
5
6
7
8
9
10
class UserRegister(
val passwordChecker: WeakPasswordChecker
) {

fun register(id: String, pw: String, email: String) {
if (passwordChecker.checkPasswordWeak(pw)) {
throw WeakPasswordException()
}
}
}

이제 WeakPasswordCheckercheckPasswordWeak() 메서드를 추가한다.

1
2
3
4
5
interface WeakPasswordChecker {

fun checkPasswordWeak(pw: String): Boolean

}

인터페이스에 추가된 선언을 StubWeakPasswordChecker에서도 추가하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
class StubWeakPasswordChecker : WeakPasswordChecker {

private var weak: Boolean = false

fun setWeak(weak: Boolean) {
this.weak = weak
}

override fun checkPasswordWeak(pw: String): Boolean { // HERE
return weak
}

}

StubWeakPasswordChecker 클래스는 단순히 weak 프로퍼티만 반환해도 테스트를 통과시킬 수 있다.

7.4.2. 레포지토리를 가짜 구현으로 사용

다음 테스트로 동일한 ID를 가진 회원이 존재하는 경우 예외를 발생시키도록 테스트를 작성해보자.

먼저 동일한 ID를 가진 회원이라는 상황은 아래와 같이 부여할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class UserRegisterTest {
private lateinit var userRegister: UserRegister
private val stubPasswordChecker = StubWeakPasswordChecker()
private val fakeRepository = MemoryUserRepository() // HERE

@BeforeEach
fun setUp() {
userRegister = UserRegister(stubPasswordChecker, fakeRepository) // HERE
}

@Test
@DisplayName("약한 암호면 가입 실패")
fun weakPassword() {
stubPasswordChecker.setWeak(true) // 암호가 약하다고 응답하도록 설정

assertThrows(WeakPasswordException::class.java) {
userRegister.register("id", "pw", "email")
}
}

@Test
@DisplayName("이미 같은 ID가 존재하면 가입 실패")
fun dupIdExists() { // HERE
// 이미 같은 ID가 존재하는 상황 만들기
fakeRepository.save(
User(
"id",
"pw1",
"email@email.com"
)
)

assertThrows(DupIdException::class.java) {
userRegister.register("id", "pw2", "email")
}
}
}

이제 테스트 코드의 컴파일 오류를 해소하기위한 코드를 추가해보자.

1
2
3
interface UserRepository {

}
1
2
3
class MemoryUserRepository : UserRepository{

}

이제 UserRegister의 생성자에 파라미터를 추가한다.

1
2
3
4
5
6
7
8
class UserRegister(
val passwordChecker: WeakPasswordChecker,
val userRepository: UserRepository, // HERE
) {

// ...

}

이제 레포지토리의 오류를 없애보자.

먼저 User 클래스를 작성한다.

1
2
3
4
5
data class User(
val id: String,
val password: String,
val email: String,
)

이제 MemoryUserRepositorysave() 함수를 추가한다.

1
2
3
4
interface UserRepository {

fun save(user: User) // HERE
}

UserRepository 인터페이스에 위 메서드를 추가해서 MemoryUserRepositorysave() 메서드 구현을 강제한다.

1
2
3
4
5
6
7
8
class MemoryUserRepository : UserRepository{
private val users = hashMapOf<String, User>()


override fun save(user: User) {
users[user.id] = user
}
}

마지막으로 DupIdException 예외 클래스를 추가하자.

1
2
3
class DupIdException : RuntimeException() {

}

UserRegister 클래스에 예외를 던지도록 수정하면 컴파일 오류는 사라진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
class UserRegister(
val passwordChecker: WeakPasswordChecker,
val userRepository: UserRepository,
) {

fun register(id: String, pw: String, email: String) {
if (passwordChecker.checkPasswordWeak(pw)) {
throw WeakPasswordException()
}
throw DupIdException()
}

}

여기까지 작업해서 테스트가 통과되었으니 이제 구현을 일반화할 차례이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class UserRegister(
val passwordChecker: WeakPasswordChecker,
val userRepository: UserRepository,
) {

fun register(id: String, pw: String, email: String) {
if (passwordChecker.checkPasswordWeak(pw)) {
throw WeakPasswordException()
}
val user = userRepository.findById(id) // HERE
if (user != null) {
throw DupIdException()
}
}

}

UserRepositoryfindById() 메서드를 추가하여 id로 회원을 조회하여 User VO를 반환받도록 한다.

이제 findById()를 인터페이스와 구현체에 추가하자.

1
2
3
4
5
6
interface UserRepository {

fun save(user: User)

fun findById(id: String): User?
}
1
2
3
4
5
6
7
8
9
10
11
12
class MemoryUserRepository : UserRepository {
private val users = hashMapOf<String, User>()


override fun save(user: User) {
users[user.id] = user
}

override fun findById(id: String): User? { // HERE
return users[id]
}
}

이제 다시 테스트를 수행하면 잘 통과됨을 확인할 수 있다.

이번엔 중복된 아이디가 없을 때 회원 가입에 성공하는 경우도 테스트해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class UserRegisterTest {
// ...

@Test
@DisplayName("같은 ID가 없으면 가입 성공")
fun noDupid_RegisterSuccess() {
userRegister.register("id", "pw", "email")

val savedUser = fakeRepository.findById("id") // 가입 결과 확인

assertEquals("id", savedUser?.id)
assertEquals("email", savedUser?.email)
}
}

이 테스트는 실패한다.

이 테스트는 savedUser가 null이기때문에 NullPointerExcpetion이 발생하기 때문이다.

아래와 같이 회원 가입 로직을 수정하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class UserRegister(
val passwordChecker: WeakPasswordChecker,
val userRepository: UserRepository,
) {

fun register(id: String, pw: String, email: String) {
if (passwordChecker.checkPasswordWeak(pw)) {
throw WeakPasswordException()
}
val user = userRepository.findById(id)
if (user != null) {
throw DupIdException()
}
userRepository.save( // HERE
user = User(
id = "id",
password = "pw",
email = "email"
)
)
}

}

이제 테스트에 통과한다.

그 다음으로는 코드의 일반화를 진행한다.

리터럴값이 아닌 파라미터를 이용해 User객체를 생성하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class UserRegister(
val passwordChecker: WeakPasswordChecker,
val userRepository: UserRepository,
) {

fun register(id: String, pw: String, email: String) {
if (passwordChecker.checkPasswordWeak(pw)) {
throw WeakPasswordException()
}
val user = userRepository.findById(id)
if (user != null) {
throw DupIdException()
}
userRepository.save(
user = User(
id = id,
password = pw,
email = email
)
)
}

}

7.4.3. 이메일 발송 여부를 확인하기 위해 스파이를 사용

회원 가입에 대한 구현이 끝났으니 회원 가입 이후를 생각해보자.

이 시스템은 회원 가입에 성공하면 이메일로 회원 가입 안내 메일을 발송한다고 가정한다.

이메일 발송 여부를 어떻게 확인할 수 있을까?

가장 단순한 접근 방법은 UserRegister 클래스가 EmailNotifier의 메일 발송 기능을 실행할 때,

주어진 이메일 주소를 사용했는지 확인하는 것이다.

이러한 용도로 사용할 있 수있는 것이 스파이 대역이다.

EmailNotifier의 스파이 대역을 이용한 테스트 코드를 작성해보자.

먼저 회원 가입시 이메일을 올바르게 발송했는지 확인할 수 있으려면 EmailNotifier의 스파이 대역이 이메일 발송 여부와

발송을 요청할 때 사용한 이메일 주소를 제공할수 있어야 한다.

먼저 EmailNotifier 인터페이스를 선언한다.

1
2
3
interface EmailNotifier {

}

그리고 위 인터페이스를 구현한 SpyEmailNotifier를 작성한다.

1
2
3
4
5
6
7
8
9
10
11
class SpyEmailNotifier : EmailNotifier {
private var called: Boolean = false
private var email: String? = null

fun isCalled(): Boolean {
return called
}
fun getEmail(): String? {
return email
}
}

이제 SpyEmailNotifier를 이용해서 메일 발송 여부를 확인하는 테스트를 작성하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class UserRegisterTest {
private lateinit var userRegister: UserRegister
private val stubPasswordChecker = StubWeakPasswordChecker()
private val fakeRepository = MemoryUserRepository()
private val spyEmailNotifier = SpyEmailNotifier() // HERE

// ...

@Test
@DisplayName("가입하면 메일을 전송함")
fun whenRegisterThenSendMail() { // HERE
userRegister.register("id", "pw", "email@email.com")

assertTrue(spyEmailNotifier.isCalled())
assertEquals("email@email.com", spyEmailNotifier.getEmail())
}
}

이제 UserRegister의 생성자가 EmailNotifier를 추가 파라미터로 받도록 수정한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class UserRegister(
val passwordChecker: WeakPasswordChecker,
val userRepository: UserRepository,
val emailNotifier: EmailNotifier, // HERE
) {

fun register(id: String, pw: String, email: String) {
if (passwordChecker.checkPasswordWeak(pw)) {
throw WeakPasswordException()
}
val user = userRepository.findById(id)
if (user != null) {
throw DupIdException()
}
userRepository.save(
user = User(
id = id,
password = pw,
email = email
)
)
}
}

여기까지 작업해도 아직 테스트는 실패한다.

1
2
3
4
5
6
7
8
9
10
11
12
class UserRegisterTest {
// ...

@Test
@DisplayName("가입하면 메일을 전송함")
fun whenRegisterThenSendMail() {
userRegister.register("id", "pw", "email@email.com")

assertTrue(spyEmailNotifier.isCalled()) // HERE
assertEquals("email@email.com", spyEmailNotifier.getEmail())
}
}

spyEmailNotifier.isCalled()에서 아직 false를 반환하고 있기 때문이다.

이를 통과시키려면 아래 두 가지에 대해 고려해야한다.

  1. UserRegisterEmailNotifier의 이메일 발송 기능을 호출
  2. 스파이의 이메일 발송 기능 구현에서 호출 여부 기록

먼저 UserRegisterEmailNotifier의 이메일 발송 기능을 호출하도록 코드를 추가하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class UserRegister(
val passwordChecker: WeakPasswordChecker,
val userRepository: UserRepository,
val emailNotifier: EmailNotifier,
) {

fun register(id: String, pw: String, email: String) {
if (passwordChecker.checkPasswordWeak(pw)) {
throw WeakPasswordException()
}
val user = userRepository.findById(id)
if (user != null) {
throw DupIdException()
}
userRepository.save(
user = User(
id = id,
password = pw,
email = email
)
)

emailNotifier.sendRegisterEmail(email) // HERE
}
}

아직 EmailNotifier 인터페이스에 sendRegisterEmail() 메서드를 추가한다.

1
2
3
4
interface EmailNotifier {

fun sendRegisterEmail(email: String)
}

이후 SpyEmailNotifier 에서 구현부를 작성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class SpyEmailNotifier : EmailNotifier {
private var called: Boolean = false
private var email: String? = null

fun isCalled(): Boolean {
return called
}
fun getEmail(): String? {
return email
}

override fun sendRegisterEmail(email: String) { // HERE
this.called = true
}
}

그래도 아직 테스트는 실패하고 있다.

검증단계에서 email이 아직 null이기 때문이다.

이를 통과시키려면 아래와 같이 전송한 email 주소도 올바르게 반환해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class SpyEmailNotifier : EmailNotifier {
private var called: Boolean = false
private var email: String? = null

fun isCalled(): Boolean {
return called
}
fun getEmail(): String? {
return email
}

override fun sendRegisterEmail(email: String) {
this.called = true
this.email = email // HERE
}
}

이제 모든 테스트가 통과되는 것을 확인할 수 있다.

7.4.4. 모의 객체로 스텁과 스파이 대체

앞서서 작성했던 테스트 코드를 모의 객체를 이용해서 다시 작성해보자.

여기서는 Mockito 라이브러리를 활용해서 테스트 코드를 작성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class UserRegisterMockTest {
private lateinit var userRegister: UserRegister
private val mockPasswordChecker = Mockito.mock(WeakPasswordChecker::class.java)
private val fakeRepository = MemoryUserRepository()
private val mockEmailNotifier = Mockito.mock(EmailNotifier::class.java)

@BeforeEach
fun setUp() {
userRegister = UserRegister(
passwordChecker = mockPasswordChecker,
userRepository = fakeRepository,
emailNotifier = mockEmailNotifier
)
}

@Test
@DisplayName("약한 암호면 가입 실패")
fun weakPassword() {
BDDMockito
.given(mockPasswordChecker.checkPasswordWeak("pw"))
.willReturn(true)

Assertions.assertThrows(WeakPasswordException::class.java) {
userRegister.register("id", "pw", "email")
}
}
}

Mockito.mock() 메서드는 인자로 전달받은 탕비의 모의 객체를 생성한다.

위 코드에서는 스텁과 스파이를 모두 모의 객체가 대체한 것을 볼 수 있다.

특히 weakPassword() 테스트 메서드내에서는 모의 객체의 행동을 재정의하는 것을 볼 수 있다.

이처럼 대역 객체가 기대하는 대로 상호작용했는지 확인하는 것이 모의 객체의 주요 기능이다.

이번엔 다른 테스트 코드를 Mockito로 작성해보자.

1
2
3
4
5
6
7
8
9
10
@Test
@DisplayName("회원 가입시 암호 검사 수행함")
fun checkPassword() {
userRegister.register("id", "pw", "email")

BDDMockito
.then(mockPasswordChecker)
.should()
.checkPasswordWeak(BDDMockito.anyString())
}

위와 같이 작성하면 전달받은 모의 객체의 특정 메서드가 호출되었는지 검증하고 임의의 인자를 넘겨 메서드 호출 여부를 검증할 수 있다.

이번엔 스파이를 대체해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
@DisplayName("가입하면 메일을 전송함")
fun whenRegisterThenSendMail() {
userRegister.register("id", "pw", "email@email.com")

val captor = ArgumentCaptor.forClass(String::class.java)
BDDMockito
.then(mockEmailNotifier)
.should()
.sendRegisterEmail(captor.capture())

val realEmail = captor.value
Assertions.assertEquals("email@email.com", realEmail)
}

ArgumentCaptor는 모의 객체를 메서드를 호출할 때 전달한 객체를 담아주는 기능을 한다.

should() 등으로 모의 객체릐 메서드를 호출 여부를 확인할 때, capture() 메서드를 사용하면 메서드를 호출할 때 전달한 인자가 ArgumentCaptor에 담긴다.

마지막으로 ArgumentCaptorvalue 값을 검증하면 보관도어있는 인자를 얻을 수 있다.

7.5. 상황과 결과 확인을 위한 협업 대상(의존) 도출과 대역 사용

하나의 테스트는 특정 상황에서 기능을 실행하고 그 결과를 확인한다.

이때 실제 구현을 이용하면 특정 상황을 모사하기 어려운 경우게 많다.

이렇게 제어하기 힘든 외부 상황이 존재하면 아음과 같은 방법으로 의존을 도출하고 이를 대역으로 대체할 수 있다.

  • 제어하기 힘든 외부 상황을 별도 타입으로 분리
  • 테스트 코드는 별도로 분리한 타입의 대역을 생성
  • 생성한 대역을 테스트 대상의 생성자 등을 이용해서 전달
  • 대역을 이용해서 상황 구성

7.6. 대역과 개발 속도

TDD 과정에서 대역을 사용하지않고 실제 구현만을 사용한다고 가정하자.

이 실제 구현이 외부에 의존하는 경우 외부에서 결과를 돌려주기까지 테스트를 완료하지못하고 계속 대기해야하는 문제가 생긴다.

이는 빠른 피드백을 얻고자 하는 단위 테스트 원칙에도 위배된다.

따라서 대역을 사용하여 실제 구현 없이 다양한 상황에 대한 테스트를 수행해서, 의존하는 대상을 구현하지 않아도 대상을 완성하고 개발 속도를 올리는 데 그 의의가 있다.

7.7. 모의 객체를 과하게 사용하지 않기

모의 객체는 스텁과 스파이를 지원하므로 전방위적으로 많이 쓰이는 방법이다.

하지만 모의 객체를 과도하게 사용하면 오히려 테스트 코드가 복잡해지는 경우도 발생한다.

별도의 대역 클래스를 만들지않아도 되기에 편하게 느껴질 수 있지만, 결과 값을 확인하는 수단으로 모의 객체를 사용하기 시작하면

결과 검증 코드가 길어지고 복잡해진다.

특히 하나의 테스트를 위해 여러 모의 객체를 사용하기 시작하면 결과 검증 코드의 복잡도는 계속해서 증가하게 된다.

모의 객체는 기본적으로 메서드 호출 여부를 검증하는 수단이기때문에, 테스트 대상과 모의 객체간의 상호작용이 바뀌어서 테스트가 깨질 가능성이 있다.

이러한 이유들로 모의 객체의 메서드 호출 여부를 결과 검증 수단으로 사용하는 것은 주의해야 한다.