Why Good Developers Write Bad Unit Tests

Michael Lynch

왜 훌륭한 개발자는 나쁜 단위 테스트를 작성하는가

원문은 Michael Lynch님이 에 게재했습니다. 이 블로그 구독하기

축하합니다! 마침내 해변에 별장을 살 수 있을 만큼 많은 코드를 작성했습니다. 당신은 마천루로 세계적으로 유명한 건축가 피터 키팅을 고용합니다. 그는 해변 부지에 대한 훌륭한 계획을 가지고 있다고 장담합니다.

몇 달 뒤, 당신은 성대한 공개 행사에 도착합니다. 새로 지은 집은 강철과 콘크리트, 반사 유리로 이루어진 위압적인 5층짜리 거대 건축물입니다. 회전문을 지나자 모래가 화려한 대리석 바닥에 묻어납니다. 안에는 엘리베이터가 줄지어 선 뒤편에 안내 데스크가 있고, 위층에 있는 안방과 세 개의 게스트룸은 그저 네 개의 붙어 있는 사무용 큐비클에 불과합니다.

해변에 마천루를 제시하는 건축가

전문가 건축가인 피터 키팅은 당신이 왜 실망했는지 이해하지 못합니다. 그는 방어적으로 말합니다. “저는 모든 모범 사례를 다 따랐습니다.” 벽을 90센티미터 두께로 만든 건 구조적 무결성이 매우 중요하기 때문입니다. 그래서 당신의 집은 옆에 있는 바람이 잘 통하고 채광이 좋은 집들보다 더 우수합니다. 커다란 바다 전망 창문은 없을지 몰라도, 키팅은 그런 창문이 모범 사례가 아니라고 말합니다. 에너지 효율을 떨어뜨리고 사무실 직원들의 집중을 방해한다는 것입니다.

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

테스트 코드는 다른 코드와 다르다

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

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

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

좋은 프로덕션 코드는 잘 분해되어 있고, 좋은 테스트 코드는 명확하다.

자의 클로즈업 사진

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

그런 자를 목수에게 준다면 목수는 그 자로 제 얼굴을 때릴 겁니다. 명확하고 모호함 없는 정보를 주는 도구에 추상화 계층을 추가하는 건 터무니없는 일이니까요.

좋은 테스트 코드도 마찬가지입니다. 독자가 여러 단계의 간접 참조를 거치지 않고도 명확한 결과를 얻을 수 있어야 합니다. 개발자들은 프로덕션 코드를 작성하던 방식과 다르다는 이유로 이를 자주 잊어버립니다.

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

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

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_managerprivilege_database를 감싼 래퍼입니다. 왜 두 가지 다른 추상화 수준에서 동작하고 있을까요? 그리고 “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

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

댓글