테스트 주도 개발을 개괄적으로 설명해요.
테스트 주도 개발(TDD)은 코드로 프로그램 설계를 구현해 나가는 과정을 테스트가 이끌도록, 즉 테스트를 먼저 작성하는 프로그래밍 방식이에요.
코딩을 시작하기 전에 하나 이상의 테스트(특히 단위 테스트)를 작성해요. 이 테스트는 프로그램 동작의 한 측면을 다루기 위한 것으로, 하나의 함수나 메서드에 집중할 수도 있어요. 테스트를 작성하는 것은 프로그램 요구 사항과 전체 구조를 구체적인 구현 설계로 바꾸는 방법이에요. 테스트를 실행하면 아직 코드를 구현하지 않았기 때문에 실패해야 해요. 그다음 코드를 구현하고 테스트를 다시 실행해요. 테스트가 통과하면 그 동작의 구현이 끝난 것이거나, 아니면 아직 작성해야 할 테스트가 더 남아 있을 수 있어요. 테스트가 통과하지 않으면 코드를 디버깅하고 테스트를 다시 실행해요. 테스트와 코딩의 주기는 필요한 모든 테스트가 통과할 때까지 반복돼요. 그러면 그 동작에 대한 구현은 일단 끝난 거예요.
리팩터링은 설계를 개선하기 위해 코드를 다시 작성하는 것이에요. 단순히 버그를 고치려고 코드를 다시 작성하는 것과는 달라요. 때로는 테스트를 통과시키기 위해 코드를 수정하는 일을 "리팩터링"이라고 부르기도 해요. 코드를 수정하는 것이 테스트를 통과하기 위한 방법으로 설계를 개선하는 것을 포함할 수도 있지만, 단순히 디버깅하는 것이 반드시 코드의 _설계_를 개선하는 것은 아니고, 따라서 반드시 _리팩터링_인 것도 아니에요.
다음은 리팩터링 없이 디버깅하는 예예요:
# A function intended to return x added to y.
# x and y are bad parameter names, but we ignore that for now.
def add(x, y):
# used multiply operator by mistake. It fails the tests.
return x * y
# Function corrected. It passes the tests. It has been debugged, but not refactored.
def add(x, y):
return x + y
다음은 리팩터링한 뒤 디버깅하는 예예요:
# Function name and parameter names are modified to something more meaningful. This is refactoring.
def lot_inventory(old_cars, new_cars):
# Introduced multiply operator by mistake. It fails the tests. This is why we test.
return old_cars * new_cars
# Function corrected. It passes the tests. This is debugging.
def lot_inventory(old_cars, new_cars):
return old_cars + new_cars
Exercism의 Python 트랙은 연습 문제에서 TDD 방법론을 활용해요. 단위 테스트는 이미 작성되어 있어요. 학생은 풀이가 통과하려면 무엇이 필요한지 더 자세히 이해하기 위해 테스트를 살펴볼 수 있어요. 학생에게 풀이 스텁이 제공될 수도 있어요.
Python 풀이에서 하나 이상의 테스트가 실패하면, 해당 과제에는 초록색 배경이 표시되지 않아요. 처음 실패한 과제 영역이 펼쳐지고, 그 머리글은 대략 이런 모습이에요
Task 1 Extract coordinates -
마이너스 기호를 클릭하면 과제가 접혀서 다른 과제를 살펴볼 수 있지만, 지금은 이 과제를 계속 볼게요.
그 아래에는 펼쳐진 테스트 영역이 이런 식으로 표시될 거예요
Test 1 ⌄
FAILED TisburyTreasure > get coordinate
여기서 Tisbury Treasure는 연습 문제를, get_coordinate는 실패한 함수나 메서드를 나타내요.
Test 1은 보통 테스트를 준비하기 위한 코드 섹션이 있는 일종의 템플릿이에요.
실패한 특정 테스트에 대한 정보는 들어 있지 않아요.
아래쪽에 이런 내용이 적혀 있을 거예요
One or more variations of this test failed. Details can be found under each [variant#].
⌄를 클릭하면 테스트가 접혀요.
그 아래에는 접힌 테스트가 이런 식으로 표시돼요:
Test 2 >
FAILED TisburyTreasure > get coordinate [variation #1] (item=
("Scrimshaw Whale's Tooth", '2A'), result='2A')
어떻게 보이는지는 오른쪽 창의 너비 설정에 따라 달라져요.
>를 클릭하면 테스트가 펼쳐져요.
입력 데이터와 예상 결과 데이터는 아마 코드 섹션에 표시될 거예요.
이 데이터는 이 과제의 모든 테스트에 대한 것일 수도 있어요.
맨 아래 Test Failure 섹션에는 이 테스트가 실패한 구체적인 이유가 있어요.
이런 식으로 보일 수 있어요:
AssertionError: ['2A'] != '2A'
이 경우에는 반환된 값 ['2A']가 예상 값 '2A'와 같지 않다는 것을 나타내요.
get_coordinate의 코드를 보면 이렇게 구현되어 있어요
def get_coordinate(record):
return [record[1]]
리스트 대괄호를 없애고(예: return record[1]) 테스트를 다시 실행하면, Task 1의 테스트가 통과할 거예요.
하나 이상의 과제가 계속 실패한다면, 모든 테스트가 통과할 때까지 각 과제에 대해 위 과정을 반복해요.
때로는 예상 데이터와 반환된 데이터가 너무 커서 Test Failure 섹션에 모두 담을 수 없을 때도 있어요.
이런 식으로 보일 수 있어요:
AssertionError: '("Sc[67 chars]\')\n\n(\'Brass Spyglass\', \'Abandoned Lighth[952 chars]')\n' != '("Sc[67 chars]\')\n(\'Brass Spyglass\', \'Abandoned Lighthou[928 chars]')\n'
Diff is 970 characters long. Set self.maxDiff to None to see it.
그래도 문제가 무엇인지 알아볼 만큼의 데이터는 있을 수 있어요.
위의 경우에는 줄 바꿈이 두 개 반환되었는데(예: \n\n(\'Brass Spyglass), 하나만 예상되었어요(예: \n(\'Brass Spyglass).
축하해요! 모든 테스트를 통과했어요. 다음은 무엇을 할까요? 풀이를 바로 공개할 수 있어요. 또는 이제 코드가 동작하니, 어떤 이유로든 리팩터링하고 싶다면 코드를 수정하고 새로운 이터레이션을 제출할 수 있어요. 코드가 더 나아질 수 있다고 생각하지만 어떻게 해야 할지 모르겠다면, 그 풀이에 대해 멘토링을 요청할 수 있어요. 멘토가 가능하다면, 풀이에 대한 다른 접근 방법에 관한 아이디어를 가지고 연락할 수도 있어요. 풀이를 공개할 때 댓글을 허용할 수 있고, 다른 학생들이 댓글을 남기거나 질문할 기회를 가질 수 있어요.
"조기 최적화는 모든 악의 근원이다"(토니 호어와 도널드 크누스 모두에게 귀속되는 말이에요)라는 말이 있지만, 풀이가 동작하더라도 그 성능을 개선하고 싶어지는 때가 오기 마련이에요.
그런 때 중 하나는 풀이가 일부 테스트는 통과하지만 다른 테스트에서는 시간 초과가 날 때예요.
코드 한 조각이 정확히 얼마나 시간이 걸리는지 아는 것이 도움이 될 수 있어요.
timeit 모듈을 사용하면 코드 실행 시간을 아주 짧은 단위까지 잴 수 있어요.
timeit 함수는 최대 다섯 개의 인자를 받을 수 있어요: timeit.timeit(stmt='pass', setup='pass', timer=<default timer>, number=1000000, globals=None).
stmt 매개변수는 실제로 실행하고 시간을 잴 코드를 정의해요.
number 매개변수는 stmt 코드가 몇 번 실행될지 결정해요.
setup 매개변수는 stmt 코드를 실행하기 위한 준비로 딱 한 번 실행되는 코드를 정의해요.
setup 코드가 실행되는 시간은 전체 시간에 포함돼요.
stmt 코드가 더 많이 반복 실행될수록, 반복당 setup 시간이 차지하는 비중은 줄어들어요.
timer 매개변수는 기본값과 다른 Timer를 전달할 수 있게 해줘요.
timer 매개변수의 기본 인자는 perf_counter이며, 대부분의 경우에 충분해요.
number 매개변수의 기본 인자는 1_000_000이에요.
globals 매개변수는 코드를 실행할 네임스페이스를 지정해요.
globals 매개변수의 기본 인자는 None이에요.
다음은 문장에 모든 영어 모음이 들어 있는지 판단하는 데 얼마나 걸리는지 확인하기 위해 timeit을 사용하는 예예요:
import timeit
# run one million times
loops = 1_000_000
# first positional argument is for stmt
# second positional argument is for setup
# third (named) argument is for number
print(timeit.timeit("""has_all_vowels('Another piggy digs up the truffles.')""",
"""
VOWELS = "AEIOU"
def has_all_vowels(sentence):
return all(letter in sentence.casefold() for letter in VOWELS)
""", number=loops) / loops)
코드를 백만 번 실행했더니 호출당 평균 4.965089999896008e-07초(호출당 약 497나노초)가 걸렸어요.
다음 예는 리스트 컴프리헨션에서 casefold 호출을 빼내면 시간이 절약되는지 확인하기 위한 것이에요:
import timeit
loops = 1_000_000
print(timeit.timeit("""has_all_vowels('Another piggy digs up the truffles.')""",
"""
VOWELS = "AEIOU"
def has_all_vowels(sentence):
sentence = sentence.casefold()
return all(letter in sentence for letter in VOWELS)
""", number=loops) / loops)
코드를 백만 번 실행했더니 호출당 평균 4.923898000270128e-07초(호출당 약 492나노초)가 걸렸어요.
그래서 리스트 컴프리헨션에서 casefold를 빼낸 덕분에 호출당 약 5나노초, 백만 번 호출 기준으로 총 약 5밀리초를 절약했어요.
cProfile로도 코드를 프로파일링할 수 있지만, 밀리초 단위까지만 측정하므로 그만큼 세밀하지는 않아요.