Search

7장 가치 있는 단위 테스트를 위한 리팩터링

작성자
진행일
2022/11/16 → 2022/11/24

7. 1 리팩터링할 코드 식별하기

코드를 리팩터링하지 않고서는 테스트 스위트를 크게 개선할 수 없습니다. 먼저 리팩터링의 방향을 설명하고자 코드를 네 가지 유형으로 분류하는 방법을 소개합니다.

코드의 네 가지 유형

모든 프로덕션 코드는 2차원으로 분류할 수 있습니다.
복잡도 또는 도메인 유의성
협력자의 수
코드의 복잡도는 코드 내 분기 지점 수로 정의합니다. 이 숫자가 클수록 복잡도는 더 높아집니다.
도메인 유의성은 코드가 프로젝트의 문제 도메인에 대해 얼마나 의미있는지를 나타냅니다. 일반적으로 도메인 계층의 모든 코드는 최종 사용자의 목표와 직접적인 연관성이 있으므로 도메인 유의성이 높습니다. 반면, 유틸리티 코드는 이런 연관성이 없습니다.
복잡하고 도메인 유의성을 가진 코드는 회귀 방지에 뛰어나기 때문에, 단위 테스트에서 가장 이롭다고 할 수 있습니다. 복잡함과 도메인 유의성은 독립적인데, 예를 들어 주문 가격을 계산하는 메서드에 조건문이 없다면 순환 복잡도는 1이지만 비지니스에 중요한 기능이므로 테스트하는 것이 중요하다고 할 수 있습니다.
두 번째 차원은 클래스 또는 메서드가 가진 협력자 수입니다. 협력자가 많은 코드는 테스트 비용이 많이 들기에, 협력자의 유형이나 수를 잘 고려해야 합니다.
위 그림은 네 가지 코드 유형에 관한 그림입니다.
도메인 모델 및 알고리즘을 단위 테스트하면 노력 대비 가장 이롭습니다. 해당 코드가 복잡하거나 중요한 로직을 수행해서 테스트의 회귀 방지가 향상되기 때문에 가치있다고 할 수 있습니다.
가장 문제가 되는 코드는 지나치게 복잡한 코드인데, 이는 알고리즘과 컨트롤러라는 두 부분으로 나누는 것이 일반적입니다.
지나치게 복잡한 코드를 피하고 도메인 모델과 알고리즘만 단위 테스트하는 것이 매우 가치 있고 유지 보수가 쉬운 테스트 스위트로 가는 길이지만 각각의 테스트가 프로젝트 가치를 높이는 테스트 스위트를 목표로 해야 합니다.
물론 지나치게 복잡한 코드를 제거하는 것은 쉬운 것이 아니기에, 이에 도움이 되는 기법을 알아볼 것입니다.

험블 객체 패턴을 사용해 지나치게 복잡한 코드 분할하기

지나치게 복잡한 코드를 쪼개려면, 험블 객체 패턴을 써야 합니다.
지나치게 복잡한 코드
테스트 대상 코드의 로직을 테스트하려면, 테스트가 가능한 부분을 추출해야 합니다. 결과적으로 코드는 테스트 가능한 부분을 둘러싼 얇은 험블 래퍼가 되는데, 이 험블 래퍼가 테스트하기 어려운 의존성과 새로 추출된 컴포넌트를 붙이지만 자체적인 로직이 거의 없으므로 테스트할 필요가 없습니다.
험블 객체를 사용
사실 육각형, 함수형 아키텍쳐가 모두 정확하게 이 패턴을 구현하고 있습니다. 함수형 아키텍쳐는 프로세스 외부 의존성 뿐만 아니라 모든 협력자와의 커뮤니케이션에서 비즈니스 로직을 분리합니다. 즉, 함수형 코어에는 아무런 협력자도 없습니다.
다른 예시로 MVP, MVC 패턴이 있는데, 두 패턴 모두 비즈니스 로직 (모델), UI 관심사(뷰), 그리고 모델과 뷰 사이의 조정(프레젠터, 컨트롤러)을 분리하는 데 도움이 되는데, 프레젠터와 컨트롤러는 험블 객체에 해당합니다.
또 다른 예로 DDD(도메인 주도 설계)에 나오는 Aggregate 패턴이 있는데, 이 목표 중 하나는 클래스를 클러스터로 묶어서 클래스간 연결을 줄이는 것입니다. 클래스는 해당 클러스터 내부에 강하게 결합되어 있지만, 클러스터 자체는 느슨하게 결합되어 있습니다.

7.2 가치 있는 단위 테스트를 위한 리팩터링하기

이번엔 험블 객체 패턴을 사용해 모든 엔터프라이즈급 애플리케이션에 대한 방법으로 일반화해보겠습니다.

고객 관리 시스템 소개

이번 샘플은 사용자 등록을 처리하는 고객 관리 시스템이며, 모든 사용자가 DB에 저장됩니다. 현재 시스템은 사용자 이메일 변경이라는 단 하나의 유즈케이스만 지원합니다. 이 연산에는 3가지 비즈니스 규칙이 있습니다.
사용자 이메일이 회사 도메인에 속한 경우 해당 사용자는 직원으로 표시된다. 그렇지 않으면 고객으로 간주된다.
시스템은 회사의 직원 수를 추적해야 한다. 사용자 유형이 직원에서 고객으로, 또는 그 반대로 변경되면 이 숫자도 변경되어야 한다.
이메일이 변경되면 시스템은 메시지 버스로 메시지를 보내 외부 시스템에 알려야 한다.
:: 샘플 코드 참고
코드 복잡도는 그리 높지 않지만, changeEmail 메서드는 핵심 비지니스 로직을 담고 있습니다. User 클래스에는 4개의 의존성이 있으며, 두 개는 명시적이고 두 개는 암시적인데, 명시적 의존성은 userIdnewEmail 인수에 해당합니다. 그러나 이 둘은 값이므로 협력자에 포함되지 않습니다. 암시적인 것은 DatabaseMessageBus인데, 이 둘은 외부 협력자에 해당합니다.

리팩토링 1단계 : 암시적 의존성을 명시적으로 만들기

암시적 의존성을 명시적으로 만들기 위해 데이터베이스와 메시지 버스에 대한 인터페이스를 두고 이 인터페이스를 User에 주입한 후 테스트에서 목으로 처리할 수 있습니다. 이 방법은 도움이 되지만, 충분하지는 않습니다.
목을 데이터베이스 의존성에 사용하면 테스트 취약성을 야기할 수 있고, 목 사용 체계는 테스트 유지비가 증가할 수 있습니다.
결국 도메인 모델은 직접적/간접적으로 프로세스 외부 협력자에게 의존하지 않는 것이 훨씬 더 깔끔합니다. 이것이 바로 육각형 아키텍쳐에서 바라는 바입니다.

리팩토링 2단계 : 애플리케이션 서비스 계층 도입

도메인 모델이 외부 시스템과 직접 통신하는 문제를 극복하려면 다른 클래스인 험블 컨트롤러(육각형 아키텍쳐 분류 상 애플리케이션 서비스)로 책임을 옮겨야 합니다.
:: step1 - 샘플 코드 참고
step1
이제 User 클래스는 더 이상 프로세스 외부 의존성과 통신할 필요가 없으므로 테스트 하기가 매우 쉬워졌습니다. 하지만 UserController가 문제입니다. 아직 로직이 꽤 복잡하여, 지나치게 복잡한 코드의 경계에 걸쳐있습니다.
괜찮은 시도였지만 이 코드에는 몇 가지 문제가 있습니다.
프로세스 외부 의존성이 주입되지 않고 직접 인스턴스화된다. 테스트에서 문제가 될 것이다.
컨트롤러는 데이터베이스에서 받은 원시 데이터를 User 인스턴스로 재구성한다. 이는 복잡한 로직이므로 애플리케이션 서비스에 속하면 안 된다.
회사 데이터도 마찬가지다. User는 이제 업데이트된 직원 수를 반환하는데, 이 부분은 User 클래스와 연관이 없는 로직이다. 이 책임은 다른 곳에 있어야 된다.
컨트롤러는 새로운 이메일이 전과 다른지 여부와 관계없이 무조건 데이터를 수정해서 저장하고 메시지 버스에 알림을 보낸다.

리팩토링 3단계 : 애플리케이션 서비스 복잡도 낮추기

UserController가 확실하게 컨트롤러 영역에 있으려면 재구성 로직을 추출해야 합니다.
ORM 라이브러리를 사용한다면 간단하지만, 사용하지 않는다면 도메인 모델에 원시 DB 데이터로 도메인 클래스를 인스턴스화 하는 팩토리 클래스를 작성해야 합니다.
팩토리 클래스를 별도로 만들거나, 도메인 클래스의 정적 메서드로 둘 수도 있을 것입니다.
:: step2 코드 참고
위 재구성 로직은 약간 복잡하지만 도메인 유의성이 없습니다. 사용자 이메일을 변경하려는 클라이언트의 목표와 직접적인 관련이 없기에 유틸리티 코드의 예라고 볼 수 있습니다.

리팩토링 4단계 : 새 Company 클래스 소개

아직 컨트롤러 코드에는 업데이트 된 직원 수를 반환하는 부분이 어색합니다. 이는 책임을 잘못 뒀다는 신호이자 추상화가 없다는 신호입니다. 그러므로, 또 다른 도메인 클래스인 Company를 만들어야 합니다.
:: step3 코드 참고
Company 클래스에는 메서드가 2개 존재하는데, 이 메서드는 5장에서 언급한 ‘묻지 말고 답하라’라는 원칙을 준수하는데 도움이 됩니다. 잘못 둔 책임을 제거하니 User가 더 깔끔해졌습니다. 아래 그림에서 각 클래스의 위치를 볼 수 있습니다.
User의 협력자(Company)가 하나 생겼기 때문에 오른쪽으로 이동하여 테스트하기가 어려워졌지만, 많이 어려워진 것은 아닙니다.
이제 모든 복잡도가 팩토리로 이동하여 UserController는 확실히 컨트롤러에 속하게 됩니다.
함수형 아키텍처와 비교했을 때, 함수형 코어는 어떠한 부작용을 일으키지 않지만, 우리의 도메인 모델은 부작용을 일으킵니다. 하지만 모든 부작용은 변경된 사용자 이메일과 직원 수의 형태로 도메인 모델 내부에 남아있습니다. 컨트롤러가 User 객체와 Company 객체를 DB에 저장할 때만 부작용이 도메인 모델의 경계를 넘는 것입니다.
마지막 순간까지 모든 부작용이 메모리에 남아있다는 사실로 인해 테스트 용이성이 크게 향상되는데, 이는 메모리에 있는 객체의 출력 기반 테스트와 상태 기반 테스트로 모든 검증을 수행할 수 있기 때문입니다.

7.3 최적의 단위 테스트 커버리지 분석

이제 리팩터링을 마쳤으니, 프로젝트의 어느 부분이 어떤 코드에 속하는지와 어떻게 테스트 해야하는지 알아보겠습니다.
협력자가 거의 없음
협력자가 많음
복잡도와 도메인 유의성이 높음
User의 changeEmail, Company의 ChageNumberOfEmployess와 isEmailCorporate, ComanyFactory의 create
-
복잡도와 도메인 유의성이 낮음
User와 Company의 생성자
UserController의 changeEmail

도메인 계층과 유틸리티 코드 테스트하기

표에서 좌측 상단 테스트 메서드는 비용 대비 최상의 결과를 가져다 줍니다. 코드의 복잡도나 도메인 유의성이 높으면 회귀 방지가 뛰어나고 협력자가 거의 없어 유지비도 가장 낮습니다.
아래는 User를 어떻게 테스트하는지에 대한 예시입니다.
@Test fun changeing_email_from_non_corporate_to_corporate() { val company = Company("mycorp.com", 1) val sut = User(1, "user@gmail.com", UserType.Customer) sut.changeEmail("new@mycorp.com", company) assertEquals(2, company.numberOfEmployees) assertEquals("new@mycorp.com", sut.email) assertEquals(UserType.Emplyoee, sut.type) }
Kotlin
복사
전체 커버리지를 달성하려면, 다음과 같이 테스트 세 개가 더 필요합니다.
fun changing_email_from_corporate_to_non_corporate() fun changing_email_without_changing_user_type() fun changing_email_to_the_same_one()
Kotlin
복사
다른 세 가지 케이스에 대한 테스트는 훨씬 짧을 것이고, 파라미터화된 테스트로 여러 테스트 케이스를 묶을 수도 있습니다. (JUnit5)
@ParameterizedTest @MethodSource("companySource") fun differentiates_a_corporate_email_from_non_corporate( domain: String, email: String, expectedResult: Boolean ) { val sut = Company(domain, 0) val isEmailCorporate = sut.isEmailCorporate(email) assertEquals(expectedResult, isEmailCorporate) } companion object { @JvmStatic fun companySource() = listOf( Arguments.of("mycorp.com", "email@mycorp.com", true), Arguments.of("mycorp.com", "email@gmail.com", false) ) }
Kotlin
복사

나머지 세 사분면에 대한 코드 테스트하기

복잡도가 낮고 협력자가 거의 없는 코드는 UserCompany의 생성자를 들 수 있습니다.
이런 생성자는 단순해서 노력을 들일 필요가 없으며, 테스트는 회귀 방지가 떨어질 것입니다. 복잡도가 높고 협력자가 많은 코드를 리팩터링으로 제거했으므로 테스트 할 것이 없습니다.

전제 조건을 테스트해야 하는가?

// in Company fun changeNumberOfEmployees(delta: Int) { require(numberOfEmployees + delta >= 0) numberOfEmployees += delta }
Kotlin
복사
위 메서드는 회사의 직원 수가 음수가 돼서는 안 된다는 전제 조건이 있습니다. 이 전제 조건은 예외 상황에서만 활성화되는 보호 상황이기에, 코드에 오류가 있는 경우에만 직원 수가 0미만으로 내려가게 됩니다.
일반적으로 권장하는 지침은 도메인 유의성이 있는 모든 전제 조건을 테스트하는 것입니다. 반면 도메인 유의성이 없는 전제 조건을 테스트 하는 데 시간을 들일 필요는 없습니다.
// in User fun create(data: List<Any>): User { require(data.size >= 3) // 도메인 의미가 없으므로 테스트하기에 가치가 없다. ... }
Kotlin
복사

컨트롤러에서 조건부 로직 처리

조건부 로직을 처리하면서 동시에 프로세스 외부 협력자 없이 도메인 계층을 유지 보수하는 것은 까다롭고 절충이 있기 마련입니다.
육각형 아키텍처와 함수형 아키텍처는 프로세스 외부 의존성에 대한 모든 참조가 비지니스 연산의 가장자리로 밀려났을 때 가장 효과적입니다.
저장소에서 데이터 검색 → 비즈니스 로직 실행 → 데이터를 다시 저장소에 저장
문제는 이렇게 단계가 명확하지 않은 경우가 많다는 것입니다. 6장에서 본 것 처럼, 의사 결정 프로세스의 중간 결과를 기반으로 프로세스 외부 의존성에서 추가 데이터를 조회해야 할 수 도 있습니다.
이전 장에서 봤듯, 이런 상황에서는 세 가지 방법이 있습니다.
외부에 대한 모든 읽기와 쓰기를 가장자리로 밀어낸다.
도메인 모델에 프로세스 외부 의존성을 주입하고 비즈니스 로직이 해당 의존성을 호출할 시점을 직접 결정할 수 있게 한다.
의사 결정 프로세스 단계를 더 세분화하고, 각 단계별로 컨트롤러를 실행하도록 한다.
문제는 다음 세 가지 특성의 균형을 맞추는 것입니다.
도메인 모델 테스트 유의성 : 도메인 클래스의 협력자 수와 유형에 따른 함수
컨트롤러 단순성 : 의사 결정(분기) 지점이 있는지에 따라 따름
성능 : 프로세스 외부 의존성에 대한 호출 수로 정의
위에서 언급한 방법은 세 가지 특성 중 두 가지 특성만 갖습니다.
외부에 대한 모든 읽기와 쓰기를 가장자리로 밀어낸다. → 성능 저하
도메인 모델에 프로세스 외부 의존성을 주입하고 비즈니스 로직이 해당 의존성을 호출할 시점을 직접 결정할 수 있게 한다. → 도메인 테스트 유의성 저하
의사 결정 프로세스 단계를 더 세분화하고, 각 단계별로 컨트롤러를 실행하도록 한다. → 컨트롤러가 단순하지 않게 됨.
위의 세 가지 특성을 모두 충족하는 해법은 없습니다. 따라서 세 가지 중 두 가지를 선택해야 합니다.
대부분의 소프트웨어 프로젝트에서는 성능이 매우 중요하므로 첫 번째 방법(외부에 대한 읽기와 쓰기를 비지니스 연산 가장자리로 밀어내기)은 고려할 필요가 없습니다.
두 번째 옵션(도메인 모델에 프로세스 외부 의존성 주입하기)은 대부분 코드를 지나치게 복잡한 사분면에 넣습니다. 이러한 코드는 비즈니스 로직과 프로세스 외부 의존성과의 통신이 분리되지 않아 테스트와 유지 보수가 훨씬 어려워지므로 이러한 방법은 피하는 것이 좋습니다.
세 번째 옵션(의사 결정 프로세스 단계를 더 세분화하기)은 컨트롤러를 더 복잡하게 만들지만, 이를 완화 할 수 있는 방법이 있습니다.

CanExecute / Execute 패턴 사용

컨트롤러 복잡도가 커지는 것을 완화하는 첫 번째 방법은 CanExecute / Execute 패턴을 사용하여 비즈니스 로직이 도메인 모델에서 컨트롤러로 유출되는 것을 방지하는 것입니다.
이전 예시에서 이메일은 사용자가 확인할 때까지만 변경할 수 있다고 가정합시다. 사용자가 확인한 후에 이메일을 변경하려고 하면 오류 메시지가 표시되어야 합니다.
이 요구 사항을 담고자 User 클래스에 isEmailConfirmed라는 필드를 추가했습니다.
User( var userId: Int, var email: String, var type: UserType, var isEmailConfirmed: Boolean, )
Kotlin
복사
확인할 위치를 정하는 데 두 가지 옵션이 있습니다. 첫 번째는 UserchangeEmail 메서드에 넣는 것입니다.
fun changeEmail(newEmail: String, company: Company): String { if (isEmailConfirmed) return "Can`t change a confirmed email" ... }
Kotlin
복사
그 다음 이 메서드의 출력에 따라 컨트롤러는 오류를 리턴하거나 필요한 모든 부작용을 발산할 수 있습니다.
fun changeEmail(userId: Int, newEmail: String): String { ... val error = user.changeEmail(newEmail, company) error?.let { return it } ... return "OK" }
Kotlin
복사
위 구현으로 컨트롤러가 의사결정을 하지는 않지만, 성능 저하를 감수해야 합니다. 이메일을 확인해 변경할 수 없는 경우에도 무조건 DB에서 Company 인스턴스를 검색합니다. 이는 모든 외부 읽기와 쓰기를 비즈니스 연산 끝으로 밀어내는 예시입니다.
두 번째 옵션은 isEmailConfirmed 의 확인을 User에서 컨트롤러로 옮기는 것입니다.
fun changeEmail(userId: Int, newEmail: String): String { ... if (user.isEmailConfirmed) return "Can`t change a confirmed email" ... user.changeEmail(newEmail, company) ... return "OK" }
Kotlin
복사
위 구현은 성능은 그대로 유지됩니다. 하지만 이제 의사 결정 프로세스는 두 부분으로 나뉩니다.
이메일 변경 진행 여부 (컨트롤러에서 수행)
변경 시 해야 할 일 (User에서 수행)
이제 isEmailConfirmed 플래그를 먼저 확인하지 않고 이메일을 변경할 수 있지만, 도메인 모델의 캡슐화가 떨어집니다.
이런 파편화를 방지하려면 User에 새로운 메서드를 둬서, 이 메서드가 잘 실행되는 것을 이메일 변경의 전제 조건으로 해야 합니다. 아래 코드는 CanExecute/Execute 패턴을 따르게끔 수정한 버전입니다.
// in User fun canChangeEmail(): String? { if (isEmailConfirmed) return "Can`t change a confirmed email" return null } fun changeEmail(newEmail: String, company: Company) { require(canChangeEmail() == null) ... }
Kotlin
복사
이 방법에는 두 가지 중요한 이점이 있습니다.
컨트롤러는 더 이상 이메일 변경 프로세스를 알 필요가 없다.
changeEmail의 전제 조건이 추가돼도 먼저 확인하지 않으면 이메일을 변경할 수 없도록 보장한다.
이 패턴을 사용하면 도메인 계층의 모든 결정을 통합할 수 있으며 컨트롤러에 의사 결정 지점은 더 이상 존재하지 않습니다.

도메인 이벤트를 사용하여 도메인 모델 변경 사항 추적

도메인 모델을 현재 상태로 만든 단계를 빼기 어려울 때가 있습니다. 하지만 애플리케이션에서 정확히 무슨 일이 일어나는지 외부 시스템에 알려야 하기 때문에 이런 단계를 아는 것이 중요할 수 있습니다.
도메인 이벤트로 이러한 추적을 구현할 수 있습니다.
추적 요구 사항이 생겨서, 메시지 버스에 메시지를 보내서 외부 시스템에 변경된 사용자 이메일을 알려줘야 한다고 가정해봅시다. 현재 구현에는 알림 기능에 결함이 있습니다. 이메일이 변경되지 않은 경우에도 메시지를 보내고 있습니다.
이메일이 같은지 검사하는 부분을 컨트롤러로 옮겨서 버그를 해결할 수 있지만, 비즈니스 로직이 파편화되는 문제가 있습니다. 이 때 도메인 이벤트를 사용하여 복잡성을 해결할 수 있는데, 구현 관점에서 도메인 이벤트는 외부 시스템에 통보하는데 필요한 데이터가 포함된 클래스입니다.
구체적인 예로는 유저의 ID와 이메일을 들 수 있습니다.
data class EmailChangedEvent(val userId: Int, val newEmail: String)
Kotlin
복사
User는 이메일이 변경될 때 새 요소를 추가할 수 있는 이벤트 컬렉션을 가지게 됩니다. 다음은 changeEmail을 리팩터링한 후입니다.
// in User private val _emailChangedEvents: MutableList<EmailChangedEvent> = mutableListOf() val emailChangedEvents: List<EmailChangedEvent> = _emailChangedEvents fun changeEmail(newEmail: String, company: Company) { ... _emailChangedEvents.add(EmailChangedEvent(userId, newEmail)) // 이벤트 추가 }
Kotlin
복사
그 다음 컨트롤러는 이벤트를 메시지 버스의 메시지로 변환합니다.
// in UserController fun changeEmail(userId: Int, newEmail: String) { ... user.emailChangedEvents.forEach { _messageBus.sendEmailChangedMessage(it.userId, it.newEmail) } }
Kotlin
복사
저장 로직이 도메인 이벤트에 의존하지 않으므로 여전히 Company 인스턴스와 User 인스턴스는 무조건 DB에 저장됩니다. 이는 DB의 변경 사항과 메시지 버스의 메시지가 다르기 때문입니다.
DomainEvent 같은 베이스 클래스를 추출해서 모든 도메인 클래스에 대해 이 베이스 클래스를 참조하게 할 수도 있으며, 컨트롤러에서 도메인 이벤트를 수동으로 발송하는 대신, 별도의 이벤트 디스패처를 작성할 수도 있습니다.
도메인 이벤트는 컨트롤러에서 의사 결정 책임을 제거하고 해당 책임을 도메인 모델에 적용함으로써 외부 시스템과의 통신에 대한 단위 테스트를 간결하게 합니다.
@Test fun changing_email_from_corporate_to_non_corporate() { val company = Company("naver.com", 1) val sut = User(1, "user@naver.com", UserType.Employee, false) sut.changeEmail("new@gmail.com", company) assertEquals(0, company.numberOfEmployees) assertEquals("new@gmail.com", sut.email) assertEquals(UserType.Customer, `is`(sut.type)) assertEquals( listOf(EmailChangedEvent(1, "new@gmail.com")), sut.emailChangedEvents ) }
Kotlin
복사

결론

이 장에서 다뤘던 주제는 외부 시스템에 대한 애플리케이션의 부작용을 추상화하는 것이었다. 비즈니스 연산이 끝날 때까지 이런 부작용을 메모리에 둬서 추상화하고, 프로세스 외부 의존성 없이 단순한 단위 테스트로 테스트할 수 있었다.
추상화 할 것을 테스트하기 보다 추상화를 테스트하는 것이 더 쉽다.
식별할 수 있는 동작이 되려면 메서드는 다음 두 가지 기준 중 하나를 충족해야 한다.
클라이언트 목표 중 하나에 직접적인 연관이 있음
외부 애플리케이션에서 볼 수 있는 프로세스 외부 의존성에서 부작용이 발생함
컨트롤러의 changeEmail 메서드는 식별할 수 있는 동작이며, 메시지 버스에 대한 호출도 마찬가지다. 따라서 이 두 메서드의 호출을 모두 확인해야 한다. 그러나 컨트롤러에서 User로 가는 후속 호출은 외부 클라이언트의 목표와 직접적인 연관이 없기에, 컨트롤러의 동작을 테스트할 때 컨트롤러가 User에 수행하는 호출을 검증해서는 안 된다.
식별할 수 있는 동작과 구현 세부 사항을 양파의 여러 겹으로 생각하라. 외부 계층의 관점에서 각 계층을 테스트하고, 해당 계층이 기저 계층과 어떻게 통신하는지는 무시하라. 이러한 계층을 하나씩 벗겨가면서 관점을 바꾸게 된다. 이전에 구현 세부 사항이었던 것이 이제는 식별할 수 있는 동작이 되며, 이는 또 다른 테스트로 다루게 된다.