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 )          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 )          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 )               } } 
이제 대역 클래스 코드에 해당하는 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 )          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       fun  setInvalidNo (invalidNo: String )          this .invalidNo = invalidNo     }     fun  setTheftNo (theftNo: String )          this .theftNo = theftNo     }     override  fun  validate (cardNumber: String )          if  (invalidNo != null  && invalidNo == cardNumber) {             return  CardValidity.INVALID         }         if  (theftNo != null  && theftNo == cardNumber) {              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 )          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()         }     } } 
이제 WeakPasswordChecker에 checkPasswordWeak() 메서드를 추가한다.
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  {          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()      @BeforeEach      fun  setUp ()          userRegister = UserRegister(stubPasswordChecker, fakeRepository)      }     @Test      @DisplayName("약한 암호면 가입 실패" )      fun  weakPassword ()          stubPasswordChecker.setWeak(true )          assertThrows(WeakPasswordException::class .java) {             userRegister.register("id" , "pw" , "email" )         }     }     @Test      @DisplayName("이미 같은 ID가 존재하면 가입 실패" )      fun  dupIdExists ()                   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,  ) {      } 
이제 레포지토리의 오류를 없애보자.
먼저 User 클래스를 작성한다.
1 2 3 4 5 data  class  User (    val  id: String,     val  password: String,     val  email: String, ) 
이제 MemoryUserRepository 에 save() 함수를 추가한다.
1 2 3 4 interface  UserRepository  {         fun  save (user: User )  } 
UserRepository 인터페이스에 위 메서드를 추가해서 MemoryUserRepository 에 save() 메서드 구현을 강제한다.
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)          if  (user != null ) {             throw  DupIdException()         }     } } 
UserRepository에 findById() 메서드를 추가하여 id로 회원을 조회하여 User VO를 반환받도록 한다.
이제 findById()를 인터페이스와 구현체에 추가하자.
1 2 3 4 5 6 interface  UserRepository  {    fun  save (user: User )      fun  findById (id: String )  } 
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 )          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(              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 ()          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()           @Test      @DisplayName("가입하면 메일을 전송함" )      fun  whenRegisterThenSendMail ()          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,  ) {     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())          assertEquals("email@email.com" , spyEmailNotifier.getEmail())     } } 
spyEmailNotifier.isCalled()에서 아직 false를 반환하고 있기 때문이다.
이를 통과시키려면 아래 두 가지에 대해 고려해야한다.
UserRegister가 EmailNotifier의 이메일 발송 기능을 호출스파이의 이메일 발송 기능 구현에서 호출 여부 기록 
 
먼저 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 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)      } } 
아직 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 ()          return  email     }     override  fun  sendRegisterEmail (email: String )          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 ()          return  email     }     override  fun  sendRegisterEmail (email: String )          this .called = true          this .email = email      } } 
이제 모든 테스트가 통과되는 것을 확인할 수 있다.
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에 담긴다.
마지막으로 ArgumentCaptor의 value 값을 검증하면 보관도어있는 인자를 얻을 수 있다.
7.5. 상황과 결과 확인을 위한 협업 대상(의존) 도출과 대역 사용 하나의 테스트는 특정 상황에서 기능을 실행하고 그 결과를 확인한다.
이때 실제 구현을 이용하면 특정 상황을 모사하기 어려운 경우게 많다.
이렇게 제어하기 힘든 외부 상황이 존재하면 아음과 같은 방법으로 의존을 도출하고 이를 대역으로 대체할 수 있다.
제어하기 힘든 외부 상황을 별도 타입으로 분리 
테스트 코드는 별도로 분리한 타입의 대역을 생성 
생성한 대역을 테스트 대상의 생성자 등을 이용해서 전달 
대역을 이용해서 상황 구성 
 
7.6. 대역과 개발 속도 TDD 과정에서 대역을 사용하지않고 실제 구현만을 사용한다고 가정하자.
이 실제 구현이 외부에 의존하는 경우 외부에서 결과를 돌려주기까지 테스트를 완료하지못하고 계속 대기해야하는 문제가 생긴다.
이는 빠른 피드백을 얻고자 하는 단위 테스트 원칙에도 위배된다.
따라서 대역을 사용하여 실제 구현 없이 다양한 상황에 대한 테스트를 수행해서, 의존하는 대상을 구현하지 않아도 대상을 완성하고 개발 속도를 올리는 데 그 의의가 있다.
7.7. 모의 객체를 과하게 사용하지 않기 모의 객체는 스텁과 스파이를 지원하므로 전방위적으로 많이 쓰이는 방법이다.
하지만 모의 객체를 과도하게 사용하면 오히려 테스트 코드가 복잡해지는 경우도 발생한다.
별도의 대역 클래스를 만들지않아도 되기에 편하게 느껴질 수 있지만, 결과 값을 확인하는 수단으로 모의 객체를 사용하기 시작하면
결과 검증 코드가 길어지고 복잡해진다.
특히 하나의 테스트를 위해 여러 모의 객체를 사용하기 시작하면 결과 검증 코드의 복잡도는 계속해서 증가하게 된다.
모의 객체는 기본적으로 메서드 호출 여부를 검증하는 수단이기때문에, 테스트 대상과 모의 객체간의 상호작용이 바뀌어서 테스트가 깨질 가능성이 있다.
이러한 이유들로 모의 객체의 메서드 호출 여부를 결과 검증 수단으로 사용하는 것은 주의해야 한다.