Why Good Developers Write Bad Unit Tests

Michael Lynch

왜 실력 있는 개발자가 형편없는 단위 테스트를 만드는가

축하합니다! 마침내 코드를 너무나 많이 작성한 덕분에 해변에 집을 살 수 있게 되었습니다. 당신은 마천루로 세계적으로 유명한 건축가 Peter Keating을 고용합니다. 그는 해안 부지를 위한 근사한 계획을 가지고 있다고 장담합니다.

몇 달 뒤, 성대한 공개 현장에 도착합니다. 당신의 새집은 강철과 콘크리트, 반사 유리로 이루어진 위압적인 5층짜리 거대 구조물입니다. 회전문을 통과하자마자 화려한 대리석 바닥에 모래가 묻어납니다. 안에는 엘리베이터가 줄지어 선 벽 앞에 인포메이션 데스크가 있습니다. 위층으로 올라가 보니 안방과 게스트룸 세 개는 그저 나란히 붙은 사무용 큐비클 네 칸일 뿐입니다.

해변에서 마천루 모형을 선보이는 건축가

전문 건축가인 Peter Keating은 당신이 왜 실망하는지 이해하지 못합니다. 그는 방어적으로 말합니다. “저는 모든 모범 사례를 따랐습니다.” 구조적 안정성이 매우 중요하기 때문에 벽 두께는 90센티미터나 됩니다. 그러니 당신의 집은 옆집의 바람 잘 통하고 빛이 가득한 집들보다 훌륭합니다. 커다란 오션뷰 창문은 없을지 몰라도, Keating은 그런 창문은 모범 사례가 아니라고 말합니다. 에너지 효율을 떨어뜨리고 사무실 직원들의 집중을 방해한다는 것입니다.

너무나 자주 소프트웨어 개발자들은 똑같이 잘못된 사고방식으로 단위 테스트에 접근합니다. 테스트에 적합한지 따져보지도 않고 프로덕션 코드에서 배운 ‘규칙’들을 기계적으로 적용합니다. 그 결과 해변에 마천루를 짓게 됩니다.

테스트 코드는 다른 코드와 다릅니다

프로덕션 애플리케이션은 보통 수천에서 수백만 줄의 코드로 이루어집니다. 사람이 한 번에 전체를 머릿속에 담기에는 너무 큽니다. 이 복잡성을 관리하기 위해 언어 설계자들은 함수나 클래스 계층 구조 같은 메커니즘을 제공해 개발자가 추상화를 통해 사고할 수 있도록 했습니다.

좋은 프로덕션 코드는 캡슐화를 달성합니다. 독자가 필요에 따라 세부 사항으로 내려가거나 더 높은 추상 수준으로 올라가며 대규모 시스템을 쉽게 탐색할 수 있게 합니다.

테스트 코드는 전혀 다른 존재입니다. 좋은 단위 테스트는 대개 개발자가 모든 로직을 한 번에 파악할 수 있을 만큼 작습니다. 테스트 코드에 추상화 계층을 더하면 복잡성만 높아집니다. 테스트는 진단 도구이므로 가능한 한 단순하고 명확해야 합니다.

좋은 프로덕션 코드는 잘 팩터링되어 있고, 좋은 테스트 코드는 명확합니다.

자 클로즈업 사진

자를 생각해 보십시오. 자는 단순하고 해석하기 쉽기 때문에 수백 년 동안 같은 형태로 존재해 왔습니다. 제가 “추상 자 단위”로 길이를 재는 새로운 자를 발명했다고 가정해 보겠습니다. “자 단위”를 인치나 센티미터로 변환하려면 별도의 변환표를 사용해야 합니다.

그런 자를 목수에게 건넨다면 목수는 그 자로 제 얼굴을 후려칠 것입니다. 명확하고 모호함 없는 정보를 주는 도구에 굳이 추상화 계층을 더하는 것은 터무니없는 일입니다.

좋은 테스트 코드도 마찬가지입니다. 독자가 여러 단계의 간접 참조를 거치도록 강요하지 않고 명확한 결과를 내놓아야 합니다. 개발자들은 이것이 프로덕션 코드를 작성할 때 배운 방식과 다르기 때문에 종종 이 점을 놓칩니다.

실력 있는 개발자의 나쁜 테스트

저는 재능 있는 개발자들이 다음과 같은 테스트를 작성하는 경우를 자주 봅니다.

def test_initial_score(self):
  initial_score = self.account_manager.get_score(username='joe123')
  self.assertEqual(150.0, initial_score)

이 테스트는 무엇을 할까요? 이름이 joe123인 사용자의 “점수”를 가져와 그 점수가 150인지 검증합니다. 이 시점에서 다음과 같은 의문이 들어야 합니다.

  1. joe123 계정은 어디에서 왔습니까?
  2. joe123의 점수가 150일 것이라고 예상합니까?

아마도 답은 각 테스트 함수를 실행하기 전에 테스트 프레임워크가 호출하는 setUp 메서드에 있을 것입니다.

def setUp(self):
  database = MockDatabase()
  database.add_row({
      'username': 'joe123',
      'score': 150.0
    })
  self.account_manager = AccountManager(database)

좋습니다. setUp 메서드가 점수가 150인 joe123 사용자를 만들었으니, test_initial_score가 왜 그 값을 기대했는지 설명됩니다. 이제 모든 게 잘 된 것 아닐까요?

아닙니다. 이것은 나쁜 테스트입니다.

독자를 테스트 함수 안에 머물게 하십시오

테스트를 작성할 때는 다음에 테스트 실패를 마주할 개발자를 생각해 보십시오. 그 사람은 전체 테스트 스위트를 읽고 싶어 하지 않으며, 테스트 유틸리티의 상속 트리를 샅샅이 읽고 싶어 하지도 않습니다.

테스트가 깨지면 독자는 테스트 함수를 위에서 아래로 직선적으로 읽는 것만으로 문제를 진단할 수 있어야 합니다. 테스트 밖으로 나가 보조 코드를 읽어야 한다면 그 테스트는 제 역할을 하지 못한 것입니다.

이런 관점에서 앞선 테스트를 다시 작성해 보겠습니다.

def test_initial_score(self):
  database = MockDatabase()
  database.add_row({
      'username': 'joe123',
      'score': 150.0
    })
  account_manager = AccountManager(database)

  initial_score = account_manager.get_score(username='joe123')

  self.assertEqual(150.0, initial_score)

제가 한 일은 setUp 메서드에 있던 코드를 인라인으로 옮긴 것뿐이지만, 그 효과는 엄청납니다. 이제 독자에게 필요한 모든 것이 바로 테스트 안에 있습니다. 또한 준비, 실행, 검증 구조를 따르므로 테스트의 각 단계가 명확하게 구분됩니다.

독자는 다른 코드를 읽지 않고도 당신의 테스트를 이해할 수 있어야 합니다.

감히 DRY를 위반하십시오

셋업 코드를 인라인으로 옮기는 것은 테스트 하나에서는 괜찮지만, 테스트가 많다면 어떻게 될까요? 매번 그 코드를 중복하게 되지 않을까요? 마음의 준비를 하십시오. 지금부터 복사 후 붙여넣기 프로그래밍을 옹호하려 합니다.

같은 클래스에 대한 또 다른 테스트입니다.

def test_increase_score(self):
  database = MockDatabase()                  # <
  database.add_row({                         # <
      'username': 'joe123',                  # <--- Copy/pasted from
      'score': 150.0                         # <--- previous test
    })                                       # <
  account_manager = AccountManager(database) # <

  account_manager.adjust_score(username='joe123',
                         adjustment=25.0)

  self.assertEqual(175.0,
             account_manager.get_score(username='joe123'))

DRY 원칙(“반복하지 마라”)을 엄격하게 따르는 사람에게는 위 코드가 끔찍하게 보일 것입니다. 저는 대놓고 반복하고 있습니다. 이전 테스트에서 여섯 줄을 그대로 복사했습니다. 더 나쁜 것은, 저는 이렇게 DRY를 위반한 테스트가 중복 없는 테스트보다 낫다고 주장한다는 점입니다. 어떻게 그럴 수 있을까요?

코드를 중복하지 않고도 명확한 테스트를 만들 수 있다면 그게 가장 좋습니다. 하지만 중복을 없애는 것은 목적이 아니라 수단이라는 점을 기억하십시오. 최종 목표는 명확하고 단순한 테스트입니다.

테스트에 DRY를 맹목적으로 적용하기 전에 테스트가 실패했을 때 무엇이 문제를 가장 명확하게 드러낼지 생각해 보십시오. 리팩터링은 중복을 줄일 수 있지만, 복잡성을 높이고 문제가 생겼을 때 정보를 흐리게 만들 수도 있습니다.

단순함에 도움이 된다면 중복을 받아들이십시오.

헬퍼 메서드를 추가하기 전에 두 번 생각하십시오

모든 테스트에서 여섯 줄을 복사해 붙여넣는 정도는 감수할 수 있을지 몰라도, AccountManager에 더 많은 셋업 코드가 필요하다면 어떨까요?

def test_increase_score(self):
  # vvvvvvvvvvvvvvvvvvvvv Beginning of boilerplate code vvvvvvvvvvvvvvvvvvvvv
  user_database = MockDatabase()
  user_database.add_row({
      'username': 'joe123',
      'score': 150.0
    })
  privilege_database = MockDatabase()
  privilege_database.add_row({
      'privilege': 'upvote',
      'minimum_score': 200.0
    })
  privilege_manager = PrivilegeManager(privilege_database)
  url_downloader = UrlDownloader()
  account_manager = AccountManager(user_database,
                                   privilege_manager,
                                   url_downloader)
  # ^^^^^^^^^^^^^^^^^^^^^ End of boilerplate code ^^^^^^^^^^^^^^^^^^^^^^^^^^^

  account_manager.adjust_score(username='joe123',
                         adjustment=25.0)

  self.assertEqual(175.0,
             account_manager.get_score(username='joe123'))

AccountManager 인스턴스를 만들고 테스트를 시작하는 데만 15줄이 듭니다. 이 정도면 테스트하려는 동작 자체보다 상용구 코드가 너무 많아 방해가 됩니다.

당연히 흥미 없는 코드를 테스트 헬퍼 메서드에 위임하고 싶겠지만, 그 전에 더 중요한 질문을 먼저 던져야 합니다. 왜 시스템이 테스트하기가 이렇게 어려울까요?

과도한 상용구 코드는 종종 취약한 아키텍처의 징후입니다. 예를 들어 위 테스트는 몇 가지 디자인 스멜을 드러냅니다.

account_manager = AccountManager(user_database,
                                 privilege_manager,
                                 url_downloader)

AccountManageruser_database에 직접 접근하면서도 다음 파라미터로는 privilege_database의 래퍼인 privilege_manager를 받습니다. 왜 서로 다른 추상화 수준에서 동작하고 있는 것일까요? 그리고 “URL 다운로더”는 도대체 무엇일까요? 다른 두 파라미터와는 개념적으로 분명히 거리가 멀어 보입니다.

이 경우 AccountManager를 리팩터링하면 근본적인 문제가 해결되지만, 헬퍼 메서드를 추가하는 것은 증상을 덮어두는 것에 불과합니다.

테스트 헬퍼 메서드를 작성하고 싶을 때는 대신 프로덕션 코드를 리팩터링해 보십시오.

헬퍼 메서드가 필요하다면 책임감 있게 작성하십시오

테스트 용이성을 위해 프로덕션 클래스를 뜯어고칠 자유가 항상 있는 것은 아닙니다. 때로는 헬퍼 메서드가 유일한 선택지이므로, 필요하다면 잘 작성해야 합니다.

효과적인 헬퍼 메서드는 “독자를 테스트 함수 안에 머물게 하라”는 원칙을 뒷받침합니다. 독자의 이해를 해치지 않는다면 상용구 코드를 헬퍼 함수로 추출해도 괜찮습니다.

구체적으로 헬퍼 메서드는 다음을 해서는 안 됩니다.

  • 중요한 값을 숨기기
  • 테스트 대상 객체와 상호작용하기

다음은 이런 지침을 위반한 헬퍼 메서드의 예입니다.

def add_dummy_account(self): # <- Helper method
  dummy_account = Account(username='joe123',
                          name='Joe Bloggs',
                          email='[email protected]',
                          score=150.0)
  # BAD: Helper method hides a call to the object under test
  self.account_manager.add_account(dummy_account)

def test_increase_score(self):
  self.account_manager = AccountManager()
  self.add_dummy_account()

  account_manager.adjust_score(username='joe123',
                               adjustment=25.0)

  self.assertEqual(175.0, # BAD: Relies on value set in helper method
                   account_manager.get_score(username='joe123'))

독자는 헬퍼 메서드에 숨겨진 150이라는 값을 찾아내지 않고서는 최종 점수가 왜 175여야 하는지 이해할 수 없습니다. 헬퍼는 또한 add_account 호출을 숨겨 account_manager의 동작을 가리고, 모든 상호작용을 테스트 함수 안에 두지 못하게 합니다.

다음은 이 문제들을 해결한 버전입니다.

def make_dummy_account(self, username, score):
  return Account(username=username,
                 name='Dummy User',         # <- OK: Buries values but they're
                 email='[email protected]', # <-     irrelevant to the test
                 score=score)

def test_increase_score(self):
  account_manager = AccountManager()
  account_manager.add_account(
    make_dummy_account(
      username='joe123',  # <- GOOD: Relevant values stay
      score=150.0))       # <-       in the test

  account_manager.adjust_score(username='joe123',
                               adjustment=25.0)

  self.assertEqual(175.0,
                   account_manager.get_score(username='joe123'))

여전히 헬퍼 메서드에 값을 숨기고 있지만, 그 값들은 테스트와 무관합니다. 또한 add_account 호출을 다시 테스트 안으로 가져와 독자가 account_manager에 일어나는 모든 일을 쉽게 추적할 수 있게 했습니다.

헬퍼 메서드에는 독자가 테스트를 이해하는 데 필요한 어떤 정보도 숨기지 마십시오.

테스트 이름은 길게 마음껏 쓰십시오

다음 중 프로덕션 코드에서 어떤 함수 이름을 더 선호하시겠습니까?

  • userExistsAndTheirAccountIsInGoodStandingWithAllBillsPaid
  • isAccountActive

전자가 더 많은 정보를 전달하지만 57자나 되는 이름을 감수해야 합니다. 대부분의 개발자는 isAccountActive처럼 간결하면서도 거의 비슷한 의미를 전달하는 이름을 위해 약간의 정확성을 포기하려 합니다(단, 자바 개발자에게는 두 이름 모두 지나치게 간결하게 느껴질 것입니다).

테스트 함수에서는 결정적인 요소 하나가 이 방정식을 바꿉니다. 테스트 함수는 호출하는 코드를 절대 작성하지 않는다는 점입니다. 개발자는 테스트 이름을 딱 한 번, 함수 시그니처에서만 입력합니다. 그렇기 때문에 간결함은 여전히 중요하지만 프로덕션 코드에서만큼 중요하지는 않습니다.

테스트가 깨질 때마다 가장 먼저 보이는 것이 테스트 이름이므로, 테스트 이름은 가능한 한 많은 정보를 전달해야 합니다. 예를 들어 다음과 같은 프로덕션 클래스가 있다고 해 보겠습니다.

class Tokenizer {
 public:
  Tokenizer(std::unique_ptr<TextStream> stream);
  std::unique_ptr<Token> NextToken();
 private:
  std::unique_ptr<TextStream> stream_;
};

테스트 스위트를 실행했는데 출력에 다음 줄이 나타났다고 가정해 보겠습니다.

[  FAILED  ] TokenizerTests.TestNextToken (6 ms)

무엇 때문에 테스트가 실패했는지 알 수 있을까요? 아마 아닐 것입니다.

TestNextToken이 실패했다는 것은 NextToken() 메서드를 망쳤다는 뜻이지만, 공개 메서드가 하나뿐인 클래스에서는 사실상 아무 의미도 없습니다. 실패 원인을 진단하려면 테스트 구현을 직접 읽어야 합니다.

대신 다음과 같은 결과가 보였다면 어떨까요?

[  FAILED  ] TokenizerTests.ReturnsNullptrWhenStreamIsEmpty (6 ms)

ReturnsNullptrWhenStreamIsEmpty라는 함수는 다른 맥락에서는 지나치게 장황하게 느껴지겠지만, 테스트 이름으로는 훌륭합니다. 이 테스트가 깨지는 것을 본다면 클래스가 빈 데이터 스트림을 잘못 처리하고 있다는 것을 즉시 알 수 있습니다. 테스트 구현을 읽지 않고도 버그를 고칠 수 있을 가능성이 높습니다. 그것이 바로 좋은 테스트 이름의 증거입니다.

실패의 원인을 이름만 보고도 진단할 수 있도록 테스트 이름을 지으십시오.

매직 넘버를 받아들이십시오

“매직 넘버를 사용하지 마라.”

프로그래밍 세계의 “낯선 사람과 대화하지 마라” 같은 격언입니다. 많은 숙련된 개발자들은 이 교훈을 너무 깊이 내면화한 나머지 매직 넘버가 코드를 더 좋게 만들 수 있는 경우를 전혀 고려하지 않습니다.

다시 짚어보면, “매직 넘버”란 코드에서 그것이 무엇을 나타내는지에 대한 정보 없이 등장하는 숫자 값이나 문자열을 말합니다. 예는 다음과 같습니다.

calculate_pay(80) # <-- Magic number

프로그래머들은 프로덕션 코드의 매직 넘버가 매우 나쁜 것이라는 데 동의하므로, 다음과 같이 이름 있는 상수로 대체합니다.

HOURS_PER_WEEK = 40
WEEKS_PER_PAY_PERIOD = 2
calculate_pay(hours=HOURS_PER_WEEK * WEEKS_PER_PAY_PERIOD)

안타깝게도 매직 넘버가 테스트 코드를 약화시킨다는 오해가 있지만, 실제로는 정반대입니다.

다음 테스트를 살펴보십시오.

def test_add_hours(self):
  TEST_STARTING_HOURS = 72.0
  TEST_HOURS_INCREASE = 8.0
  hours_tracker = BillableHoursTracker(initial_hours=TEST_STARTING_HOURS)
  hours_tracker.add_hours(TEST_HOURS_INCREASE)
  expected_billable_hours = TEST_STARTING_HOURS + TEST_HOURS_INCREASE
  self.assertEqual(expected_billable_hours, hours_tracker.billable_hours())

매직 넘버는 무조건 나쁘다고 믿는다면 위 테스트는 올바르게 보일 것입니다. 72.08.0에 이름 있는 상수가 있으니 아무도 매직 넘버라고 비난할 수 없습니다.

하지만 잠시 종교적 신념을 내려놓고 금지된 열매인 매직 넘버를 맛본 버전을 보십시오.

def test_add_hours(self):
  hours_tracker = BillableHoursTracker(initial_hours=72.0)
  hours_tracker.add_hours(8.0)
  self.assertEqual(80.0, hours_tracker.billable_hours())

더 단순하고 코드 줄 수도 절반입니다. 그리고 더 명확합니다. 독자가 함수 안에서 상수 이름을 따라가며 왔다 갔다 할 필요가 없습니다.

테스트 코드에서 개발자가 상수를 정의하는 것을 보면, 대개 DRY를 잘못 고수해서이거나 매직 넘버 사용을 두려워하기 때문입니다. 하지만 테스트에서 상수를 선언하는 것이 필요한 경우는 드물며, 그렇게 하면 오히려 이해하기 어려워집니다.

테스트 코드에서는 이름 있는 상수보다 매직 넘버를 선호하십시오.
참고: 단위 테스트가 프로덕션 코드에서 노출한 상수를 참조하는 것은 괜찮습니다. 다만 테스트 자체에서 상수를 정의해서는 안 됩니다.

결론

훌륭한 테스트를 작성하려면 개발자는 테스트 코드의 목표에 맞춰 엔지니어링 결정을 내려야 합니다. 가장 중요한 것은 테스트가 추상화를 최소화하면서 단순함을 극대화해야 한다는 점입니다. 좋은 테스트는 독자가 테스트 함수를 벗어나지 않고도 의도된 동작을 이해하고 문제를 진단할 수 있게 합니다.


표지 그림: Loraine Yow

원문은 Michael Lynch님이 에 게재했습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.