TDD의 아이러니 중 하나는 TDD가 테스트 기술이 아니라는 점이다(워드 커닝엄의 선문답이다). TDD는 분석 기술이며, 설계 기술이기도 하다. 사실은 개발의 모든 활동을 구조화하는 기술이다.
- 《테스트 주도 개발》by 켄트 벡
나는 TDD가 현업에서도 쓸만한 방법이란 걸 확인하고 싶었다. 그래서 업무와 비슷한 예제를 찾고, TDD로 구현하면 좋겠다는 생각을 했다. 문제는 쓸만한 예제를 찾는 일이었다. 현업과 비슷한 예제를 찾기 위해 두 가지 가정을 했다.
첫째, 대부분의 개발자는 신규 프로젝트를 하지 않고, 기존 코드에 기능을 추가하거나 버그를 수정하는 일을 한다. 나는 기존 코드에 새 기능을 추가하는 예제를 원했다.
둘째, 너무 쉬운 예제는 안된다. 피보나치수열은 TDD 단골 예제이다. 피보나치수열은 문제가 단순하기 때문에 TDD로 구현하기 쉽다. 현업의 복잡한 문제들을 생각하면 이렇게 단순한(그리고 이미 해법이 알려진) 예제는 쓸만하지 않았다.
고민하던 중 적당한 예제가 생각났다. 켄트 벡이 《테스트 주도 개발》에서 2부 예제로 만들다가 만 xUnit코드가 있다. 이 코드를 이어서 개발하면, 내가 하고 싶은 이야기를 할 수 있겠다는 생각이 들었다. 예제 코드에 대한 설명은 따로 하지 않겠다. 이 포스트의 독자는 《테스트 주도 개발》을 읽은 사람이라고 가정한다.
아래는 켄트 벡인 남긴 xUnit에 대한 작업목록이다. 이 포스트에선 "TestSuite를 자동으로 만들기" 기능을 구현할 생각이다.
|
1. 테스트 추가하기: 인터페이스 설계하기
테스트를 작성할 때는 오퍼레이션의 완벽한 인터페이스에 대해 상상해 보는 것이 좋다. 우리는 지금 오퍼레이션이 외부에서 어떤 식으로 보일지에 대한 이야기를 테스트에 적고 있는 것이다.
- 《테스트 주도 개발》 by 켄트 벡
내가 생각하는 단위 테스트의 목표는 세 가지이다.
- 인터페이스 설계: 클래스 이름, 메서드 이름, 파라미터 개수 등을 결정한다.
- 샘플 코드: 다른 개발자에게 API 사용법을 공유한다.
- 회귀 테스트: 코드가 제대로 돌아가는지 확인한다.
TDD에서 테스트를 구현코드보다 먼저 만드는 이유가 무엇일까? 그 이유는 '인터페이스 설계' 때문이다. 나머지 두 가지 목표는 덤으로 얻는 가치이다. 테스트를 만들면서 새 기능을 어떻게 사용할지 결정한다. 기능을 사용하는 데 얼마나 많은 객체가 필요한지, 어떤 객체에게 메시지를 보내야 하는지, 어떤 데이터가 필요한지 등을 고민한다. 그러다가 적당한 아이디어가 생각나면 테스트로 만들어서 눈으로 확인한다. 테스트는 짧고 단순할수록 좋다. 복잡한 테스트는 인터페이스를 단순하게 만들라는 신호이다.
테스트를 쉽게 만드는 방법은 '기능을 사용하는 단순한 사례'를 생각하는 것이다. API의 샘플코드를 만든다고 생각하면 쉽다. 이 방법은 김창준 님이 《테스트 주도 개발》에서 말한 '예에 의한 사고(예를 들어 생각하기)'기법이다. 아래 세 가지 질문이 도움이 될 것이다.
- 입력 데이터: 구체적인 입력 값은 무엇인가?
- 출력 데이터: 작동한다는 것을 무엇으로 확인할 수 있을까?
- 인터페이스: 어떤 방식으로 사용할 것인가?
위 질문을 우리가 만들려는 기능(TestCase로 TestSuite 만들기)에 적용해 보자.
- 입력 데이터: WasRun으로 TestSuite를 만든다.
- 출력 데이터: TestResult.summary()의 리턴값이 "2 run, 1 failed"가 나오는지 확인한다.
- 인터페이스: 2가지 방법이 생각난다.
WasRun으로 TestSuite를 만드는 방법은 두 가지이다. 생각난 순서대로 써보면 이렇다.
suite = WasRun.suite() #팩토리 메서드
suite = TestSuite(WasRun) #완결 생성자(변환 생성자)
두 인터페이스 중 하나를 골라야 한다. 자신의 코딩스타일을 기준으로 선택해 보자.
나는 생성자로 테스트를 만들 생각이다.
팩토리 메서드가 더 유연한 인터페이스지만, 지금 당장 필요한 건 아니다. 일단 단순한 인터페이스로 시작하는 것이 좋다.
우선, 켄트 벡이 만든 TestCaseTest.testSuite() 메서드를 보자. 아래 테스트는 TestSuite를 '수동'으로 만드는 방법을 알려준다.
class TestCaseTest(TestCase):
def testSuite(self):
suite = TestSuite()
suite.add(WasRun("testMethod"))
suite.add(WasRun("testBrokenMethod"))
suite.run(self.result)
assert("2 run, 1 failed" == self.result.summary())
위 테스트를 고쳐서 TestSuite를 '자동'으로 만들어보자.
class TestCaseTest(TestCase):
def testSuite(self):
suite = TestSuite(WasRun)
suite.run(self.result)
assert("2 run, 1 failed" == self.result.summary())
그리고 돌려보면, 실패한다.
> 5 run, 1 failed
2. 테스트 통과시키기: 가짜로 구현하기
테스트를 통과시키는 방법은 세 가지다.
- 명백한 구현
- 가짜로 구현하기
- 삼각측량
TDD를 마스터하고 싶으면 '가짜로 구현하기'를 많이 사용해라. 처음엔 '가짜로 구현하기'가 바보 같아 보일 수 있다. 그래도 '가짜로 구현하기'와 '작은 단계'로 리팩터링 하는 방법을 연습하는 편이 좋다. 그러면 오버엔지니어링을 피하고, 현재 요구사항에 꼭 맞는 설계를 만들 수 있다. 이 포스트에서는 '가짜로 구현하기'와 '작은 단계'로 리팩토링 하는 전략을 쓰겠다.
일단 테스트를 통과시켜야 한다. 지금은 컴파일 에러가 있다. 현재 테스트에서 TestSuite생성자에 파라미터를 넣어 주는데, TestSuite는 파라미터를 받지 않는다.
class TestSuite:
def __init__(self):
self.tests = []
우선 TestSuite생성자에 파라미터를 하나 추가하자. 파라미터 이름은 testClass로 하자.
class TestSuite:
def __init__(self, testClass):
self.tests = []
그리고 돌려보자.
> Traceback (most recent call last): suite = TestSuite()
> TypeError: __init__() takes exactly 2 arguments (1 given)
이상한 에러가 생겼다. 새로운 에러의 원인은 실행 코드에 있다. 실행 코드에서 TestSuite를 만들 때 파라미터를 전달하지 않는다.
# 실행코드(TestCaseTest의 TestSuite 만들기)
suite = TestSuite() # <= 여기가 문제이다
suite.add(TestCaseTest("testTemplateMethod"))
...
result = TestResult()
suite.run(result)
print result.summary()
이 에러를 잡는 가장 단순한 방법이 뭘까?
가장 먼저 떠오른 아이디어는 메서드 오버로딩이다. 파라미터가 없는 생성자와 파라미터를 받는 생성자 두 개를 만드는 것이다. 하지만 파이썬은 메서드 오버로딩을 지원하지 않아서 쓸 수 없다.
다른 방법을 찾아보니 '기본 파라미터 값'이라는 기능이 있다. 이걸 쓰면 오버로딩을 흉내 낼 수 있다고 한다. 이 기능을 사용하자.
testClass의 '기본 파라미터 값'으로 None을 넣어주자. 그리고 돌려보자.
class TestSuite:
def __init__(self, testClass=None):
self.tests = []
컴파일 에러는 잡았지만, 테스트는 계속 실패하고 있다.
> 5 run, 1 failed
테스트를 통과시키는 단순한 방법이 뭘까?
하드코딩으로 TestSuite를 생성하면 된다. 이전 테스트 코드에서 사용하던 코드를 그대로 붙여 넣기 하자.
조건문을 추가해서 testClass가 WasRun인 경우에만 실행되도록 만들자.
class TestSuite:
def __init__(self, testClass=None):
self.tests = []
if (testClass is WasRun):
self.add(WasRun("testMethod"))
self.add(WasRun("testBrokenMethod"))
그리고 돌려보면 성공한다.
> 5 run, 0 failed
3. 중복코드 제거하기: 구현 코드 설계하기
'가짜로 구현하기'를 마스터하려면 중복을 찾는 훈련을 해야 한다. TDD에서 중복은 두 가지 형태로 나타난다.
- '테스트 코드'와 '구현 코드' 사이의 중복
- '구현 코드' 와 '구현 코드' 사이의 중복
여기에선 구현 코드와 구현 코드 사이에 중복이 있다. 코드에서 'testMethod'라는 글자가 두 번 나온다.
TestSuite 클래스의 생성자에서 '문자열'로 'testMethod'가 사용되고, WasRun 클래스의 '메서드 이름'으로 'testMethod'가 사용된다. 중복이다.
이 중복만 없애면 이번 리팩토링이 끝난다.
당장 생각나는 해결책은 리플렉션을 쓰는 것이다. 리플렉션을 쓰면 WasRun의 메서드 리스트를 동적으로 찾을 수 있다. 이 구현으로 하드코딩 된 생성자를 없앨 수 있다.
하지만 이 방법은 단계가 너무 크다(구현하기까지 조금 오래 걸린다). 단계가 크면 중간에 실수할 가능성이 높아지고, 어디에서 실수를 했는지 바로 알 수 없다. 작은 단계로 중복을 조금씩 없애보자.
아래 코드에서 5번, 6번 라인을 보면 self.add() 메서드가 두 번 나온다. 문자열을 리스트에 넣고 for문으로 돌리면 self.add() 메서드를 한 줄로 만들 수 있다.
class TestSuite:
def __init__(self, testClass=None):
self.tests = []
if (testClass is WasRun):
self.add(WasRun("testMethod"))
self.add(WasRun("testBrokenMethod"))
우선 리스트를 하나 추가하자.
class TestSuite:
def __init__(self, testClass=None):
self.tests = []
if (testClass is WasRun):
names = ["testMethod", "testBrokenMethod"]
self.add(WasRun("testMethod"))
self.add(WasRun("testBrokenMethod"))
돌려보면 돌아간다. 아직 고친 부분이 없으니 당연하다. 이제 추가한 리스트를 이용해서 6번 라인을 수정하자.
class TestSuite:
def __init__(self, testClass=None):
self.tests = []
if (testClass is WasRun):
names = ["testMethod", "testBrokenMethod"]
self.add(WasRun(names[0]))
self.add(WasRun("testBrokenMethod"))
돌려보면 돌아간다. 7번 라인도 같은 방식으로 고치자.
class TestSuite:
def __init__(self, testClass=None):
self.tests = []
if (testClass is WasRun):
names = ["testMethod", "testBrokenMethod"]
self.add(WasRun(names[0]))
self.add(WasRun(names[1]))
돌아간다. names의 스펠링이 틀리지 않은 것 같다.
우리는 for문이 자동으로 하는 일을 하드코딩으로 구현했다. for문을 써서 하드코딩을 없애버리자.
class TestSuite:
def __init__(self, testClass=None):
self.tests = []
if (testClass is WasRun):
names = ["testMethod", "testBrokenMethod"]
for name in names:
self.add(WasRun(name))
돌아간다.
이제 리스트만 동적으로 얻을 수 있다면 'TestSuite'와 'WasRun'사이의 중복을 없앨 수 있다.
Java에서는 리플렉션을 쓰면 메서드 리스트를 알아낼 수 있다. 파이썬에 비슷한 기능이 있는지 찾아보자.
API문서를 찾아보면 dir()이라는 함수가 있다. 이 함수는 파라미터의 모든 멤버(필드, 메서드)를 리스트로 반환한다. '필드'리스트는 필요 없지만, 다른 함수가 없기 때문에 이걸 써야겠다. dir() 함수를 써서 names에 새 리스트를 넣어주자.
class TestSuite:
def __init__(self, testClass=None):
self.tests = []
if (testClass is WasRun):
names = ["testMethod", "testBrokenMethod"]
names = dir(WasRun)
for name in names:
self.add(WasRun(name))
돌려보면 실패한다.
실패하는 이유는 dir() 함수가 상속한 클래스의 멤버까지 찾아내기 때문이다.
코드에서 WasRun은 TestCase를 상속하고, TestCase는 object를 상속한다. 결국 dir(WasRun) 함수는 WasRun, TestCase, object의 모든 멤버를 반환한다. 테스트를 통과시키려면 필요 없는 멤버를 걸러내야 한다.
리스트에서 특정 원소만 뽑아내는 기능이 있으면 좋겠다. API문서를 찾아보면 filter()라는 함수가 있다. filter() 함수는 두 개의 파라미터를 받는다. 람다와 리스트이다. 람다에 원하는 조건식을 써서 filter() 함수에 전해주면, filter() 함수는 조건에 맞는 원소만 찾아서 새 리스트로 반환한다.
내가 원하는 원소는 이름이 'test'로 시작하는 메서드이다. fileter() 함수와 람다를 써서 고드를 고치자.
class TestSuite:
def __init__(self, testClass=None):
self.tests = []
if (testClass is WasRun):
names = ["testMethod", "testBrokenMethod"]
names = filter((lambda each: each.startswith("test")), dir(WasRun))
for name in names:
self.add(WasRun(name))
돌려보면 잘 돌아간다. 5번 라인은 이제 필요 없다. 지워버리자.
class TestSuite:
def __init__(self, testClass=None):
self.tests = []
if (testClass is WasRun):
names = filter((lambda each: each.startswith("test")), dir(WasRun))
for name in names:
self.add(WasRun(name))
잘 돌아간다.
다른 중복을 찾아보자. 5번 라인과 7번 라인을 보면 WasRun이 두 번 나온다. 이걸 없애는 가장 단순한 방법이 뭘까?
현재 조건문에서 testClass는 항상 WasRun이다. 조건식이 이것을 보장한다. testClass를 쓰면 중복을 없앨 수 있을 것 같다. 실험해 보자.
우선, 5번 라인의 WasRun을 testClass로 바꿔보자. 그리고 돌려보자.
class TestSuite:
def __init__(self, testClass=None):
self.tests = []
if (testClass is WasRun):
names = filter((lambda each: each.startswith("test")), dir(testClass))
for name in names:
self.add(WasRun(name))
잘 돌아간다. 다행이다. 7번 라인도 WasRun을 testClass로 바꾸자.
class TestSuite:
def __init__(self, testClass=None):
self.tests = []
if (testClass is WasRun):
names = filter((lambda each: each.startswith("test")), dir(testClass))
for name in names:
self.add(testClass(name))
잘 돌아간다. 이제 코드에 WasRun에 대한 내용이 없다. 충분히 추상화를 한 것 같다. 이 코드로 다른 TestCase의 TestSuite를 만들 수 있을 것 같다. 가능할까? 실험해 보자.
실행코드를 보면 '수동'으로 TestCaseTest의 TestSuite를 만드는 코드가 있다.
# 테스트 실행코드
suite = TestSuite()
suite.add(TestCaseTest("testTemplateMethod"))
suite.add(TestCaseTest("testResult"))
suite.add(TestCaseTest("testFailedResult"))
suite.add(TestCaseTest("testFailedResultFormatting"))
suite.add(TestCaseTest("testSuite"))
result = TestResult()
suite.run(result)
print result.summary()
위 코드를 고쳐서 '자동'으로 TestSuite를 만들자.
suite = TestSuite(TestCaseTest)
result = TestResult()
suite.run(result)
print result.summary()
그리고 돌려보자.
> 0 run, 0 failed
실패한다. 원하던 결과가 아니다. '0 run, 0 failed'가 아닌 '5 run, 0 failed'가 나와야 한다.
TestSuite생성자를 고쳐보자. 일단, 조건문을 추가해서 testClass 가 TestCaseTest 인 경우에 실행되도록 만들자.
class TestSuite:
def __init__(self, testClass=None):
self.tests = []
if (testClass is WasRun):
names = filter((lambda each: each.startswith("test")), dir(testClass))
for name in names:
self.add(testClass(name))
elif (testClass is TestCaseTest):
names = filter((lambda each: each.startswith("test")), dir(testClass))
for name in names:
self.add(testClass(name))
돌아간다. 두 조건문의 코드가 똑같다. 중복이다. 조건문을 지우고, 하나의 로직으로 만들자.
class TestSuite:
def __init__(self, testClass=None):
self.tests = []
names = filter((lambda each: each.startswith("test")), dir(testClass))
for name in names:
self.add(testClass(name))
돌아간다. 이제 '기본 파리미터 값'을 쓰는 코드가 없다. 지워보자.
class TestSuite:
def __init__(self, testClass):
self.tests = []
names = filter((lambda each: each.startswith("test")), dir(testClass))
for name in names:
self.add(testClass(name))
돌아간다. 코드 추상화가 끝났다. 모든 TestSuite를 자동으로 만들 수 있다.
추상화는 끝났지만, 리팩토링은 조금 더 해야 한다.
위 코드의 4, 5, 6번 라인은 서로 깊게 연관된 로직이다. 해당 라인을 addTests 라는 함수로 뽑아내자.
class TestSuite:
def __init__(self, testClass):
self.tests = []
self.addTests(testClass)
def addTests(self, testClass):
names = filter((lambda each: each.startswith("test")), dir(testClass))
for name in names:
self.add(testClass(name))
돌아간다. 7번 라인은 다른 곳 보다 복잡하다. 메서드로 뽑아내서 단순화해야겠다.
이 코드는 정적 메서드로 뽑아내자. 7번 라인은 인스턴스 멤버를 쓰지 않기 때문에 인스턴스 메서드나 정적 메서드로 뽑아낼 수 있다. 내 기준엔 정적 메서드가 더 단순하다.
class TestSuite:
def addTests(self, testClass):
names = TestSuite._findTestNames(testClass)
for name in names:
self.add(testClass(name))
@staticmethod
def _findTestNames(testClass):
return filter((lambda each: each.startswith("test")), dir(testClass))
돌아간다. 이제 복잡한 filter() 함수를 정리하자.
우선, 람다에 이름을 붙여주자.
class TestSuite:
@staticmethod
def _findTestNames(testClass):
find = (lambda each: each.startswith("test"))
return filter(find, dir(testClass))
돌아간다. 메서드 이름을 생각하면 each를 name으로 바꾸는 게 좋겠다.
class TestSuite:
@staticmethod
def _findTestNames(testClass):
find = (lambda name: name.startswith("test"))
return filter(find, dir(testClass))
돌아간다. dir(testClass)도 이름을 붙여서 균형을 맞추자.
class TestSuite:
@staticmethod
def _findTestNames(testClass):
find = (lambda name: name.startswith("test"))
allNames = dir(testClass)
return filter(find, allNames)
돌아간다. 됐다. 이제 addTests() 메서드로 되돌아가자.
class TestSuite:
def addTests(self, testClass):
names = TestSuite._findTestNames(testClass)
for name in names:
self.add(testClass(name))
3번 코드는 인라인 시키자. 그래도 코드 읽기는 불편하지 않을 것 같다.
class TestSuite:
def addTests(self, testClass):
for name in TestSuite._findTestNames(testClass):
self.add(testClass(name))
돌아간다. 모든 리팩토링이 끝났다. 새 기능을 추가했고, 잘 돌아가도록 구현했다. 그리고 올바른 자리로 옮겨줬다. 할 일을 끝냈으니 목록에서 지울 수 있다.
그런데 걱정거리가 하나 있다. 현재 코드는 test로 시작하는 메서드만 있다면 TestSuite를 만들 수 있다. 꼭 TestCase를 상속하지 않아도 TestSuite를 만들 수 있다. 이걸 막아야 하나? 아직 모르겠다. 일단 목록에 적어놓자. 나중에 xUnit을 어떻게 사용할지 정리되면 처리하자.
|
오늘 작업한 구현 코드는 아래와 같다.
class TestSuite:
def __init__(self, testClass):
self.tests = []
self.addTests(testClass)
def addTests(self, testClass):
for name in TestSuite._findTestNames(testClass):
self.add(testClass(name))
@staticmethod
def _findTestNames(testClass):
find = (lambda name: name.startswith("test"))
allNames = dir(testClass)
return filter(find, allNames)
마무리
사전 설계 | 설 계 -> 구 현 -> 테 스 트 |
T D D (점진적인 설계) |
테 스 트 -> 구 현 -> 리팩토링 (인터페이스 설계) (구현코드 설계) |
설계를 언제 해야 하나?
'설계하기'란 결과물의 구조를 결정하는 일을 말한다. 건축가에게 설계하기란 건물구조를 결정하는 일을 말하고, 작가에게 설계하기란 글의 구조를 결정하는 일을 말하고, 프로그래머에게 설계하기란 코드 구조를 결정하는 일을 말한다.
전통적으로 소프트웨어 설계는 건축 설계를 모방해 왔다. 하지만 건축에서 효과적인 방식은 소프트웨어에서는 통하지 않았다. 두 제품이 다른 세계의 제품이기 때문이다. 건물은 물리세계 제품이다. 물리세계 제품은 '구현하기 전'과 '구현한 후'의 설계 수정비용 차이가 크다. 예를 들어 화장실 크기를 넓히고 싶다 치자. 구현하기 전에는 거의 공짜로 고칠 수 있다. 지우개와 연필만 있으면 화장실 크기를 바꿀 수 있다. 반면 구현한 후에는 많은 돈을 써야 고칠 수 있다. 기존 화장실 벽을 부수고, 새로운 벽을 세워야 하기 때문에 돈이 많이 든다. 이런 이유로 물리세계 제품은 '구현하기 전에 설계'하는 전략을 사용한다.
가상세계 제품은 '구현한 후 설계'하는 전략을 사용한다. 글, 음악, 그림은 대표적인 가상세계 제품이다. 이 중에서 글을 쓰는 작가들의 작업방식을 살펴보자. 작가이자 글쓰기 선생인 윌리엄 진서는 《글쓰기 생각 쓰기》에서 "좋은 글쓰기의 핵심은 고쳐쓰기"라고 말한다. 또 "고쳐쓰기는 부끄러운 일이 아니다"라고 말한다. 대부분의 작가들은 고쳐쓰기를 적극적으로 활용한다. 작가들도 건축가들처럼 '구현하기 전에 설계'를 하긴 하지만, 완벽한 사전설계를 하지 않는다. 고쳐쓰기로 잘못된 설계를 바로잡을 수 있기 때문에 작가들은 최소한의 사전설계만 한다.
TDD는 작가들의 작업방식과 비슷하다. 일단 돌아가는 코드(초고)를 만들고 코드를 여러 번 리팩토링(고쳐쓰기)한다. TDD의 핵심은 설계시점의 변화에 있다. TDD는 '구현하기 전 설계'보다 '구현한 후 설계'를 적극적으로 이용한다.
'TDD' 카테고리의 다른 글
TDD를 잘하는 방법 (0) | 2024.09.08 |
---|---|
TDD로 학습 테스트 (라이브러리 사용법부터 오픈소스 기여까지) (5) | 2024.09.03 |
TDD로 추상화 로직 설계하기 (4) | 2024.09.02 |