feat. static
우리는 테스트 코드를 작성하기 힘든 코드를 많이 보았고 많이 만들어 냈을것이다. 이번 글은 어떻게하면 테스트하기 좋은 코드를 만들어 갈것인지에 대한 이야기이다(원문).
Global State와 Singleton은 API가 진정한 종속성에 대해 거짓말을 하도록 만듭니다. 종속성을 제대로 이해하려면 개발자가 코드의 모든 줄을 읽어야 합니다. 그것은 테스트 작성이 어렵고 변경된 전역 상태로 인해 후속 또는 병렬 테스트가 예기치 않게 실패할 수 있습니다. 수동 또는 Guice 종속성 주입을 사용하여 정적 종속성을 끊어야 합니다.
- singleton을 사용하거나 추가하는 경우
- static field나 static mathod를 사용하거나 추가하는 경우
- static initialization 블록을 사용하거나 추가하는 경우
- registry를 사용하거나 추가하는 경우
- service locator를 사용하거나 추가하는 경우
- 전역 상태의 근본 문제는 전역적으로만 접근 할 수 있다는 것이다.
- 객체지향에서는 객체는 (생성자 또는 메소드 호출을 통해) 직접 전달된 다른 객체와만 상호작용 할 수 있어야한다
- 다시 말해, 두 객체 A와 B를 인스턴스화하고 A에서 B로 참조를 전달하지 않으면 A와 B 모두 상대방을 참조하거나 상대방의 상태를 수정할 수 없다. 이것은 매우 바람직한 코드 속성이다.
- 객체 A는 개발자에게 알려지지 않은 싱글톤 C를 참조하며 수정할 수 있다. 객체 B가 인스턴스화될 때 싱글톤 C를 사용하며 A와 B가 C를 통해 서로 영향을 미칠 수 있다.
- singleton을 사용할 때의 문제는 시스템에 일정량의 결합이 생긴다는것이다.
- 테스트 격리의 본질은 객체의 collaborator를 대체 구현으로 대체할 수 있는 능력을 가정한다. 디자인을 변경하지 않는 한 singleton을 사용하면 singleton에 의존하게 된다.
- static은 다른 클래스, 다른 클래스의 서브 클래스 또는 래핑된 버전과의 협업을 방해하고 종속성을 하드 코딩함으로써 다형성의 능력과 유연성을 잃게된다.
- static을 사용하는 모든 테스트는 예상되는 상태에서 시작해야하며 그렇지 않으면 테스트가 실패한다.
- static은 절차지향적이며 명령형 스타일이다. 객체지향은 선언형 스타일 이어야한다.
- static field의 존재
- 싱글톤에는 initializeForTest(..), uninitialize(..) 및 기타 재설정 방법이 있다.
- 개별 테스트는 통과하지만 전체 테스트는 실패한다(그 반대도 마찬가지).
- 실행순서를 변경하면 테스트가 실패한다
- 플래그 값을 읽거나 쓰거나 할때 Flags.disableStateCheckingForTest() and Flags.enableStateCheckingForTest() is called.
- Singleton, Mingletons, Hingletons및 Fingletons가 있거나 사용
- 정적 메소드를 호출
- static, singleton의 사용을 멀리하자
- 의존성 주입을하자
- 공유 상태가 필요한 경우 테스트 하기 쉬운 방식으로 어플리케이션 범위 싱글톤으로 관리할 수 있도록 Guice와 같은 DI tool을 사용하자
- 필요 악으로 사용해야한다면 또는 코드에 static이 많고 당장 Di tool을 도입하기 힘들다면 우리의 코드가 객체를 직접 처리할 수 있도록 static을 감싸는 클래스를 만들어서 고립시키자.
- 라이브러리 클래스의 정적 메소드를 사용하는 경우에도 인터페이스를 구현하는 객체나 클래스로 래핑하하여 필요한 객체를 전달하자. 이렇게 하면 테스트시에 인터페이스를 스텁하고 static 종속성을 제거할 수 있다.
- 정적 플래그 값사용의 경우에도 주입가능한 객체에 바인딩 하여 그 플래그 값이 필요한 곳이면 어디든지 전달하여 사용하자
여러 가지 이유로 응용 프로그램에 객체가 하나만 존재해야 할 수 도 있다. 일반적인 싱글톤의 경우 유연성과 테스트 작성시에 어려움을 수반한다. 그래서 보통 setForTest(...)/resetForTest()등으로 내부에 캡슐화된 정적 객체를 교체한다. 즉, 캡슐화가 해제되는 것이다
이 예제의 결함은
- 모든 static 메서드 사용과 마찬가지로 구현을 다형성으로 변경 할 수 없다.
- 공유 전역 상태에 대한 각 스레드의 충돌이 일어 나므로 테스트를 병렬로 실행 할 수 없다.
단 하나의 인스턴스를 보장해야하는경우 Di tool을 활용하여 개체가 singleton범위임을 설정하고 사용하도록하자. 이를 활용하는것은 설계상 문제가 아니다.
<수정된 코드>
static 필드를 플래그로 사용시에 설정을 공유하는데 사용된다. 전역 상태를 공유하기때문에 테스트 전후에 신중하게 조정해야한다. 그렇지 않으면 후속 테스트가 실패 할수 있다.
이 예제의 결함은
- 한 테스트에서 플래그 값을 설정한 다음 재설정하는 것을 잊어버려 후속 테스트가 실패 할 수 있다.
- 테스트가 실행하기 위해 특정 플래그의 다른 값이 필요한 경우 병렬화 할 수 없다. 플래그 설정시 경쟁조건이 발생하고 다른 테스트는 깨질 것이다.
- 플래그가 필요한 코드는 깨지기 쉽고, 플래그의 사용여부는 API를 보고 사용자는 알 수가 없다. 즉, API가 거짓말을 하는 것이다.
이러한 문제를 해결하려면 의존성 주입을 사용하자. 플래그를 클래스로 래핑하고 이를 필요로하는곳에 주입하여 사용하게 하도록하자.
<수정된 코드 >
이 문제는 테스트 개별은 통과할 수 있지만 전체 개념을 테스트하면 실패 할 수 있다. 테스트 순서가 변경되는 경우에도 실패하게 된다.
테스트 실행 순서에 따라 RpcClient를 먼저 로드하면 FLAG_user_RealBackend가 읽히고 값이 설정된다. 다른 백엔드를 사용하려는 테스트는 그렇게 할 수 없다. 이유는 static은 테스트 설정과 해제 사이에 전역 상태를 유지 하기 때문이다. RpcClient의 백엔드에 대한 setter를 노출하여 이 문제를 해결하면 이전의 예제1과 동일한 문제가 발생한다. 정적 문제의 근본적인 해결책이 되지 않는다.
이 예제의 결함은
- static 초기화 블록은 한 번 실행되며 테스트에서 재정의 할 수 없다.
- 백엔드는 한번 설정되며 향후 테스트를 위해 변경할 수 없다. 이로 인해 테스트 순서에 따라 일부 테스트가 실패 할 수 있다.
결국 정적 상태를 제거해야지만 해결 가능하다. 다시 또 나왔지만 의존성 주입을 통해 해결이 가능하다. Di tool을 활용해서 어플리케이션 범위의 RpcClient 단일 인스턴스를 관리하자. 이렇게 되면 일반적인 Singleton에 비해 테스트가 더 쉬워진다.
<수정된 코드 >
라이브러리나 프레임워크의 정적 메소드를 사용해야 할 때가 있는데 이는 우리가 어떻게 컨트롤할 수 없다.
이 예제의 결함은
- TrackStatusChecker의 메서드는 정적 호출로 잠겨 있기 때문에 우너하지 않는 경우에도 강제로 실행된다.
- 테스트가 느려질 수 있으며 라이브러리의 전역 상태를 변경할 위험이 있다.
- 정적 메서드는 재정의 할 수 없고 의존성 주입을 사용할 수 없다.
- 정적 메서드는 테스트코드에서 seam을 제거한다
근본적으로는 문제가되는 static 메서드를 제거하는거지만 사용해야한다면 클래스로 래핑하거나 인터페이스를 생성하여 고립시키는것이다. 그렇게 하면 정적 메서드는 오직 래핑된 클래스 안에서만 호출되기 때문에 언젠가는 모든 정적 메서드 호출을 코드상에서 안보이게 할 수 있다. 오직 래핑된 클래스 안에만 남는것이고 이 방법을 이용하면 죽은 코드를 고립시킨 후 점진적으로 우리 코드를 수정할 수 있다. 이렇게 하는 것이 더 쉽게 테스트 코드를 작성할 수 있게된다.
<수정된 코드 >
다음글에는 클래스가 많은 일을 수행하는 결함에 대해 알아보자