Search
Duplicate

TDD Pattern

목차

개요

이번 포스팅에서는 몇 가지의 TDD 트릭, 몇 가지의 디자인 패턴, 몇 가지의 리팩토링을 소개하며
TDD를 좀 더 유연하고 잘 사용할 수 있도록 돕는 기술들에 대해 소개한다.

1. 빨간 막대 패턴

테스트를 언제 어디에 작성할 것인지와 언제 테스트 작성을 멈출지에 대한 내용.

1-1. 한 단계 테스트

내가 무엇을 테스트할 지 어떤 기준으로 선택을 할 것인가?
최종 목표로 한 단계 진전시켜줄 수 있는 테스트
사실 무엇을 테스트 할 지에 대한 선택에 정답은 없다. 사람마다 생각할 수 있는 바운더리가 다르고, 경험에따라 한 단계의 크기가 다를 수 있다. 내가 자동차라는 객체에서 전진, 후진, 좌회전, 우회전 4개의 테스트를 생각했다고 할 때 나보다 풍부한 경험을 가진 개발자는 여기서 기어 변속, 네비게이션 변경, 주유하기, 다른 기름 넣어 주유하기 등 더 많은 테스트를 떠올릴 수 있을 것이다.
책에서는 뻔하지는 않지만 (1+1은 2이다! 라는걸 굳이 증명해야할까? 생각해볼 주제다.), 구현할 수 있다는 확신이 드는 테스트말이다.

상향식(bottom-up) ? 하향식(top-down) ?

전체적인 기능에서 하나의 간단한 사례를 시작으로 테스트를 작성하고 이 테스트를 통해 커져가는 프로그램은 하향식(top-down)으로 작성된 것으로 볼 수 있고, 반대로 전체의 작은 한 조각을 나타내는 테스트에서 시작해 조금씩 붙여가는 테스트를 작성한다면, 이는 상향식(bottom-up)으로 생각할 수 있다. 하지만, 이는 둘 다 TDD 프로세스를 효과적으로 설명할 수 없는데, 이와 같은 수직적 메타포는 프로그램이 시간이 흐르면서 어떻게 변해가는가에 대한 시각화일 뿐이다.

성장(growth)

결국 두 방식의 공통점은 프로그램을 성장(growth)시킨다는 것에 있다.
성장은 자기유사성을 가진 피드백 고리를 암시하는데, 환경과 프로그램이 서로 영향을 준다.
또한, 메타포가 상향 혹은 하향식의 방향성을 가지기보다는 아는 것에서 모르는 것으로(known-to-unknown)라는 방향이 낫지 않을까?
이런 방향성은 어느정도의 지식과 경험을 기반으로 개발을 시작하면서도, 개발 와중에 새로운 것을 배울것도 암시한다.

1-2. 시작 테스트

오퍼레이션이 아무 일도 하지 않는 경우를 먼저 테스트할 것
새 오퍼레이션은 처음에는 어디에 넣어야 할지 고민이다. 이 고민을 해결하기전에 테스트에 무엇을 작성해야 할 지 답변할 수 없다.
오퍼레이션에 대한 테스트를 작성할 때 처음 고민해봐야 할 문제는 여러가지가 있다.
이 오퍼레이션을 어디에 두어야 하는가?
적절한 입력 값은 무엇인가?
이 입력들이 주어졌을 때 적절한 출력은 무엇인가?
이런 고민들을 한 번의 테스트로 시작하기에는 시간이 오래 걸릴 수 있다.
TDD에서는 빨강-초록-리팩토링의 과정이 짧은 주기로 반복되는 것을 권장한다. 그렇기에 우선 첫 번째 고민인 새 오퍼레이션의 위치를 고민할 때 적절한 입력값과 출력값은 정말 발견하기 쉬운 값을 사용하거나 아예 값이 없는 경우로 가정하면 이 시간을 많이 줄일 수 있다.
간단한 소켓 기반서버의 첫 번째 테스트를 위의 이야기 기반하에 작성하면 다음과 같이 시작 테스트를 작성할 수 있다.
StartServer Socket = new Socket Message = "hello" Socket.write(message) AssertEquals(message, socket.read)
Java
복사

1-3 설명 테스트

이런 자동화된 테스트를 팀에서 모두가 사용하게 하고싶다면 어떻게 해야할까?
그냥 TDD 좋아요 츄라이 츄라이! 하면 모두가 OK! 하고, 테스트를 작성할까? 굳이 시간만 더 들 것 같은 생각에, 익숙치 않은 기술이나 개념을 학습해야한다는 생각에 반발심만 들 것이다.
그보다는 개발하는 기능을 테스트를 통해 설명하고, (이왕이면) 테스트를 통해 설명을 요청하라.
팀에서 혼자 TDD를 한다면 어려움을 많이 느낄 수 있지만, 시간이 흐름에 따라 테스트가 작성된 코드에 대해서는 문제가 발생하는게 적어지고, 이를 수치화 할 수 있을 때 다시 팀원들에게 얘기한다면, 처음에 무작정 권유할 때와는 다른 반응이 올 수 있을 것이다.
하지만, 새롭게 TDD를 시작한 사람들의 열정을 조심해야 한다.
사실 TDD뿐 아니라 많은 개발 관련 기술에 대해 새롭게 익히고 매력에 빠진 사람들을 조심해야 한다.
다짜고짜 좋다고 밀어붙히는 사람만큼 해당 기술(TDD)이 퍼지는 것을 막는 것은 없다.
함부로 이미 익숙하게 작업을 하고 있는 팀원들이 일하는 방식을 바꾸려 해서는 안된다.
단순한 이용법은 코드 리뷰 혹은 코드를 설명할 때 테스트를 이 용하여 설명하는 것이다.
Catsbi: Foo를 이런식으로 설정하고 Bar를 이런식으로 설정하면 결과가 76이 나와야 하는거죠? Catsbi: Foo에 조건을 설정하고 Bar에 조건을 넣으면 답은 76이 나옵니다.
Plain Text
복사
좀 더 개괄적인 범위로 생각해보면 누군가에게 시퀀스 다이어그램(sequence diagram)을 설명하려 할 때 이를 테스트 케이스로 작성해보는 것도 하나의 방법이다.

1-4 학습 테스트

외부에서 제공하는 라이브러리나 API를 테스트 해야 할 때도 있다.
패키지의 새로운 기능을 처음 사용하기 전 테스트를 작성 할 수도 있다. 자바의 모바일 정보 기기 프로파일 라이브러리(MIDP, Mobile Information Device Profile)를 기반으로 프로젝트의 기능을 만든다고 할 때 RecordStore에 어떤 데이터를 저장하고 이를 받아오고자 한다면, 다음과 같이 테스트를 작성해볼 수 있을 것 같다.
@BeforeEach void setUp() { sotre = RecordStore.openRecordStore("testing", true); } @AfterEach void tearDown(){ RecordStore.deleteRecordStore("testing"); } void testStore() { int id = store.addRecord(new byte[]{5, 6}, 0, 2); assertEquals(2, store.getRecordSize(id)); byte[] buffer = new byte[2]; assertEquals(2, store.getRecord(id, buffer, 0)); assertEquals(5, buffer[0]); assertEquals(6, buffer[1]); }
Java
복사
만약 API가 제대로 동작하고 우리가 제대로 API를 이해한게 맞다면 이 테스트는 바로 통과할 것이다.
테스트가 통과되지 않는다면, 애플리케이션도 동작안할테니 돌려볼 필요도 없다.

1-5. 또 다른 테스트

사람이라는게 하나의 주제를 가지고 얘기하더라도, 집중이 흐트러지면서 다른 생각을 하게 된다.
그럼 어떻게 주제에서 벗어나지 않고 기술얘기를 논의할 수 있을까? 언급했던 것처럼 주제와 무관한 아이디어가 떠오르면 이에 대한 테스트를 할 일 목록에 작성하고 다시 주제로 돌아오는 것이다.
즉, 새로운 아이디어가 떠오르면, 따로 목록을 작성해서 버려지는 일은 없게 하되 이런 아이디어가 내 집중을 깨트리지 못하도록 한다.

1-6. 회귀 테스트

사실 우리가 아무리 노력해도 100% 완벽하고, 모든 경우에 대한 테스트 코드를 작성하는 것은 불가능에 가깝다. 그렇기에 시스템 장애는 테스트 기반으로 작성했을지라도 발생할 수 있다.
그럼 이렇게 시스템 장애가 발생할 경우 해당 장애로 인해 실패하는 테스트와, 통과 할 경우 장애가 수정되았다는 것을 볼 수 잇는 테스트를 작성하는것을 회귀 테스트(regression test)라 한다.

1-7. 휴식

개발을 하다보면 야근을 하고 철야를 해도 안풀리던 문제, 장애가 한숨 자고 일어나서 출근하니까 10분만에 해결되는 경우를 한 번쯤은 겪어봤을 수 있다. 위의 피드백 고리처럼 피로가 높아질수록 판단력이 낮아지고 판단력이 낮아짐으로써 문제 해결이 안되면서 피로도는 또 높아질 수 있다.
데이브 응가(Dave Ungar)의 샤워 방법론은 다음과 같이 말한다.
키보드로 뭘 쳐야 할지 알면, 명백한 구현을 한다.
올바른 설계가 명확하지 않다면 삼각측량 기법을 사용한다.
이래도 안된다면 샤워나 하러 가자.

1-8. 다시 하기

코드를 구현하고 테스트를 작성하다보면, 종종 길을 잃은 느낌을 받을 때가 있다.
잘 모르겠다면 가짜 구현을 한다.
문제를 해결하기위해 코드를 추가하고 인터페이스를 설계하고, 디자인패턴을 사용해보지만, 코드는 더 엉켜지고, 꼬여만 가는 기분이다. 휴식도 취해보고 산책까지 다녀와서 다른 팀원들과 티타임까지 가져봤지만, 여전히 길을 잃은 미아가 된 기분이라면, 그냥 처음부터 다시 하는게 항상 더 합리적이다.
이런 순간에 아쉽다는 생각과 귀찮다는 생각에 억지로 밀고나갔다가 남는 것은 뭔지 알 수 없는 스파게티 코드 덩어리와 빨간불 뿐이다.

2. 테스팅 패턴

여기저기 TDD를 하자 테스트가 좋다! 라는 말에 현혹되어 나도 한 번? 생각해보고, JUnit 의존성을 추가하고 테스트 클래스를 만들어보지만, 뭐부터 작성해야할지 난감하다. 이번 챕터에서는 다양한 테스팅 작성법에 다뤄보며, 테스트를 어디서부터 작성할지에 대해 조금이나마 도움이 되길 바란다.

2-1. 자식 테스트

테스트 케이스를 작성할 때 해당 케이스가 너무 크다고 생각이 들고, 깨지는 부분이 발생한다면, 해당 부분에 해당하는 작은 테스트 케이스를 작성하고, 이 작은 테스트 케이스가 실행되도록 하자.
이 케이스가 통과한다면 원래의 큰 테스트케이스도 통과될 것이다.
테스트 케이스의 크기가 크다면 고민해볼 필요가 있다. 왜이렇게 테스트가 큰지, 더 작게는 만들 수 없는지, 내 느낌은 어떤지에 대해서 말이다.

2-2. 모의 객체

비용이 많이 드는, 혹은 복잡한 리소스에 의존하는 객체를 테스트 하기 위해서는 어떻게 해야할까?
심지어 해당 객체가 사용하는 도메인이나 서비스같은 외부의 패키지를 사용해야 하는데, 사용하기위해 필요한 의존성들이 복잡하게 얽혀있다면, 어떻게 해야할까? 그냥 손을 놔버리고 테스트를 포기하고싶어질 수 있다. 이런 경우 상수를 반환하게끔 만든 속임수 버전의 리소스를 만들면 된다.
즉, 테스트 더블(Test Double)을 활용한다고 생각하면 되는데, 테스트 더블이란 테스트 목적으로 실제 객체 사용되는 모든 종류의 척도 객체에 대한 일반화 용어로써, 실제를 대체한다는 의미를 가진다.
다양한 형태의 Test Double

참고: Stubbing

테스트 수행 시 호출이 발생할 경우 미리 지정된 답변으로 응답하는 것을 Stubbing이라 하며 미리 프로그램된 것 외의 것에 대해서는 응답하지 않는다.
Stubbing

2-3. 셀프 션트(self shunt)

객체가 다른 객체와 올바르게 상호 작용(소통)을 하는지 확인하기 위해 테스트 대상(객체)이 원래의 대화 상대가 아닌 테스트 케이스와 대화하도록 만드는 것.
즉, 테스트 케이스 자체를 모의 객체로 만들어 사용하는 패턴으로 테스트 하고 싶은 객체를 인터페이스화 하여 테스트 케이스가 해당 인터페이스를 구현하도록 한다.
interface TestTarget { void action(); } class Real { void action(TestTarget tt){ tt.action(); } } class TestTargetMock implements TestTarget { private int actionCount; void action(){ actionCount++; } void actionTest() { Real real = new Real(); real.action(this); Assertions.assertEquals(1, actionCount); } }
Java
복사
셀프션트를 이용한 테스트
다만, 이 방법은 mocking이 필요한 객체가 많아질수록 사용이 기하급수적으로 어려워진다.
자바를 기준으로 셀프 션트를 사용하다보면 인터페이스 안의 온갖 메서드를 모두 다 구현하는 테스트들을 볼 수 있다. (스몰토크나 파이썬 같은 낙관적 타입 시스템 언어는 필요한 오퍼레이션만 구현하면 되기에 조금 더 유리하다고 할 수 있다. )
객체들의 인터페이스를 통한 추상화는 OOP 패러다임을 지키기에 유리해지기에 좋은 설계가 될 수 있겠다는 생각은 들지만, 이정도의 추가 비용을 감수할 수 있는지는 트레이드오프를 잘 계산해 봐야 할 것 같다.

2-4. 로그 문자열

로직이 내가 생각하는 순서대로 수행되는지를 검사하기 위해서 로그 문자열을 가지고 있다가 메세지가 호출될 때마다 문자열에 추가하도록 하는 방법.
def testTemplateMethod(self): test = WasRun("testMethod") result = TestResult() test.run(result) assert "setUp testMethod tearDown " == test.log def setUp(self): self.log = "setUp " def testMethod(self): self.log = self.log + "testMethod " def tearDown(self): self.log = self.log + "tearDown "
Python
복사
로그 문자열 기법은 특히 옵저버 패턴으로 이벤트 통보가 원하는 순서대로 발생하는지 확인하고자 할 때 유용하다.

2-5. 크래시 테스트 더미

발생하기 힘든 에러 상황은 어떻게 테스트해야 할까?
실제 비즈니스로직 대신 예외를 발생시키기만 하는 특수한 객체를 만들어 이를 호출하는 방법이 있다. 테스트되지 않은 코드는 작동하는게 아니고, 이런 가정이 가장 안전한 가정이다.
그렇다면 수 많은 예외 상황에 대해서 테스트는 어떻게 해야할까?
파일 시스템에 여유공간이 없어 발생하는 문제를 테스트하고싶다고 하면, 어떻게 테스트할지에 대해 몇 가지 생각해볼 수 있다.
1.
실제로 큰 파일을 많이 만들어서 파일 시스템을 가득 채우기
2.
가짜 구현(fake it)을 사용하기
크래스 테스트 더미(Crash Test Dummy)는 다음과 같다.
private class FullFile extends File { public FullFile(String path){ super(path); } public boolean createNewFile() throws IOException { throw new IOException();
Java
복사
public void testFileSystemError() { File f = new FullFile("foo"); try { saveAs(f); fail(); } catch(IOException e) { } }
Java
복사

2-6. 깨진 테스트

퇴근하기 5분전! 하지만, 개인 작업 프로그래밍(테스트)은 완성되지 않은 상태일 때 프로그래밍 세션을 어떤 상태로 끝마치는게 좋을지에 대해 생각해 본 적이 있는가? 나는 사실 별 생각을 한 적은 없었다.
책에서는 마지막 테스트라 깨진 상태로 끝마치는 것을 권장한다.
다음날 혹은 다음 주 출근하여 자리에 앉았을 때 반쪽짜리 코드를 보면, 이 코드를 쓸 때 어떤 생각을 했는지에 대해 실마리를 떠올리고 문장을 마무리할 수 있을 것이다.
프로젝트가 깨진 상태로 두는것이 불만이라고 주석처리 혹은 테스트대상 제외, 무조건 통과하도록 코드를 작성한 상태로 잊게되면, 나중에는 이 부분에 대해 문제가 발생해도 발견하지 못하는 불상사가 발생할 수 있다.

2-7. 깨끗한 체크인

팀 작업 프로그래밍에서의 프로그래밍 세션은 모든 테스트가 통과된 상태로 끝마치는게 좋다.
혼자가 아닌 팀원들과 함께 작업하는 경우라면, 내가 마지막으로 작성한 코드로부터 지금까지 무슨일이 일어났는지 세밀하게 알기 힘들다. 그렇기에 안심이 되고 확신이 있는 상태에서 시작 할 필요가 있다.
내 코드를 체크인(병합)하기 전 통합 테스트를 실행해서 실패하는 경우가 생긴다면, 간단하게는 코드를 날려버린 뒤 다시 하는 것이다. 테스트가 실패했다는 것은 내가 프로그램을 완전히 이해하지 못했음을 알려주는 증거가 된다. 이런 문화가 팀에 정착된다면 모두 체크인(병합)을 자주하려는 경향이 생길 수 있다. 왜냐? 빨리 체크인할수록 문제가 발생 해서 코드를 날려버릴 확률이 줄어들 테니까...
하지만, 테스트를 통과하기 위해서 주석 처리를 하는 것은 절대로 해서는 안된다. 이는 더 큰 문제를 발생시키는 단초가 될 수 있다.

3. 초록 막대 패턴

테스트에 빨간불이 들어왔다면 이를 빨리 해결해야 초록 막대로 변경할 수 있다.
심신의 안정을 위해서도 저 사악한 빨간 막대를 없애야 한다. 이번 챕터에서는 코드가 테스트를 통과하여 심신의 안정을 주는 초록 막대로 만들기 위해 사용할 수 있는 패턴들을 소개한다.

3-1. 가짜로 구현하기(진짜로 만들기 전까지만)

TDD를 해보겠다고 실패하는 테스트를 만든 뒤 처음으로 해야 핼 구현은 무엇일까?
우선 상수를 반환하게 하자. 그렇게 일단 초록 막대가 나온다음 그 다음 단계적으로 상수를 반환하는 대신 정상적인 로직을 작성해 올바른 값을 반환하도록 리팩토링 하면 된다.
이제 이런 생각을 해 볼 수 있다.
어짜피 들어낼 작업인데 어째서 해야하는가?
결론부터 말하자면, 무언가 돌아가는 것을 만들어두는 것이 돌아가지 않는 걸 가지고 있는 것보다 좋다. 가짜를 구현하기라는 주제에 힘을 보태주는 효과는 다음과 같이 두 가지가 있다.
심리학적
: 콘솔창에 막대라 빨간색일때와 초록색일 때 받는 심리적 압박감이 다르다. 현재 내 상황와 어디에 있는지 안다. 나는 확신을 가지고 거기부터 리팩토링 해 나가면 된다.
범위(scope)조절
: 프로그래머는 온갖 문제를 상상하며 방어코드를 작성하다보면 오버 엔지니어링을하는 경우가 빈번하다. 이 때 하나의 구체적인 예에서 시작해 일반화를 하게 되면 불필요한 고민으로 혼동이 생기는 것을 예방할 수 있다. 또한, 다음 테스트 케이스를 작성함에 있어 이전 테스트의 작동이 보장된다는 것을 알기에 다음 테스트에 집중할 수 있다.

3-2. 삼각측량

TDD에서 추상화 과정을 하는 과정은 최소 두 개 이상의 테스트 케이스를 얻었을 경우이다.
예제를 통해 알아보자. 우선 두 정수의 합을 반환하는 함수를 작성하고 이를 테스트한다고 하자.
public void testSum() { assertEuqlas(4, plus(3, 1)); } private int plus(int augend, int addend) { return 4; }
Java
복사
이제 여기서 삼각측량(triangulate)을 사용해 바른 설계로 간다면 다음과 같이 두 번째 테스트 케이스를 작성할 수 있다.
public void testSum() { assertEuqlas(4, plus(3, 1)); assertEuqlas(7, plus(3, 4)); }
Java
복사
이제 plus 메서드는 4라는 상수를 반환해서는 이 테스트를 통과할 수 없다, 그리고 케이스가 두 개 이상이기에 구현을 추상화 할 수 있게 된다.
private int plus(int augend, int addend) { return augend + addend; }
Java
복사
자! 이제는 plus 메서드가 깔끔하게 추상화가 되었다.
그럼 이제 고민해 볼 문제가 단언문 2개는 값만 다를 뿐 중복된 단언이다. 그렇기에 하나를 제거할 수 있는데, 이렇게 되면 다시 plus함수를 상수를 반환하도록 단순화 할 수 있게된다.
... 이렇게되면 처음과 같아지니 무한 반복이 될 수 있는데, 사실 추상화를 하고 중복 제거를 한 시점에서, 멈추면 된다고 생각한다.
여하튼, 올바른 추상화에 대해 감을 잡기 힘든 경우에만 삼각측량을 이용하고 그 외에는 명백한 구현 혹은 가짜로 구현하기를 주로 사용한다.

3-3. 명백한 구현

포스팅을 보다보면 이런 생각을 할 수 있다.
간단한 덧셈정도 구현하는데 삼각측량을 쓰고 추상화를 해야해? 그냥 바로 구현하면 안돼 ?
단순한 연산들을 매번 상수로 만들고 삼각측량을 써서 추상화를 하고 그래야 할까?
아니다 명백하고 단순한 연산들은 그냥 구현해도 된다!. 때로는 어떤 연산을 어떻게 구현할지에 대해 확신을 가지고 있는데, 돌아갈 필요는 없다. 그리고 빨간 막대가 등장한다면 그 때 다시 조금 더 작은 규모의 테스트 케이스를 작성할 것이다.
다만, 늘 명백한 구현을 사용하게 된다면 나 자신의 완벽함이 필요하게 되는데, 이는 상당한 압박감과 현타로 돌아올 수 있다. 한 번에 제대로 동작하고 거기에 깨끗하기까지 한 코드를 만드는 것은 결코 쉽지 않다. 그렇기에 우선 제대로 동작하는 단계로 돌아가 해결한 뒤 그 이후 깨끗한 코드 를 해결하도록 하자.

3-4. 하나에서 여럿으로

객체 컬렉션(collection)을 다루는 연산을 구현하는 경우에는 우선 컬렉션 없이 구현 후, 컬렉션을 사용하도록 한다. 다음은 배열의 합을 구하는 함수를 작성해 테스트를 해보자. 우선 언급한대로 객체 컬렉션이 없이 하나로 시작해보자.
public void testSum() { assertEquals(5, sum(5)); } private int sum(int value) { return value; }
Java
복사
이제 {5, 7}의 합이 12라는 것을 테스트하고 싶다. 그러기위해 우선 매개변수로 배열을 하나 추가해주도록 하자.
public void testSum() { assertEquals(5, sum(5, new int[] {5})); } private int sum(int value, int[] values) { return value; }
Java
복사
위와같은 단계를 변화 격리하기의 한 예로 볼 수 있는데, 테스트 케이스에 인자를 추가하게 되면 테스트 케이스에는 영향을 주지 않으면서 자유롭게 구현을 변경할 수 있다.
이 다음은 단일값 대신 컬렉션을 사용하도록 로직을 추가해보자.
private int sum(int value, int[] values) { int sum = 0; for(int val : values) { sum += val; } return sum; }
Java
복사
컬렉션을 사용해서도 테스트가 통과되도록 만들었다. 이 다음은 사용하지 않는 단일 인자를 제거하자.
public void testSum() { assertEquals(12, sum(new int[] {5, 7})); } private int sum(int[] values) { int sum = 0; for(int val : values) { sum += val; } return sum; }
Java
복사
이제 최초 계획대로 테스트 케이스를 개선하며 객체 컬렉션 배열을 받아 합을 계산하는 메서드와 이를 검증하는 테스트 케이스가 완성되었다.