brunch

You can make anything
by writing

C.S.Lewis

by myner Aug 20. 2021

Testable Code 1

feat. constructor

우리는 테스트 코드를 작성하기 힘든 코드를 많이 보았고 많이 만들어 냈을것이다. 이번 글은 어떻게하면 테스트하기 좋은 코드를 만들어 갈것인지에 대한 이야기이다(원문).



Constructor does Real Work 결함


constructor에서 'collaborator를 생성/초기화', '다른 service와 상호작용', '자기 자신의 상태설정' 과 같은 작업은 테스트에 필요한 seam을 제거하고, 서브클래스와 mock이 원치 않는 행위를 상속받게 만든다.

다시 말해서 constructor에서 많은 일을 하면 테스트에서 생성, collaborator 변경을 어렵게 만든다




Warning Signs


- constructor나 필드 정의에서 new 키워드가 사용된 경우

- constructor나 필드 정의에서 static method를 호출한 경우

- constructor에서 필드 할당문 이외의 다른 문장이 사용된 경우

- constructor 종료 후에 객체의 완전히 초기화 되지 않은 경우(initialize 메소드 주의)

- constructor에 조건문/루프문 같은 제어 로직이 있는경우

- factory나 builder 사용 없이 constructor에 복잡한 객체 그래프 생성 로직이 있는경우

- initialization 블록을 추가하거나 사용하는 경우




왜 결함인가?


- constructor에서 collaborator를 생성/초기화하면 유연성이 없어지고 완성도가 떨어진 결합도(coupling)이 높은 설계가 된다.

- 이런 경우 테스트시 테스트 collaborator주입이 불가능하다 

- SRP위반: 객체 그래프 생성은 엄연히 독립된 책임이고 이 책임은 constructor에서 수행하는것은 SRP 위반이다

- 직접 테스트하는 것이 어려움: 치환 불가한 collaborator의 미묘한 변경은 constructor에 반영되어야 하는 경우가 발생할 수 있다. 이런 경우 테스트가 어려워진다

- 테스트를 위한 서브클래싱/Overriding이 여전히 결함으로 존재한다

    - 서브 클래스에서 재정의 될 것으로 예상되는 메서드에 위임되기 때문

- 테스트를 위한 Collaborator로 치환이 불가하다.

- seam을 없앤다

- multiple constructor로 해결 되지는 않는다.

    - 여전히 다른 계층에 의해 사용될것이기 때문이다.

- bottom line

    - 고립된 상태나 테스트 더블 collaborator로 얼마나 쉽게 클래스를 생성할 수 잇는가가 관건이다

    - 생성 하기 어렵다면 constructor에 너무 많은 일을 하는것이다

    - 테스트에서 생성하기 어렵다면 해당 클래스를 사용하는 다른 코드 에서도 사용하기 어렵다.




결함의 증거


- 테스트에서 테스트 더블로 치환하고 싶은 객체를 new 키워드로 생성하고 있지 않는가?

- mocking, injection이 불가한 static method호출이 있는가?

- conditional/loop 로직이 존재하는가?

  - 해당 클래스뿐만아니라 관련 클래스를 테스트 할 때마다 conditional/loop 로직을 이해하고 이에대한 설정 코드과 과도하게 들어가게된다.



결함 고치는 tip


- 객체 그래프 생성/초기화 책임을 다른 객체로 이동시켜라

- builder, factory 등을 추출하고 이런 collaborator들을 constructor에 전달하라 

- di tool이나 수동 의존성 주입으로 해결 




예제 1: 생성자 또는 필드에서 new로 객체생성 



위의 예제는 객체 그래프 생성과 로직이 섞여있다. 테스트를 작성할 때 운영환경과 다른 객체 그래프(대개는 좀 작은 객체 그래프로, 어떤 객체들은 테스트 더블로 치환된)를 만들고자 할 때가 많다.


new 키워드를 constructor에 유지하고는 테스트를 위한 객체그래프를 만들수 없다.


위 예제의 결함은

- 필드 정의에 new 키워드를 사용했다.

- 만일 Kitchen이 파일/데이터베이스와 같이 생성 비용이 많이 드는 경우라면 House 객체를 만들기 어려워진다.

- kitchen이나 bedroom의 행위를 polymorphical하게 변경할 수 없기 때문에 설계가 깨지기 쉽다.


kitchen이 value object(List, Map, User, Email ,Address 등과 같이)라면 value object가 service 객체를 참조하지 않기 때문에 inline으로 생성 할 수 있다. service객체는 테스트 더블로 치환될 필요가 있는 타입이다. 그래서 static method 호출로 직접 생성해서는 안된다.


<수정된 코드 >



예제 2: 생성자에서 부분적으로 초기화된 객체를 가져와서 나머지 부분 초기화 



객체 그래프 생성(Garden의 collaborator Gardener 를 생성하고 설정하는 것)은 Garden이 수행해야하는 책임과는 다른 책임이다. 이 처럼 constructor에 설정과 생성이 섞여있으면, 객체는 깨지기 쉽고 구체적 객체 그래프 구조에 얽메이게 된다. 이로 인해 코드를 변경하기 어렵고, 테스트 하기 거의 불가능해진다.


위 예제의 결함은

- Garden은 Gardener를 필요로 하지만 Gardener를 설정하는것은 Garden의 책임이 아니다.

- Garden의 유닛 테스트에서 warkday는 constructor에서 설정된다. 이로 인해 Joe가 하루에 12시간 일하게된다. 이러한 의존성 결정은 테스트가 느리게 동작하게 만든다. 유닛 테스트에서는 짧은 시간만 일하도록 설정하기를 원한 것이다.

- boots를 변경할 수 없다. BootsWithMassiveStaticInitBlock을 사용하고 로딩하는 문제를 회피하기 위해 boots에 대한 테스트 더블을 사용하고 싶을것이다(Static initialization block은 위험하고 문제를 야기할 소지가 많다. 특히 전역상태와 상호작용할 경우는 더 위험하다).


초기화되어야 하는 coolaborator가 필요한 경우 2개의 객체를 갖도록 하라. 2개의 객체를 초기화하고 완전히 초기화된 상태로 클래스의 constructor에 전달하라 


<수정된 코드>



예제 3: 디미터 법칙 위반



어플리케이션의 전역 상태에 접근하고 RPCClient singleton의 holder를 얻었다. singleton은 필요 없는 것이고 user만이 필요한 것인데 말이다. 첫번째 잘못은 seam을 제공하지 않는 static method를 사용한 것이고, 두번째 잘 못은 Demeter 법칙을 위배한 것이다.


위 에제의 결함은

- mock객체를 사용하기 위해 RPCClient.getInstance()메소드를 가로챌 수 없다(static method는 non-interceptable & non-mockable).

- 테스트대상시스템(SUT)가 RPCClient를 필요로 하지 않는데 왜 RPCClient를 mock으로 치환해야 하는가?(AccountView는 rpc instance를 필드에 저장하지 않는다). user만 저장/접근할 수 잇으면 된다.

- AccountView를 생성하려는 모든 테스트는 위의 문제를 갖는다. 하나의 테스트에서 문제를 해결했다고 하더라도 다른 테스트에서는 문제가 해결된 것이 아니다.


개선된 코드에서는 직접적으로 필요한 객체만 전달한다. User collaborator 테스트시 생성해야할것은 (real or test double) User 객체뿐이다. 이로 인해 설계가 보다 유연해지고 테스트 가능성이 보다 높아진다.


<수정된 코드>



예제 4: 생성자에서 불필요한 객체 생성후 사용



Car가 자신의 엔진을 만들기 위해 EngineFactory를 필요로 하는 것은 의미에 맞지않는다. Car는 엔진을 어떻게 만들것인가를 상관하지 말고 이미 만들어진 엔진을 공급 받아야한다. 주행하는 것이 목적인 Car는 공장에 대한 레퍼런스를 갖지 말아야한다. 같은 맥락으로 constructor에서는 직접적으로 필요로 하지않은 3rd party객체가 아니라 그 객체가 생성하는 객체만 사용해야한다.


위 예제의 결함은

- 실제로 필요한 것은 Engine인데 File을 넘기고 있다.

- 3rd party객체(EngineFactory)를 생성하고 있다. 3rd party객체 생성은 inject/override 불가하므로 불필요한 작업이다. 

- car가 어덯게 EngineFactory를 만드는지 또 어떻게 엔진을 만드는지 알 필요 없다.

- 이 테스트의 문제를 해소한다고 해도 car를 생성해야 하는 모든테스트는 위와 같은 불합리한 작업을 수행해야한다.

- Car constructor가 호출되는 모든 테스트는 file을 접근해야한다. 이 작업은 매우 느리고, 테스트가 진정한 유닛 테스트가 될 수 없게 한다.


이러한 3rd party객체를 제거하고 constructor에서의 작업을 단순한 변수 할당문으로 치환하라. 사전에 설정된 변수들은 constructor의 필드로 할당하라. 다른 객체(factory, builder, DI container)가 constructor의 parameter를 생성하는 작업을 담당하도록 하라. 객체의 주요 책임과 객체 그래프 생성을 분리하여 보다 유연하고 유지보수 가능한 설계를 유지하라


<수정된 코드>



예제 5: 생성자에서 static Flag값 사용



인자를 갖지않는 constructor가 많은 의존성을 가지고 있다. API가 거짓을 말하고 있는 것이다. API는 인자가 없으므로 쉽게 만들수 있다고 말하고 있지만 PingServer는 불안정하고 전역상태에 의존하고있다


이 예제의 결함은

- 테스트 객체를 생성하기 위해 전역 변수 FLAG_PORT에 의존하고 있다. 테스트 순서에 의해 테스트가 영향을 받게된다. 

- static하게 접근 가능한 전역변수 플래그로 인해 병렬로 테스트 수행이 불가해 진다.

- 객체 생성이 잘못된 곳에서 수행되고 있어서 Socket에 추가적인 설정이 불가하다


PingServer는 port번호가 아니라 소켓을 필요로 한다. port번호를 전달함으로써 테스트시 실제 소켓/쓰레드를 사용해야만 한다. 포트 번호가 아니라 소켓을 전달하도록 수정하면 테스트시 mock 소켓을 사용 할 수 있다. 


명시적으로 port번호를 전달함으로써 전역상태에 대한 의존성을 제거하여 테스트를 단순화 할 수 있다. 궁극의 해결책은 진짜로 필요한 소켓을 전달하는것이다.


<수정된 코드>



예제 6: static Flag값 사용해서 분기 로직 사용 후 객체 생성



위 예제의 결함은 

- 플래그를 직접 읽는 것은 값을 얻기 위해 전역 상태를 사용하는 것이다. 전역 상태가 분리(isolate)되지 않아서 악영향이 생긴다. 이전 테스트나 동시에 수행되는 다른 쓰레드가 예상하지 않은 상태로 설정 할 수 있기 때문이다. 

- 플래그 값에 따라 다른 타입의 Jersey를 직접생성하고 있다. CurlingTeamMember를 생성하는 테스트는 다른 Jersey collaborator를 주입할 seam을 갖지 못한다.

- CurlingTeamMember의 책임이 광범위하다 


<수정된 코드>



예제 7: 생성자의 'work'를 초기화 메소드로 이동하여 사용



'work'를 initalize메소드로 이동시키는것은 해결책이 될 수 없다. 객체가 하나의 책임만 갖도록 decouple해야한다(이때 하나의 책임은 완전하게 설정된 객체 그래프를 제공하는 것이다).


이 예제의 결함은

- 코드의 안전성이 없고 몇개의 static initialization호출에 결부되어 있다.

- initialization 메소드는 객체가 너무 많은 책임을 갖는다는 것을 타나내는 현격한 증거이다

- 의존성 initialization은 다른 클래스에서 수행되어야 하고, 바로 사용할 수 있는 객체들이 constructor에 전달되어야한다.

- 테스트시 initialize메소드를 호출고자 한다면 Server.readConfigFromFile 메소드는 intercept  불가하다

- 테스트시 Server는 initialize불가하다. Server를 사용하고자 한다면 전역 singleton 상태에서 얻어와야 한다.

- 2개의 테스트가 동시에 수행되거나 이전테스트가 Server를 예상하지 않은 상태로 초기화 했다면 전역상태로 인해 테스트가 실패한다


<수정된 코드>


이번 글에서는 Constructor에서 발생할 수 있는 테스트하기 힘들게 만드는 코드에 대해 알아 보았다. 위 이야기들은 이렇게 요약이 가능할 것 이다.

1. 생성자 하나를 주 생성자로 만들자

2. 클래스는 다양한 생성자와 적은 수의 메소드를 유지하자 

2. 생성자에 코드를 넣지 말자 

3. 부 생성자 밖에서는 new를 되도록 사용하지 말자 


생성자가 많아질수록 클라이언트가 클래스를 더 유연하게 사용할수 있게되고, 메소드가 많아질수록 클래스는 사용하기 더 어려워진다. 메소드가 많아지면 클래스의 초점이 흐려지고 SRP를 위반하게된다. 이에 비해 생성자가 많아지면 유연성은 향상된다. 생성자의 주된 작업은 제공된 인자를 사용해서 캡슐화하고 있는 프로퍼트릴 초기화하는 일이다. 이런 초기화 로직은 단 하나의 생성자(주)에만 위치시키고 나머지는 부 생성자라고 부르며 유지하면된다. 하나의 주생성자와 다수의 부생성자는 중복코드를 방지하고 설계를 더 간결하게 만들기때문에 유지보수성이 향상된다. 


위 예제들의 공통점은 결국 '하드코딩된 의존성'이 문제인것이다. 이러한 의존성을 끊기위해 외부에서 객체를 주입받는다. 만약 부생성자에서 해당 객체를 만들어서 주 생성자로 넘겨주는건 어떨까? 이것이 의존성을 주입하는것과 같은 효과이다. 객체가 필요한 의존성을 직접 생성하는 대신, 우리의 주 생성자를 통해 의존성이 주입되기 때문이다. 



이렇게 해주면 주 생성자를 사용할 경우 객체와 협력하는 모든 의존성을 우리 스스로 완전히 제어 가능하므로 테스트 코드 작성시에도 어려움을 덜 수 있다. 또한. 객체 초기화 에는 코드가 없어야 좋다. 필요하다면 인자들을 다른 타입의 객체로 감싸거나 가공하지 않는 형식으로 캡슐화 해야한다. 그리고 위에서 설명한대로 부 생성자를 통해 주 생성자로 객체를 넘겨주면된다. 객체를 인스턴스화 하는 동안에는 객체를 build하는일 이외에는 어떤 일도 수행하지 않는것이 좋은데 이렇게 되면 객체 생성과 사용의 과정을 제어할 수 있고 동작의 최적화가 가능하다. 더불어 재사용성도 높아진다.


이번엔 생성자에 대한 이야기 였고 다음 글에서는 Collaborators에 의한 결함에 대해 알아보자 











브런치는 최신 브라우저에 최적화 되어있습니다. IE chrome safari