feat. collaborators
우리는 테스트 코드를 작성하기 힘든 코드를 많이 보았고 많이 만들어 냈을것이다. 이번 글은 어떻게하면 테스트하기 좋은 코드를 만들어 갈것인지에 대한 이야기이다(원문).
'holder', 'context' 등의 객체들(모두 메소드에서 직접적으로 필요한 Sepecific Object에 대한 접근을 제공하는 grab bag 역할)의 사용을 피해야한다. method에서 직접적으로 사용할 필요가 있는 객체는 method나 constructor의 parameter로 전달하자.
예를들어 User 객체(Holder)가 있고, 메소드에서 Address객체(Specific Object)가 직접적으로 필요할 때 Holder(User)를 전달하여 Specific Object를 얻지말고(User#getAddress() 호출을 통해), 직접적으로 필요한 Specific Object를 method나 constructor의 parameter로 전달하자
- 디미터 법칙 위반: 하나 이상의 도트를 이용하여 객체 그래프를 탐색하여 메소드 호출 체인이 발생하는경우
- 전달된 객체가 직접적으로 사용되지 않는 경우(다른 객체를 얻기 위해서만 사용되는 경우) -> Holder
- 테스트에서 mock을 반환하는 mock을 생성해야 하는 경우
- 의심스러운 이름들이 사용되는 경우 ex. context, environment, principal, container, manager 등등
- fixture setup이 너무 복잡해서 테스트를 작성하기 어려운경우
1. 기만적인 API
- 카드를 통한 결제를 예로 살펴보자
- 메소드 시그니처에서는 단순한 문자열이 카드번호가 필요하다고 되어 있다.
- 메소드 바디에서는 CardProcessor나 PaymentGateway 등 실제로 사용할 객체를 구해서 카드번호를 이용한 결제를 해야한다.
- 메소드 시그니처에 실제 의존성(String이 아니라 CardProcessor나 PaymentGateway)의 표현되지 않는다.
2. 불안정한 Code의 생산
- Holder를 사용하는 경우 변경이 요구될 때 새로운 상호작용 처리를 위해 모든 Holder를 수정해야한다
- 코드가 점진적으로 Holder와 같은 Intermediary에 종속적으로 되어 보다 복잡해 진다.
- Sepecific Object를 사용한다면 코드의 안전성을 높일수 있다.
- 이 경우도 하나 이상의 책임을 갖는 class를 분리하는 경우는 발생 할 수 있다.
- SRP는 항상 고수 하도록 노력하자
3. Bloats your code and complicates what's really happening
- 실제로 사용할 객체를 Holder와 같은 intermediary를 통해 얻는 과정을 불필요하게 코드에 유지함으로써 코드의 길이/혼란이 증가한다
4. 테스트 하기 어렵다
- Holder를 사용하는 메소드를 테스트할 때 해당 메소드가 Holder에서 무엇을 요구할 지 추측하기 어렵다(무엇이든 상관없을지도 추측이 어렵다).
- empty holder를 넘긴후 NullPointerException을 발생시키고, NPE가 발생하지 않도록 Holder의 상태를 처리하고 다시 시도하는 지루한 반복 방식이 된다.
- 이 문제는 '디미터 법칙' 위반으로도 알려져 있다.
- 둘 이상의 도트가 method chaining에 발생하고 해당 method가 getters인 경우
- 테스트에서 mock을 반환하는 mock을 생성해야 하는 경우
- 의심스러운 이름들이 사용되는 경우 ex. context, environment, principal, container, manager 등등
- fixture setup이 너무 복잡해서 테스트를 작성하기 어려운경우
- 반드시 immediate firends와만 상호 작용하자
- 진짜로 필요한 객체만 constructor에 inject하거나 method에 parameter로 전달하자
- 객체를 찾거나 설정하는 책임은 factory나 DI Container등의 도움을 얻어 위임하도록 하자
calcuation(business logic)과 object lookup이 혼합되어 있는데, 실제 클래스의 역할/책임은 세금을 계산하는것이다.
이 예제의 결함은
- 테스트시 User, Invoice 객체 생성이 불필요하게 요구된다
- 메소드 사용자의 입장에서 메소드 시그니처는 decetiful을 하고 있다. 실제 필요한 것은 주소와 금액인데, API는 User와 Invoice라고 알리고 있다.
- 재사용을 하게 된다면 Invoice, User와 같이 불필요한 클래스가 신규소스에 필요하게 된다(Dependency가 증가하게된다).
<수정한 코드>
이 예제의 결함은
- RPCClient가 직접 사용되지 않는다. 왜 전달되었는가 ?
- HttpRequest는 직접 사용되지 않는다. 왜 전달되었는가 ?
- Cookie가 직접 필요한 객체인데, HttpReqeust로 부터 얻어야 한다. HttpRequest를 테스트에서 설정하는 것은 성가신 일이다.
- Authenticator가 직접 필요한 객체인데 RPCClient로 부터 얻어오고 있다.
<수정한 코드>
클래스는 다른 객체에 대한 ServiceLocator 역할을 하지 말고, 하나의 책임을 가져야한다
이 예제의 결함은
- db.getLock()은 Database 클래스의 역할이 아니다. db.getLock().acquire(), db.getLock().release()는 디미터 법칙을 위반하고 있다.
- UpdateBag클래스를 테스트할 때 Database#getLock 메소드를 mock으로 처리해야 한다
- Database 클래스는 database로 동작하고, service locator(lock을 제공)로도 동작한다. '디미터 법칙'도 위반하고, service locator로도 동작하고 있어서 심각한 문제를 가지고있다. Database 클래스의 역할은 eintity들을 database에 저장하는 것 이다.
Database클래스의 getLock메소드는 제거되어야한다. Database가 lock에 대한 참조가 필요하더라도 제거되어야한다. You should never have to mock out a setter or getter
<수정된 코드>
Context객체는 이론상 괜찮아 보인다(context 객체에 어떤 속성이 추가되어도 context객체를 사용하는 클래스의 시그니처가 변경될 필요가 없다). 하지만 context 객체는 테스트하기 매우 어렵다.
이 예제의 결함은
- API는 테스트를 위해 필요한 것인 userContext가 전부라고 표현한다. 하지만 테스트 작성자는 실제 userContext에 어떤 값들이 들어 있어야 하는지 알수 없다. 이런 경우 필요할 것 같은 값을 채워가며 정상적인 결과가 나올 때까지 반복 작업을 해야한다.
- API가 flexible(메소드 시그니처 변경 없이 파라미터를 추가할 수 있다)하다고 할 수도 있다. 하지만 refactoring tool을 사용할 수 없이 코드가 깨지기 쉽고, 사용자가 어떤 파라미터가 필요한지 알수 없다는 문제를 갖는다. API만 보고 어떤 Collaborator가 필요한지 알 수 없다. 이러한 API는 프로젝트의 신규 멤버가 클래스의 목적/역할/행위를 이해하기 어렵게만든다. 이런 경우 API가 의존성에 대해 거짓말한다고 보면된다.
<수정된 코드>
- fluent style을 사용하여 DSL(Domain Specific Language)에서 설정을 하는 경우
- 이 경우 value object(항상 새로 만들어지는)를 생성하는 것이기 때문에 문제가 안된다.
- 디미터 법칙은 하나의 도트(.)를 갖에하는 규칙은 아니다
- 디미터 법칙은 결합도와 관련된 것이며, 이 결합도가 문제가 되는 것은 객체의 내부 구조가 외부로 노출되는 경우로 한정된다.
- 기차 충돌처럼 보이는 코드라도 객체의 내부 구현에 대한 어떤 정보도 외부로 노출하지 않는다면 그것은 디미터 법칙을 준수한것이다.
객체 내부의 어떤 내용도 묻지않느다. 그저 객체를 다른 객체로 변환하는 작업을 수행하라고 시킬 뿐이다.
이런 종류의 코드와 마주쳐야한다면 '과연 여러개의 도트를 사용한 코드가 객체의 내부 구조를 노출 하고있는가?' 를 생각하며 코드를 보기 바란다.
다음글 에는 Global State와 Singletons 결함에 대해 알아보자