(Working Effectively with Legacy Code) 009. I Can’t Get This Class into a Test Harness

I Can’t Get This Class into a Test Harness

이전 포스팅들을 통해 클래스를 테스트 하네스에 넣는 작업이 쉽지않다는 것을 깨달았을 것이다.

아래는 가장 일반적으로 직면하는 네 가지 문제점이다.

  1. 클래스의 객체를 쉽게 생성할 수 없다.
  2. 클래스를 포함하는 테스트 하네스를 쉽게 빌드할 수 없다.
  3. 반드시 사용해야하는 생성자가 부작용을 야기한다.
  4. 생성자 호출시 많은 처리나 연산이 발생하며, 해당 내용을 알아내야한다.

본 포스팅에서는 테스트 하네스에 클래스를 넣을 때 겪을 수 있는 문제를 크게 7가지의 케이스로 분류하여 알아보도록 한다.

1. The Case of the Irritating Parameter (성가신 매개변수)

테스트를 위해 객체 생성을 시도할 때, 특정 매개변수가 필요한 경우가 있다.

객체 생성에 필요한 매개변수를 일일이 확인하는 것은 매우 성가신 일이지만 해결해야하는 문제이기도 하다.

예제를 통해 알아보자.

아래 CreditValidator 클래스는 가상의 신용카드 청구 시스템으로, 테스트 코드가 작성되지않은 상태라고 가정한다.

1
2
3
4
5
6
7
8
9
10
11
12
public class CreditValidator {

public CreditValidator(RGHConnection connection, CreditMaster master, String validatorID) {
// ...
}

Certificate validateCustomer(Customer customer) throws InvalidCredit {
// ...
}

// ...
}

CreditValidator 클래스는 고객의 신용 잔고가 유효한지 검증하고, 유효하지않다면 예외를 발생시키는 동작을 가지고 있다.

현재 상태를 유지한 채, 새로운 메서드는 getValidationPercent()를 추가해보자.

이 메서드의 요구사항은 CreditValidator 클래스의 객체가 존재하는 동안 validateCustomer() 메서드 호출이 성공했던 비율을 반환하는 것으로 한다.

일단 무작정 객체 생성에 도전해보자.

1
2
3
public void testCreate() {
CreditValidator validator = new CreditValidator();
}

위의 코드에서는 객체 생성에 필요한 RGHConnection 객체, CreditMaster 객체, validatorID 문자열이 없으므로 당연히 컴파일 오류가 발생한다.

인자로 넘겨야하는 객체들의 코드는 아래와 같다.

1
2
3
4
5
public class RGHConnection {
public RGHConnection(int port, String Name, String passwd) throws IOException {
// ...
}
}

RGHConnection 클래스의 객체는 생성된 후, 서버에 연결을 시도하여, 고객의 신용을 검증하는 데 필요한 정보를 내려받는다.

1
2
3
4
5
public class CreditMaster {
public CreditMaster(String filename, boolean isLocal) {
// ...
}
}

CreditMaster 클래스의 객체는 생성된 후, 파일로부터 고객의 신용 잔고 결정에 사용되는 정보를 읽어온다.

RGHConnection 클래스와 CreditMaster 클래스의 생성 정보를 토대로 테스트 코드를 보강해보자.

1
2
3
4
5
6
public void testCreate() throws Exception {
RGHConnection connection = new RGHConnection(DEFAULT_PORT, "admin", "rii8ii9s");
CreditMaster master = new CreditMaster("crm2.mas", true);

CreditValidator validator = new CreditValidator(connection, master, "a");
}

이렇게 하면 validator 객체를 생성할 수는 있지만, connection 객체가 서버와의 연결을 시도하는 문제점이 있다.

서버와의 연결은 무조건 성공한다고 보장되지도 않으며, 성공은 하지만 오랜 시간이 걸릴 수도 있기 때문이다.

반면 master 객체의 경우, 기존에 존재하는 파일을 불러오는 것이므로 파일만 정상적으로 존재한다면, 문제될 게 없다.

파일이 없어서 생기는 문제의 경우, 테스트 환경이 아니라도 실제 환경에서 발생할 것이기 때문이다.

따라서 validator 객체를 생성시 문제가 되는 것은 RGHConnection 클래스이다.

이를 우회하기 위해서 RGHConnection 클래스의 위장 객체를 만들어서 서버와의 연결 고리를 끊어버리면 된다.

이제 위장 객체를 만들기 위해 RGHConnection 클래스의 UML을 살펴보자.

Figure 9.1

RGHConnection 클래스는 서버와의 연결을 위해 connect(), disconnect(), retry() 메서드를 가지고 있으며, 비즈니스 로직을 처리하기 위해 RFDIReporterfor(), ACTIOReportFor() 메서드도 가지고 있다.

최초 정의한 요구사항인 CreditValidator에서의 메서드 추가를 위해선 RFDIReporterfor()를 호출해서 필요한 정보를 얻어야 한다고 가정해보자.

실제 프로덕트에서는 서버로부터 받아와야겠지만, 테스트 하네스 안에스는 서버와의 연결 고리를 끊을 것이기때문에 다른 방법으로 정보를 획득해야 한다.

Figure 9.1

먼저 인터페이스 추출 기법을 이용해 위의 UML대로 IRGHConnection 인터페이스를 분리한 뒤 위장 객체 FakeConnection 클래스를 작성해보자.

1
2
3
4
5
6
7
8
9
10
11
public class FakeConnection implements IRGHConnection {
public RFDIReport report;
public void connect() {}
public void disconnect() {}
public RFDIReport RFDIReportFor(int id) {
return report;
}
public ACTIOReport ACTIOReportFor(int customerID) {
return null;
}
}

이제 위장 객체 FakeConnection 클래스를 이용해 테스트 코드를 다시 작성해보면 아래와 같다.

1
2
3
4
5
6
7
8
9
10
void testNoSuccess() throws Exception {
CreditMaster master = new CreditMaster("crm2.mas", true);
IRGHConnection connection = new FakeConnection();
CreditValidator validator = new CreditValidator(connection, master, "a");

connection.report = new RFDIReport(...);

Certificate result = validator.validateCustomer(new Customer(...));
assertEquals(Certificate.VALID, result.getStatus());
}

이제 CreditValidator 클래스의 객체를 생성할 수 있게 사전 작업이 완료되었다.

최초의 요구사항인 getValidationPercent()이 있다는 전제하에 테스트 코드를 작성해보자.

1
2
3
4
5
6
7
8
9
void testAllPassed100Percent() throws Exception {
CreditMaster master = new CreditMaster("crm2.mas", true);
IRGHConnection connection = new FakeConnection("admin", "rii8ii9s");
CreditValidator validator = new CreditValidator(connection, master, "a");

connection.report = new RFDIReport(...);
Certificate result = validator.validateCustomer(new Customer(...));
assertEquals(100.0, validator.getValidationPercent(), THRESHOLD);
}

위의 테스트 코드는 한 개의 유효한 고객 정보를 받아 검증 가능 비율이 100% 임을 확인할 수 있는 코드이다.

여기까지 왔다면 한 가지 의문이 생긴다.

1
CreditMaster master = new CreditMaster("crm2.mas", true); 

위의 코드는 정말 필요한 것일까?

getValidationPercent() 메서드는 master 객체를 전혀 사용하지않는다.

따라서 아래와 같이 CreditValidator 객체 생성시 아래와 같이 처리해도 된다.

1
2
3
4
// as-is
CreditValidator validator = new CreditValidator(connection, master, "a");
// to-be
CreditValidator validator = new CreditValidator(connection, null, "a");

물론 위와 같이 null을 전달하는 것은 테스트 루틴에 한정해서 작성해야한다.

실수로 null을 전달한다 하더라도, 런타임 에러를 통해 어느 부분에서 오류가 발생했는지 바로 특정할 수 있다.

즉, 테스트 루틴내에서 인자가 실제로 사용되는 지 여부를 빠르게 검증해낼 수 있다.

좀 더 빠르게 이를 파악하고 싶다면, 테스트 하네스 안에서 무작정 객체 생성을 시도할 때 아래와 같이 작성하면 될 것이다.

1
2
3
public void testCreate() {
CreditValidator validator = new CreditValidator(null, null, "a");
}

이처럼 인터페이스 추출 기법과 null값 전달은 성가신 인자 관련 문제를 해결할 수 있는 기법들이다.

참고 물론 서브클래스를 작성해 connect() 메서드를 재정의하는 식으로 해결할 수도 있지만, 서브클래스로 인해 테스트 대상 코드의 동작이 그대로 보장되는 지(=변경되지않는지) 확인해보아야한다.

2. The Case of the Hidden Dependency (숨겨진 의존 관계)

특별할 것 없는 클래스가 하나 있다고 하자.

근데 이 클래스의 생성자를 호출하는 경우 바로 문제를 직면하게 되는 경우가 있다.

원인 중 가장 흔한 것이 의존 관계가 숨겨져 있는 경우로, 테스트 하네스 내에서 쉽게 접근할 수 없는 자원을 사용하는 경우 쉽게 발생한다.

메일링 리스트를 관리하는 예제를 통해 파악해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class mailing_list_dispatcher {
public:
mailing_list_dispatcher();
virtual ~mailing_list_dispatcher;

void send_message(const std::string& message);
void add_recipient(const mail_txm_id id, const mail_address& address);

// ...

private:
mail_service *service;
int status;
};

위의 mailing_list_dispatcher 클래스의 생성자의 일부분은 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
mailing_list_dispatcher::mailing_list_dispatcher()
: service(new mail_service), status(MAIL_OKAY) {
const int client_type = 12;
service -> connect();

if (service -> get_status() == MS_AVAILABLE) {
service -> register(this, client_type, MARK_MESSAGES_OFF);
service -> set_param(client_type, ML_NOBOUNCE | ML_REPEATOFF);
} else {
status = MAIL_OFFLINE;
}

// ...
}

생성자의 초기화 목록에서 new 키워드를 사용해 mail_service 객체를 할당하고 있는데, 이로인해 문제가 더욱 악화된다.

또한 몇 가지 작업 수행 외에는 의미를 알 수 없는 client_type 변수 또한 존재하고 있다.

물론 테스트 루틴안에서 mailing_list_dispatcher 클래스의 객체를 생성할 수는 있다.

하지만 매번 메일 라이브러리에 연결해야하고 메일 시스템을 설정해야 하며, 테스트 도중 send_message() 메서드를 호출하면 실제로 메일이 전송되어 버린다.

시스템의 전반적인 테스트를 하는 것이라면 상관없겠지만, 지금은 기존에 존재하는 코드에 테스트가 완료된 신규 기능을 추가하는 것이 목적이다.

상술한 것처럼 여기서 근본적인 문제는 mail_service에 대한 의존 관계가 mailing_list_dispatcher 클래스의 생성자 내부에 숨어있다는 점이므로, mail_service의 위장 객체를 생성할 수 있다면 문제점은 우회할 수 있을 것이다.

이때 사용할 수 있는 기법이 생성자 매개변수화 기법(Parameterize Constructor) 이다.

생성자 매개변수화 기법을 사용하면 mail_service 객체를 생성자에 전달함으로써, 생성자 내부의 의존 관계를 밖으로 드러나게 만들 수 있다.

해당 기법을 적용한 코드는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
mailing_list_dispatcher::mailing_list_dispatcher(mail_service *service)
: status(MAIL_OKAY) {
const int client_type = 12;
service -> connect();
if (service -> get_status() == MS_AVAILABLE) {
service -> register(this, client_type, MARK_MESSAGES_OFF);
service -> set_param(client_type, ML_NOBOUNCE | ML_REPEATOFF);
} else {
status = MAIL_OFFLINE;
}

//...
}

기법 이라는 단어까지 썼지만, 생성자의 인자가 추가된 것 외엔 그다지 큰 차이점이 없다.

하지만 외부로 의존 관계를 드러나게함으로써, 인터페이스 추출 기법을 사용해 main_service의 인터페이스를 정의한 위장 객체를 만들 수 있게 된 것이다.

생성자 매개변수화 기법은 생성자의 의존 관계를 외부화하는 매우 쉬운 방법이지만, 많은 사람들이 이 기법을 잊어버리곤 한다.

그 이유는 새롭게 추가된 인자를 전달하기 위해 클래스를 호출하는 모든 코드를 변경해야한다고 생각하기 때문이다.

물론 맞는 말이다. 하지만 초기화를 위한 메서드를 추가하고, 생성자의 구현부를 옮기는 것으로 이 문제를 우회할 수 있다.

이번엔 mailing_list_dispatcher 클래스에 initialize() 메서드를 추가해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void mailing_list_dispatcher::initialize(mail_service *service) {
status = MAIL_OKAY;
const int client_type = 12;
service -> connect();
if (service -> get_status() == MS_AVAILABLE) {
service -> register(this, client_type, MARK_MESSAGES_OFF);
service -> set_param(client_type, ML_NOBOUNCE | ML_REPEATOFF);
} else {
status = MAIL_OFFLINE;
}

//...
}

mailing_list_dispatcher::mailing_list_dispatcher(mail_service *service) {
initialize(service);
}

이러한 방식은 메서드 추출 기법과 달리, 생성자의 시그니처를 유지할 수 있으므로 테스트 없이도 안전하게 사용할 수 있다.

1
2
3
mailing_list_dispatcher::mailing_list_dispatcher() {
initialize(new mail_service);
}

위와 같이 한 번 더 변경하면 기존 코드와 시그니처까지 똑같기 때문에 호출하는 쪽에서 변경사항을 전혀 알 필요가 없다.

3. The Case of the Construction Blob (복잡한 생성자)

생성자 매개변수화 기법은 매우 쉽고 좋은 방법이긴 하지만, 모든 경우에 최적인 기법은 아니다.

생성자 내부에서 많은 수의 객체가 생성되거나, 많은 수의 전역 변수에 접근하는 경우 매개변수의 개수가 덩달아 늘어날 것 이기 때문이다.

심지어 생성자 내부에서 몇 개의 객체를 생성한 뒤, 이 객체들을 다른 객체를 생성하는 데 사용되는 경우도 있다.

아래 코드가 그 예시이다.

1
2
3
4
5
6
7
8
9
10
11
12
class WatercolorPane {
public:
WatercolorPane(Form *border, WashBrush *brush, Pattern *backdrop) {
// ...
anteriorPanel = new Panel(border);
anteriorPanel -> setBorderColor(brush -> getForeColor());
backgroundPanel = new Panel(border, backdrop);
cursor = new FocusWidget(brush, backgroundPanel);
// ...
}
// ...
}

위의 코드에서 cursor 변수를 통해 감지 작업을 수행하려고 하는 경우 문제가 발생한다.

cursor 변수에 초기화된 FocusWidget 클래스의 경우 생성을 위한 코드까지 전부 생성자에 있기 때문이다.

FocusWidget 클래스의 생성에 필요한 코드를 외부로 분리할 수 있다면, FocusWidget 객체를 먼저 생성한 후 생성자에 파라미터로 넘겨줄 수 있을 것이다.

다만 테스트 루틴이 준비되지않았다면 위와 같은 변경의 안정성을 보장할 수 없다.

또 다른 선택지로 인스턴스 변수 대체 기법(Supersede Instance Variable) 이 있다.

인스턴스 변수 대체 기법이란 객체를 먼저 생성한 후, 다른 인스턴스로 대체하기 위한 Setter를 클래스에 추가하는 기법이다.

참고 안전하게 메서드를 추출할 수 있는 리팩토링 도구가 있다면 팩토리 메서드 추출 및 재정의 기법(Extract and Override Factory Method) 을 또 다른 선택지로 고려할 수 있지만, 이는 모든 언어에서 동작하진 않는다.

아래 코드를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class WatercolorPane {
public:
WatercolorPane(Form *border, WashBrush *brush, Pattern *backdrop) {
// ...
anteriorPanel = new Panel(border);
anteriorPanel -> setBorderColor(brush -> getForeColor());
backgroundPanel = new Panel(border, backdrop);
cursor = new FocusWidget(brush, backgroundPanel);
// ...
}
// ...
void supersedeCursor(FocusWidget *newCursor) {
delete cursor;
cursor = newCursor;
}
}

이제 대체 메서드 supersedeCursor() 메서드를 추가하였으니, WatercolorPane 클래스 외부에서 FocusWidget 클래스의 객체를 전달할 수 있다.

여기서 인터페이스 추출 기법이나 구현체 추출 기법을 FocusWidget 클래스에 적용하여 위장 객체를 전달하면 감지 작업을 수행할 수 있다.

1
2
3
4
5
6
7
8
9
TEST(renderBorder, WatercolorPane) {
// ...
TestingFocusWidget *widget = new TestingFocusWidget;
WatercolorPane pane(form, border, backdrop);

pane.supersedeCursor(widget);

LONGS_EQUAL(0, pane.getComponentCount());
}

지금까지 예제를 통해 인스턴스 변수 대체 기법에 대해 알아보았다.

이 기법은 대체로 자원 관리에 문제를 일으킬 가능성이 존재하므로 불가피한 경우가 아니라면 차선책으로 두어야한다.

보통 팩토리 메서드 추출과 재정의 기법을 사용하지만, C++ 등의 언어에서 사용할 수 없는 경우 적용하는 것이 좋다.

4. The Case of the Irritating Global Dependency (까다로운 전역 의존 관계)

소프트웨어 업계엔 다수의 상용 또는 오픈소스 프레임워크가 존재한다.

프레임워크는 프로젝트 초반의 효율성을 높여주고 여러가지 문제를 해결해주지만, 프레임워크를 사용한다고 하더라도 애플리케이션을 개발하면서 임의의 클래스를 테스트 하네스에 집어넣고 컴파일하는 것이 쉽지않은 일이다.

특히 전역 변수는 테스트 프레임워크에서 클래스의 생성 및 사용을 어렵게 만드는 가장 까다로운 녀석이다.

간단한 경우엔 생성자 매개변수화 기법(Parameterize Constructor), 메서드 매개변수화 기법(Parameterize Method), 호출 추출과 재정의 기법(Extract and Override Call) 등을 사용하면 되지만 전역 변수와 관련된 의존 관계를 너무 광범위하다는 것이 문제다.

이번 케이스도 마찬가지로 예제를 통해 파악해보자.

아래 코드는 정부 기관이 사용하는 건축 허가 관리를 위한 클래스이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Facility {
private Permit basePermit;

public Facility(int facilityCode, String owner, PermitNotice notice) throws PermitViolation {
Permit associatedPermit = PermitRepository.getInstance().findAssociatedPermit(notice);
if (associatedPermit.isValid() && !notice.isValid()) {
basePermit = associatedPermit;
} else if (!notice.isValid()) {
Permit permit = new Permit(notice);
permit.validate();
basePermit = permit;
} else {
throw new PermitViolation(permit);
}
}
// ...
}

Facility 클래스의 객체 생성을 무작정 시도해본다.

1
2
3
4
public void testCreate() {
PermitNotice notice = new PermitNotice(0, "a");
Facility facility = new Facility(Facility.RESIDENCE, "b", notice);
}

testCreate() 테스트 코느는 컴파일 오류 없이 작성되었다.

하지만 내부에서 PermitRepository 클래스를 사용하여 associatedPermit 변수에 Permit 객체를 초기화하고 있기에, 문제가 발생했을 경우 탐지가 어려운 상황에 놓이게 된다.

이를 해결하기위해 생성자 매개변수화 기법을 사용할 수도 있지만 PermitRepository 클래스는 싱글턴 패턴으로 작성되어 Global Scope를 가지고 있기 때문에 사용처가 여러 곳일 수 있다.

프로덕트 입장에서 싱글턴의 사용은 중복 객체를 방지하기 위한 좋은 방법이지만, 테스트 코드 입장에서는 테스트 대상 코드를 하나의 애플리케이션으로 간주하기때문에 외부와의 통로가 남아있는 싱글턴은 일종의 제약 사항으로 남는다.

이를 해결하기 위해 싱글턴에 새로운 정적 메서드를 추가해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class PermitRepository {
private static PermitRepository instance = null;
private PermitRepository() {}
public static void setTestingInstance(PermitRepository newInstance) {
instance = newInstance;
}
public static PermitRepository getInstance() {
if (instance == null) {
instance = new PermitRepository();
}
return instance;
}
public Permit findAssociatedPermit(PermitNotice notice) {
// ...
}
// ...
}

setTestingInstance() 메서드를 추가하여 새로운 객체를 생성하여 싱글턴에 주입하도록 하였다.

이제 테스트 코드에서 PermitRepository 싱글턴 클래스에 접근하는 경우 setTestingInstance() 메서드를 호출하여 인스턴스를 획득하면 된다.

1
2
3
4
5
6
7
public void setUp() {
PermitRepository repository = new PermitRepository(); // ERROR
// ...
// add permits to the repository here
// ...
PermitRepository.setTestingInstance(repository);
}

헌데 이러한 방식은 PermitRepository 클래스의 생성자가 private로 지정되는 싱글턴 패턴의 법칙에 위배된다.

싱글턴 패턴을 사용하는 즉, 시스템 내에 인스턴스가 하나만 존재해야하는 경우는 아래와 같다.

  1. 현실 세계를 모델링한 결과, 현실 세계에 한 개만 존재한다.
  2. 두 개가 존재한다면 심각한 문제가 발생한다.
  3. 두 개를 생성하면 자원 소모가 극심하다.

하지만 싱글턴 패턴은 위와 같은 거창한 이유보단, Global Scope에서 접근 가능한 전역 변수로서의 활용을 위해 사용하는 경우가 많다.

만약 싱글턴의 특성을 완화해도 되는 경우라면 생성자를 private에서 protected, public, package 으로 변경하는 식으로 테스트 루틴에 활용하는 것도 하나의 방법이다.

예를 들어 특정 허가를 모두 추가할 수 있는 addPermit() 이라는 메서드가 있다면, 별도의 Repository를 생성하여 수행해도 테스트를 수행할 수도 있으며,

테스트 하네스에서 처리되지않길 바라는 동작이 포함되는 경우, 서브클래스화 기법이나 메서드 재정의 기법을 사용해 서브클래스를 작성하여 테스트 루틴을 단순화할 수 있다.

아래는 PermitRepository 클래스에 findAssociatedPermit() 메서드를 추가한 예제이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class PermitRepository {
// ...
public Permit findAssociatedPermit(PermitNotice notice) {
// 1. open permit database
// ...

// 2. select using values in notice
// ...

// 3. verify we have only one matching permit, if not report error
// ...

// 4. return the matching permit
// ...
}
}
  1. 먼저 permit DB를 연다.
  2. 사용할 값을 선택한다.
  3. 단 하나의 알맞은 permit을 갖는지 확인한다. 알맞지않다면 에러를 발생시킨다.
  4. 일치하는 permit을 반환한다.

위의 절차에서 DB와의 외부 의존관계를 끊어내기 위해 아래와 같이 TestingPermitRepository 서브 클래스를 작성한다.

1
2
3
4
5
6
7
8
9
10
public class TestingPermitRepository extends PermitRepository {
private Map permits = new HashMap();
public void addAssociatedPermit(PermitNotice notice, permit) {
permits.put(notice, permit);
}

public Permit findAssociatedPermit(PermitNotice notice) {
return (Permit)permits.get(notice);
}
}

서브클래스화 기법을 이용하면 PermitRepository의 생성자를 protected로 처리하여 싱글턴의 일부 특성을 유지한 채로 테스트 루틴을 작성할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class PermitRepository {
private static PermitRepository instance = null;
protected PermitRepository() {} // private -> protected
public static void setTestingInstance(PermitRepository newInstance) {
instance = newInstance;
}
public static PermitRepository getInstance() {
if (instance == null) {
instance = new PermitRepository();
}
return instance;
}

public Permit findAssociatedPermit(PermitNotice notice) {
// ...
}
// ...
}

이처럼 서브클래스화 기법과 메서드 재정의 기법을 사용해 싱글턴 위장 객체를 생성할 수 있는 경우가 많다.

하지만 의존 관계가 매우 광범위한 경우 싱글턴에 인터페이스 추출 기법을 적용해 애플리케이션 내의 모든 참조를 인터페이스를 사용하도록 변경하는 것이 나을 수 있다.

후자의 경우 많은 변경으로 그에 비례한 리소스가 필요하지만 컴파일러의 도움을 얻을 수 있을 것이다.

인터페이스를 추출한 후 PermitRepository 클래스는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class PermitRepository implements IPermitRepository {
private static IPermitRepository instance = null;
protected PermitRepository() {}
public static void setTestingInstance(IPermitRepository newInstance) {
instance = newInstance;
}
public static IPermitRepository getInstance() {
if (instance == null) {
instance = new PermitRepository();
}
return instance;
}
public Permit findAssociatedPermit(PermitNotice notice) {
// ...
}
// ...
}

IPermitRepository 인터페이스는 PermitRepository 클래스의 public이면서 static이 아닌 모든 메서드의 시그니처를 가지고 있어야 한다.

1
2
3
4
public interface IPermitRepository {
Permit findAssociatedPermit(PermitNotice notice);
// ...
}

지금까지 설명한 리팩토링 방법을 정적 set 메소드 도입 기법(Introduce Static Setter) 이라고 한다.

이 기법은 광범위하게 전역 의존 관계가 존재하는 경우에도 테스트 루틴을 적절한 위치에 배치할 수 있다.

다만, 전역 의존 관계를 제거하는 역할은 하지 못하므로 완전히 끊어내려면 메서드 매개변수화 기법이나 생성자 매개변수화 기법을 사용해야한다.

참고 메서드 매개변수화 기법은 메서드가 추가되기때문에 클래스를 이애하는 데 방해가 될 수 있고, 생성자 매개변수화 기법은 현재 전역 변수를 사용중인 모든 객체에 필드가 새로 추가된다는 단점이 있다.

5. The Case of the Horrible Include Dependencies (공포스러운 포함 의존 관계)

테스트 하네스에서 C++ 클래스를 생성하는 것은 헤더의 의존 관계가 있어 매우 어려운 일이다.

테스트 하네스 내에서 클래스를 생성하는 데 필요한 헤더 파일은 어떤 것들이 있을까?

이번에도 예제를 통해 알아보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#ifndef SCHEDULER_H 
#define SCHEDULER_H

#include "Meeting.h"
#include "MailDaemon.h"

// ...

#include "SchedulerDisplay.h"
#include "DayTime.h"

class Scheduler {
public:
Scheduler(const string& owner);
~Scheduler();
void addEvent(Event *event);
bool hasEvents(Date date);
bool performConsistencyCheck(string& message);
// ... More than 200.
};
#endif

위의 Scheduler 클래스는 200개 이상의 메서드를 포함하고 있다고 가정한다.

Scheduler 클래스는 Meeting, MailDaemon, Events, SchedulerDisplays, Dates 등의 클래스를 사용한다.

SchedulerTests 테스트 루틴을 작성하여 빌드해보자.

1
2
3
4
5
6
#include "TestHarness.h" 
#include "Scheduler.h"

TEST(create,Scheduler) {
Scheduler scheduler("fred");
}

위와 같이 작성하면 include 관련 문제가 발생한다.

Scheduler 클래스를 컴파일하려면 Scheduler 클래스가 의존하고 있는 모든 것들을 컴파일러가 알아야하기 때문이다.

간단하게 모든 정보를 Scheduler.h 파일에 포함시키는 것도 가능하지만 하나의 헤더 파일에 모든 정보를 담는 것도 쉽지않은 일이다.

따라서 테스트 루틴에 객체를 생성하고 사용하려면 지속적으로 include 키워드를 사용해야 한다.

Scheduler 클래스의 모든 전처리 include를 복사하는 방법도 있지만, 모든 헤더가 필요하지 않을 수도 있으므로 어느 정도의 최적화를 진행해야한다.

단순한 작업이지만, 분량이 많을 수록 혼란을 야기하기도 하고, 의존 관계가 적더라도 테스트 하네스 내에서 접근하기 어려운 경우에도 문제가 될 수 있다.

SchedulerDisplay.hSchedulerDisplay 클래스가 이 예시이다.

1
2
3
4
5
6
7
8
9
10
#include "TestHarness.h" 
#include "Scheduler.h"

void SchedulerDisplay::displayEntry(const string& entyDescription) {

}

TEST(create,Scheduler) {
Scheduler scheduler("fred");
}

SchedulerDisplay 클래스에 displayEntry() 메서드를 추가하였다.

이렇게 만들어진 위장 객체를 별도의 헤더 파일에 작성한 후 테스트 코드 내에 아래와 같이 참조한다.

1
2
3
4
5
6
7
#include "TestHarness.h" 
#include "Scheduler.h"
#include "Fakes.h" // HERE

TEST(create,Scheduler) {
Scheduler scheduler("fred");
}

이러한 작업을 반복하면 상대적으로 간단하게 의존 관계를 주입할 수 있다.

다만 몇 가지 심각한 단점이 있다.

먼저 프로그램을 분리시켜야 하며, 실제로 프로덕션 코드에서는 의존 관계가 제거된 것이 아니다.

이렇게 테스트 파일에 포함된 SchedulerDisplay 클래스에 displayEntry() 메서드와 같은 중복 정의는 테스트를 수행하는 한 계속해서 유지가 되어야 한다.

6. The Case of the Onion Parameter (양파껍질 매개변수)

별다른 파라미터를 넘기지 않고도 객체를 생성할 수 있다면 좋겠지만, 생성 이후의 처리를 가능하게 하기 위해 기존에 생성된 별도의 객체를 넘기는 경우가 많다.

즉, A 객체를 생성하기 위해 B 객체가 필요하며, B 객체의 생성을 위해 C 객체가 필요한 상황을 말한다.

이처럼 객체 내의 또 다른 객체는 양파껍질 벗기기와 비슷하다.

아래 예제를 보자.

1
2
3
4
5
public class SchedulingTaskPane extends SchedulerPane {
public SchedulingTaskPane(SchedulingTask task) {
// ...
}
}

위의 코드를 보면 SchedulingTaskPane 객체를 생성하기 위해 SchedulingTask 객체가 필요함을 알 수 있다.

이번엔 SchedulingTask 클래스를 살펴보자.

1
2
3
4
5
public class SchedulingTask extends SerialTask {
public SchedulingTask(Scheduler scheduler, MeetingResolver resolver) {
// ...
}
}

SchedulingTask 객체를 생성하기 위해서도 Scheduler 객체와 MeetingResolver 객체가 필요하다.

이러한 구조의 클래스 관계에서는 객체 생성을 위해 원시 타입만을 필요로하는 클래스를 찾아서 계속해서 양파껍질을 벗길 수 밖에 없다.

이 상황을 대처하려면 꼭 필요한 인자가 아닌 경우 Null 전달 기법(Pass Null) 을 사용할 수 있다.

테스트에 몇 개의 동작만이 피룡하다면, 직접적인 의존 관계에 대해 인터페이스 추출 기법(Extract Interface) 이나 구현체 추출 기법(Extract Implementer) 을 사용해 인터페이스를 통한 위장 객체로 대치가 가능하다.

위의 예제에서 SchedulingTaskPane 클래스와 가장 직접적인 의존 관계를 가진 클래스는 SchedulingTask 클래스이다.

SchedulingTask 클래스의 위장 객체를 만들 수 있다면 해결할 수 있을 것이다.

좀 더 자세히 보면 SchedulingTask 클래스가 SerialTask 클래스를 상속받고 있는데, SerialTask 클래스의 메서드까지 포함하는 인터페이스를 작성해 위장객체로 만들 수 있다.

UML로 보자면 아래와 같다.

Figure 9.3

7. The Case of the Aliased Parameter (별명을 갖는 매개변수)

생성자의 인자가 문제가 되는 경우는 대부분 인터페이스 추출 기법(Extract Interface) 이나 구현체 추출 기법(Extract Implementer) 을 사용해 해결할 수 있다.

“대부분”이라고 썼듯이 모든 경우에 최적은 아닌데, 어떤 케이스에 해당하는지 예제를 통해 살펴보자.

아래 예제는 위에서 다뤘던 허가 시스템의 다른 클래스이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class IndustrialFacility extends Facility {
Permit basePermit;
public IndustrialFacility(int facilityCode, String owner, OriginationPermit permit) throws PermitViolation {
Permit associatedPermit = PermitRepository.getInstance().findAssociatedFromOrigination(permit);
if (associatedPermit.isValid() && !permit.isValid()) {
basePermit = associatedPermit;
} else if (!permit.isValid()) {
permit.validate();
basePermit = permit;
} else {
throw new PermitViolation(permit);
}
}
// ...
}

위의 코드를 기준으로 테스트 하네스에서 IndustrialFacility 클래스를 생성하는 데 문제점을 나열하면 아래와 같다.

  1. 싱글턴인 PermitRepository 클래스에 접근한다.
  2. 생성자의 OriginationPermit 객체를 생성하기가 어렵다.

1번 문제는 위의 4번 케이스의 해결 방법대로 처리하면 되지만, 2번 문제는 복잡한 의존 관계가 설정되어있을 경우, 객체 생성에 애로사항이 생긴다.

OriginationPermit 클래스에 인터페이스 추출 기법을 적용하면 될까?

아래 계층 구조를 보면 인터페이스 추출 기법으로 해소되지않음을 유추할 수 있다.

Figure 9.4

가장 확실한 해결책은 아래와 같이 인터페이스들로만 이뤄진 계층 구조를 생성한 후 Permit 필드를 IPermit 으로 변경하는 것이다.

Figure 9.5

이렇게 하면 해결은 할 수 있겠지만, 클래스와 인터페이스 간의 일대일 관계는 설계상 혼란을 야기할 가능성이 크다.

다시 OriginationPermit 클래스를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class OriginationPermit extends FacilityPermit {
// ...
public void validate() {
// form connection to database
// ...

// query for validation information
// ...

// set the validation flag
// ...

// close database
// ...
}
}

validate() 메서드는 아래와 같은 절차를 수행한다.

  1. 데이터베이스 연결
  2. 정보 검증 질의
  3. 확인 플래그 설정
  4. 데이터베이스 연결 해제

이 메서드는 데이터베이스와의 연결을 필요로 하므로, 테스트 중에 실행하지않도록 처리하는 것이 좋다.

이 상황은 서브클래스 기법메서드 재정의 기법 을 고려할만하다.

서브클래스 FakeOriginationPermit를 선언하고 검증 플래그를 변경할 수 있는 메서드를 추가할 수 있도록 작성한 뒤, 재정의한 validate()IndustrialFacility 클래스의 테스트에 사용하는 방식이다.

코드로 확인해보자.

1
2
3
4
5
6
7
8
9
10
11
public void testHasPermits() {
class AlwaysValidPermit extends FakeOriginationPermit {
public void validate() {
// set the validation flag
becomeValid();
}
};

Facility facility = new IndustrialFacility(Facility.HT_1, "b", new AlwaysValidPermit());
assertTrue(facility.hasPermits());
}

이처럼 즉석에서 클래스를 작성하여 해결할 수는 있지만, 의존 관계로 인해 선택한 궁여지책이므로 그다지 바람직한 경우는 아닐 것이다.

물론 프로덕션 환경이 아닌 테스트 환경에서 특별한 테스트 케이스를 만들 수 있는 장점이 있긴하다.

이처럼 의존 관계가 복잡한 경우 어떤 메서드 추출을 먼저할지 고민하도록 하자.