(Working Effectively with Legacy Code) 010. I Can’t Run This Method in a Test Harness

I Can’t Run This Method in a Test Harness

코드를 변경하기 위해 적절한 위치에 테스트 루틴을 작성하는 것이 어려운 경우가 있다.

대부분의 테스트 작성의 경우, 클래스 작성은 전초전에 불과하고 그 다음으로 변경 대상인 메서드의 테스트 루틴 작성이 기다리고 있다.

심지어 클래스의 객체를 생성하지않고도 메서의 테스트 루틴을 작성할 수 있는 경우도 있다.

메서드가 다소 적은 객체를 사용한다면 정적 메서드 드러내기 기법(Expose Static Method) 을 사용해 코드에 접근할 수도 있고,

메서드가 너무 길어서 제어가 힘든 상황이라면 객체의 생성이 좀 더 용이한 곳으로 이동시키는 메서드 객체 추출 기법(Break Out Method Object) 을 사용할 수도 있다.

상술한 부분만 보면 메서드에 대한 테스트 루틴 작성이 매우 힘겨운 작업처럼 보이지만, 대체로 작업량은 그리 많지 않은 편이다.

이 과정에서 대면하게 될 문제점은 크게 4가지이다.

  1. 메서드의 접근지정자가 private로 선언되어 테스트 루틴에서 접근할 수 없는 경우.
  2. 메서드 호출시 넘겨야할 파라미터를 생성하기 어려운 경우
  3. 데이터베이스의 변경 등 메서드의 부작용이 우려되어, 테스트 하네스내에서 실행할 수 없는 경우.
  4. 메서드가 사용하는 객체에 대해 사전에 감지해야할 필요성이 있는 경우

자, 이제 이 문제점들을 해소하기 위한 방법들을 알아보도록 하자.

1. 메서드가 숨어있는 경우(The Case of the Hidden Method)

메서드의 접근지정자가 private 라서 테스트 루틴에서 접근할 수 없는 경우엔 어떻게 해야할까?

가장 먼저 변경 대상인 해당 메서드를 public 으로 변경하는 것을 고려해보아한다.

이 방법은 실제 코드와 통일한 방법으로 테스트가 보장되며, 변경사항이 상대적으로 적기 때문에 작업도 간단하다.

물론 장점만 있지는 않다.

private 인 메서드에 대한 호출 방법이나 사용법에 대한 것을 테스트 루틴으로 작성하여 이에 대한 피드백을 획득해야 하는 경우나

public 메서드를 이용한 테스트가 어려운 경우도 있기 때문이다.

그래서 private 메서드를 테스트하기 위해선 어떤 방법이 좋을까?

사실 단점이 존재함에도 public으로 전환하는 것이 정답이다.

만약 public으로 전환하는 것이 꺼림칙하다면 해당 메서드를 가진 클래스가 너무 많은 책임을 가지고 있는 것이며 클래스를 수정해야하는 상황으로 판단해야 한다.

이 “꺼림칙함”을 좀 더 명세해보면 아래와 같다.

  1. 변경 대상인 메서드는 단순히 유틸리티이므로, 호출 코드는 이 메서드에 관심이 없다.
  2. 호출 코드에서 메서드를 사용하는 경우, 클래스의 다른 메서드의 결과 값에 영향을 준다.

먼저, 유틸성 메서드의 경우는 심플하다.

해당 메서드를 다른 클래스로 옮기는 것을 검토하거나, 클래스의 인터페이스에 public 메서드를 추가로 정의하면 된다.

조금 고려해야할 부분은 두 번째 꺼림칙함이다.

이 경우 private 메서드를 신규 클래스로 이전하고, public으로 변경하여 기존 클래스에서 신규 클래스의 객체를 생성하도록 유도한다.

별도의 클래스로 분리함으로써, 해당 메서드의 테스트가 가능해지고 설계도 좀 더 나아지며 기존 클래스의 책임은 좀 더 가벼워진다.

참고 여기서 설계의 좋고 나쁨의 기준테스트가 가능한가 불가능한가를 뜻한다.

이제부터 예제를 통해 해결 방법을 살펴보자.

1
2
3
4
5
6
7
8
9
class CCAImage {
private:
void setSnapRegion(int x, int y, int dx, int dy);
// ...

public:
void snap();
// ...
};

CCAImage 클래스는 보안 시스템 내에서 사진을 찍을 때 사용되는 레거시 코드이다.

이 클래스는 카메라를 제어하고 사진 촬영을 수행하는 snap() 이라는 메서드를 가지고 있다.

사진 촬영시 피사체의 움직임에 따라 setSnapRegion() 메서드를 반복적으로 호출하여 촬영한 사진을 어떤 버퍼에 저장할지를 결정한다.

이 상황에서 카메라의 API가 변경되어 setSnapRegion() 메서드도 변경되어야하는 상황이라고 가정한다.

먼저 setSnapRegion() 메서드를 무작정 public으로 전환하려고 보니, 외부에서 setSnapRegion() 메서드 호출하여, snap() 메서드에도 영향을 주어 보안 시스템에 오류를 야기할 수 있는 상황이다.

상술했듯 이러한 구조는 CCAImage 클래스가 너무 많은 책임을 가지고 있는 것으로 판정할 수 있다.

신규 클래스를 작성하여 책임을 분리하기엔 이 레거시 코드에 대한 이해도가 떨어지는 경우에도 해결할 수 있을까?

다행히 아래와 같은 방법으로 해결할 수 있다.

이번엔 setSnapRegion() 메서드를 무작정 protected로 변경한다.

1
2
3
4
5
6
7
8
9
class CCAImage {
protected:
void setSnapRegion(int x, int y, int dx, int dy);
// ...

public:
void snap();
// ...
};

이후 setSnapRegion() 메서드에 접근할 서브 클래스 TestingCCAImage를 작성한다.

1
2
3
4
5
6
class TestingCCAImage : public CCAImage {
public:
void setSnapRegion(int x, int y, int dx, int dy) {
// call the setSnapRegion of the superclass CCAImage::setSnapRegion(x, y, dx, dy);
}
};

클래스가 하나 더 생겼을 뿐 public으로 처리한 것과 유사한 결과로 보일 수 있다.

클래스의 원칙인 캡슐화가 깨지긴 했지만 테스트를 가능하게 해준다 는 장점이 그만큼 크기 때문이다.

2. 프로그래밍 언어의 편리한 기능 (The Case of the “Helpful” Language Feature)

프로그래밍 언어의 설계자들은 개발자가 더 편해지게 하려고 최선을 다하고 있다.

하지만 프로그래밍 언어라는 녀석은 단순함, 보안성, 안정성 간의 균형을 유지하는 것은 매우 어려운 일이다.

아래 예제를 한 번 보자.

1
2
3
4
5
6
7
8
9
10
11
public void IList getKSRStreams(HttpFileCollection files) { 
ArrayList list = new ArrayList();
foreach(string name in files) {
HttpPostedFile file = files[name];
if (file.FileName.EndsWith(".ksr") || (file.FileName.EndsWith(".txt") && file.ContentLength > MIN_LEN)) {
// ...
list.Add(file.InputStream);
}
}
return list;
}

이 예제는 웹 클라이언트로부터 업로드된 파일들의 컬렉션을 얻어오는 C# 프로그램의 일부이다.

getKSRStreams() 메서드를 호출하기 위해선 HttpFileCollection 객체를 파라미터로 넘겨줘야하고,

내부 순회시 HttpPostedFile 객체까지 생성해야한다.

이때 HttpPostedFile 클래스는 public한 생성자가 없고, 심지어 sealed 클래스라고 상정해보자.

C#에서 sealed 클래스는 서브클래스를 정의할 수 없으므로 HttpPostedFile 객체의 생성은 물론 서브클래스화 기법도 사용할 수 없다.

이는 HttpFileCollection 클래스도 마찬가지이며, 라이브러리에 속한 클래스라서 인터페이스 추출 기법이나 구현체 추출 기법도 사용할 수 없다고 가정한다.

상술했듯 많은 제약 사항이 존재하는 상황에서 유일하게 적용가능한 기법은 매개변수 적합 기법(Adapt Parameter) 이다.

HttpFileCollection 클래스의 슈퍼클래스인 NameObjectCollectionBase 클래스에 서브클래스화 기법을 적용하고

해당 서브클래스 객체를 getKSRStreams() 메서드에 넘기는 방식이다.

NameObjectCollectionBase 클래스의 서브클래스인 OurHttpFileCollection 클래스를 작성했다고 가정하고 코드를 수정하면 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
public void IList getKSRStreams(OurHttpFileCollection files) { 
ArrayList list = new ArrayList();
foreach(string name in files) {
HttpPostedFile file = files[name];
if (file.FileName.EndsWith(".ksr") || (file.FileName.EndsWith(".txt") && file.ContentLength > MIN_LEN)) {
// ...
list.Add(file.InputStream);
}
}
return list;
}

HttpFileCollection 클래스는 해결했고, 이제 HttpPostedFile 클래스를 보자.

HttpPostedFile 클래스의 file 객체에서 필요한 건 file.FileNamefile.ContentLength 두 개이다.

필요한 속성이 두 개이니, 이 두 개의 속성을 제공할 클래스를 작성해보자.

이때 HttpPostedFile 클래스를 분리하기 위해 API 포장 기법(Skin and Wrap the API) 을 사용한다.

이 기법을 사용해 인터페이스 IHttpPostedFile를 추출하고 HttpPostedFileWrapper 클래스를 작성하면 아래와 같다.

1
2
3
4
5
6
7
8
9
public class HttpPostedFileWrapper : IHttpPostedFile {
public HttpPostedFileWrapper(HttpPostedFile file) {
this.file = file;
}
public int ContentLength {
get { return file.ContentLength; }
}
// ...
}

인터페이스가 준비되었으니, 이제 테스트를 위한 위장 객체를 작성할 수 있다.

1
2
3
4
5
6
7
8
public class FakeHttpPostedFile : IHttpPostedFile {
public FakeHttpPostedFile(int length, Stream stream, ...) {
// ...
}
public int ContentLength {
get { return length; }
}
}

이제 컴파일러에게 맡기기 기법(Lean on the Compiler) 을 사용하면 HttpPostedFileWrapper 객체나 FakeHttpPostedFile 객체 둘 중 어느 것인지 신경쓰지않고 IHttpPostedFile 인터페이스를 사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
public IList getKSRStreams(OurHttpFileCollection) { 
ArrayList list = new ArrayList();
foreach(string name in files) {
IHttpPostedFile file = files[name];
if (file.FileName.EndsWith(".ksr") || (file.FileName.EndsWith(".txt"))&& file.ContentLength > MAX_LEN)) {
// ...
list.Add(file.InputStream);
}
}
return list;
}

3. 탐지가 불가능한 부작용(The Case of the Undetectable Side Effect)

어떠한 클래스가 있을가 있을 때, 이 클래스의 객체를 생성하고 메서드를 호출해 피드백을 받는다.

이게 지금까지 언급한 테스트 루틴을 작성한 방법이다.

문제는 테스트 대상인 객체가 타 객체와의 상호작용이 존재하는 경우가 대다수라는 것이다.

특히 반환하는 값이 없는 메서드의 경우, 호출시 어떤 작업이 수행되고 있다는 것은 유추할 수 있지만 호출 코드 입장에서는

호출한 메서드의 작업 결과나 작업 내용을 알 수가 없다.

상술한 문제점을 가진 아래 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class AccountDetailFrame extends Frame implements ActionListener, WindowListener {
private TextField display = new TextField(10);
// ...
public AccountDetailFrame(...) { ... }

public void actionPerformed(ActionEvent event) {
String source = (String)event.getActionCommand();
if (source.equals("project activity")) {
detailDisplay = new DetailFrame();
detailDisplay.setDescription(getDetailText() + " " + getProjectionText());
detailDisplay.show();

String accountDescription = detailDisplay.getAccountSymbol();
accountDescription += ": ";
// ...
display.setText(accountDescription);
// ...
}
}
// ...
}

AccountDetailFrame 클래스는 GUI 컴포넌트들을 생성하고 actionPerformed() 메서드에 구현된 핸들러를 통해 결과값을 화면에 렌더링한다.

테스트 루틴에서 actionPerformed() 메서드의 실행은 사실 아무 의미가 없다.

이 메서드는 그저 윈도우를 생성하여 화면에 띄우고, 값을 입력받고 표시해주는 것이 동작의 전부이기때문에 어떠한 동작을 하고 있는 지 탐지할 곳이 없다.

먼저 GUI에 의존적인 부분과 독립적인 부분을 분리하여, 메서드의 동작을 쪼개야 한다.

actionPerformed() 메서드를 보면 윈도우가 던져준 ActionEvent 객체로부터 명령어를 뽑아내근 부분과, 화면에 렌더링하는 부분으로 쪼갤 수 있다.

작업하면 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class AccountDetailFrame extends Frame implements ActionListener, WindowListener {
private TextField display = new TextField(10);
// ...
public AccountDetailFrame(...) { ... }

public void actionPerformed(ActionEvent event) {
String source = (String)event.getActionCommand();
performCommand(source);
}

public void performCommand(String source) {
if (source.equals(“project activity“)) {
detailDisplay = new DetailFrame();
detailDisplay.setDescription(getDetailText() + " " + getProjectionText());
detailDisplay.show();

String accountDescription = detailDisplay.getAccountSymbol();
accountDescription += ": ";
// ...
display.setText(accountDescription);
// ...
}
}
// ...
}

이후 performCommand() 메서드의 detailDisplay 변수를 클래스의 변수로 빼낸다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class AccountDetailFrame extends Frame implements ActionListener, WindowListener {
private TextField display = new TextField(10);
private DetailFrame detailDisplay;
// ...
public AccountDetailFrame(...) { .. }

public void actionPerformed(ActionEvent event) {
String source = (String)event.getActionCommand();
performCommand(source);
}

public void performCommand(String source) {
if (source.equals("project activity")) {
detailDisplay = new DetailFrame();
detailDisplay.setDescription(getDetailText() + " " + getProjectionText());
detailDisplay.show();

String accountDescription = detailDisplay.getAccountSymbol();
accountDescription += ": ";
// ...
display.setText(accountDescription);
// ...
}
}
// ...
}

다음으로 화면에 표시하는 컴포넌트의 동작을 추상화하여 정의한다.

어떤 화면 표시 컴포넌트를 사용하는 지보다는 그 동작 자체를 메서드명으로 정의하는 것이 좋다.

추상화하여 분리한 코드는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class AccountDetailFrame extends Frame implements ActionListener, WindowListener {
public void performCommand(String source) {
if (source.equals("project activity")) {
setDescription(getDetailText() + " " + getProjectionText());
// ...
String accountDescription = getAccountSymbol();
accountDescription += ": ";
// ...
display.setText(accountDescription);
// ...
}
}

void setDescription(String description) {
detailDisplay = new DetailFrame();
detailDisplay.setDescription(description);
detailDisplay.show();
}

String getAccountSymbol() {
return detailDisplay.getAccountSymbol();
}
// ...
}

이제 detailDisplay 과 관련된 모든 코드를 추출하였으니, AccountDetailFrame 클래스의 컴포넌트에 접근하는 코드를 추출한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class AccountDetailFrame extends Frame implements ActionListener, WindowListener {
public void performCommand(String source) {
if (source.equals("project activity")) {
setDescription(getDetailText() + " " + getProjectionText());
// ...
String accountDescription = detailDisplay.getAccountSymbol();
accountDescription += ": ";
// ...
setDisplayText(accountDescription);
// ...
}
}

void setDescription(String description) {
detailDisplay = new DetailFrame();
detailDisplay.setDescription(description);
detailDisplay.show();
}

String getAccountSymbol() {
return detailDisplay.getAccountSymbol();
}

void setDisplayText(String description) {
display.setText(description);
}
// ...
}

코드의 분리와 몇 차례의 추출이 진행되고 나서야, 이제 서브클래스와 메서드 재정의 기법을 사용할 수 있게 되었다.

performCommand() 메서드를 테스트하기 위해 AccountDetailFrame 클래스의 서브클래스를 작성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class TestingAccountDetailFrame extends AccountDetailFrame {
String displayText = "";
String accountSymbol = "";

void setDescription(String description) { }

String getAccountSymbol() {
return accountSymbol;
}

void setDisplayText(String text) {
displayText = text;
}
}

이제 테스트 코드는 아래와 같이 작성할 수 있게 되었다.

1
2
3
4
5
6
public void testPerformCommand() {
TestingAccountDetailFrame frame = new TestingAccountDetailFrame();
frame.accountSymbol = "SYM";
frame.performCommand("project activity");
assertEquals("SYM: basic account", frame.displayText);
}

여태까지의 작업을 UML로 나타내보자.

최초엔 아래와 같이 performAction() 이라는 최중요 메서드(important method)를 포함하는 AccountDetailFrame 클래스가 존재했다.

Figure 10.1

이후 메서드의 분리와 추출을 통해 생성된 메서드에 각각 별도의 책임을 부여하여 아래와 같은 UML로 변경하였다.

Figure 10.2