Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

내 테스트 커버리지는 어떻게 이 지경이 됐을까?

코드를 작성하는 것은 그에 대한 테스트를 작성하는 것보다 훨씬 쉽게 느껴집니다. 게다가 한 줄짜리 메서드에 정말로 테스트가 필요할까요? 너무 사소하잖아요. 테스트를 추가하면 개발 시간이 두세 배로 늘어나고, 다음에 코드를 수정할 때마다 테스트도 함께 고쳐야 합니다. 특히 예상 시간이 얼마 남지 않았다면 그런 작업은 완전한 낭비처럼 보입니다.

그러나 어느새 코드의 테스트 커버리지는 20%밖에 되지 않고, 코드를 조금이라도 수정하는 일이 카드 탑의 중간층을 무너뜨리지 않고 교체하는 것처럼 두려운 일이 되어 버립니다. 어딘가에서 문제가 생긴 것입니다. 당시에는 옳아 보였던 결정들이었음에도, 결과적으로 유지보수가 전혀 불가능한 코드베이스를 갖게 된 것이죠.

어떻게 이렇게 된 걸까요? 원래 테스트는 안전망 역할을 하며 자신 있게 리팩토링할 수 있게 해주어야 했습니다. 테스트는 코드를 더 좋게 만들어 주는 도구여야 했죠. 그런데 현실은 더 이상 이해할 수 없는 코드에 대한 낮은 테스트 커버리지와, 오히려 코드 변경을 더 어렵게 만드는 테스트들뿐입니다.

이것은 실력 부족이 아닙니다. 최고의 개발자들에게도 일어나는 일이며, 프로세스의 실패입니다. 새 기능을 작성하는 방식을 몇 가지만 바꾸면, 테스트가 속도를 늦추지 않으면서도 코드를 보호할 수 있습니다. 테스트는 코드를 더 이해하기 쉽고 유연하게 만들어 줄 수 있습니다. 모든 경로가 테스트되어 있다는 확신 속에서 자신 있게 코드를 수정할 수 있게 되는 것이죠.

결정해야 할 필요조차 없어야 합니다

키보드 앞에 앉아 어떤 코드에 테스트가 필요한지 판단하려고 한다면, 이미 잘못된 길에 들어선 것입니다. 항상 기본값은 "테스트하라!"여야 합니다. 아무리 사소해서 테스트가 필요 없어 보여도 테스트를 작성하세요.

작성하기 사소한 코드라면 테스트하기도 쉬울 것입니다. 그리고 복잡한 코드는 막 작성한 직후만큼 사소해 보일 때는 없습니다. 6개월 후에도 여전히 사소해 보일 거라고 어떻게 장담할 수 있겠습니까?

하지만 과도한 테스팅은 피해야 합니다

거대한 테스트 스위트 자체가 문제가 될 수도 있습니다. 실행하는 데 20분이 걸리는 테스트는 사실상 테스트가 없는 것과 마찬가지입니다. 매번 실행하지 않으니까요. (실행하겠다고 말하겠지만, 저는 압니다. 그러지 않으실 거라는 걸.) 더 나쁜 것은 깨지기 쉬운 테스트가 너무 많으면 리팩토링이 예전보다 더 고통스러워져서 아예 리팩토링을 포기하게 된다는 점입니다. 그러다 보면 소설 한 권 분량만 한 긴 메서드들이 양산됩니다.

앞서 한 말과 모순되는 걸까요? 꼭 그렇지 않습니다. 테스트는 항상 코드의 구현(implementation)이 아니라 인터페이스(interface)에 집중해야 합니다. 예를 들어 보겠습니다:

class Cart
  def initialize(item_params)
    @line_items = Array(item_params).map {|item| LineItem.new(item[:name], item[:price])}
  end

  def total
    @line_items.sum(&:price)
  end
end

Cart 클래스와 LineItem 클래스 둘 다 테스트해야 할 것 같습니다. 하지만 LineItem 클래스가 다른 곳에서 사용되고 있나요? 단순히 Cart의 구현 세부사항이고 외부에 노출되지 않는다면, 정말 몇 개의 테스트가 필요할까요? Cart 클래스를 통해 테스트하면 충분하지 않을까요?

리팩토링으로 추출된 클래스들은 대개 자체적인 테스트 스위트가 필요 없습니다. 그저 구현 세부사항일 뿐입니다. 독립적으로 사용되기 시작할 때 비로소 별도의 테스트가 필요해집니다.

퍼블릭 인터페이스에 대한 훌륭한 테스트 스위트가 있다면, 테스트를 전부 재작성하지 않고도 구현을 자유롭게 변경할 수 있습니다. 객체의 구현에 대해 평범한 테스트 스위트 하나를 작성하는 것보다 훨씬 적은 노력으로 이것이 가능합니다.

TDD로 테스트 비용을 분산하세요

첫 번째 섹션에서는 모든 것을 테스트해야 한다고 배웠고, 두 번째 섹션에서는 퍼블릭 인터페이스만 테스트해야 한다고 배웠습니다. 서로 상충하는 듯한 이 두 목표를 연결해 주는 것이 바로 테스트 주도 개발(Test-Driven Development, TDD)입니다.

TDD에서는 다음 프로세스를 따르며 테스트가 코드의 설계와 구현을 이끌어 갑니다:

  1. 필요한 코드가 이미 있다고 가정하는 실패하는 테스트를 작성합니다.
  2. 테스트를 통과시키는 가장 단순한 구현을 작성합니다.
  3. 중복을 제거하거나(혹은 코드를 더 표현력 있게 만들기 위해) 리팩토링합니다.
  4. 테스트를 다시 실행합니다(여전히 통과하는지 확인).
  5. 1단계로 돌아갑니다.

이 단계를 따르면 모든 것을 테스트하면서(실패하는 테스트 없이는 코드를 작성하지 않으므로), 동시에 퍼블릭 인터페이스만 테스트하게 됩니다(리팩토링 직후에는 새 테스트를 작성하지 않으므로).

물론 실제로는 그렇게 단순하지 않습니다. 하지만 가장 복잡한 코드조차 TDD로 개발할 방법은 존재합니다.

TDD에는 다음과 같은 부수적인 이점도 있습니다:

  • 더 유연하고 검증된 객체 모델을 자연스럽게 얻게 됩니다(이것이 사실상 가장 큰 이점입니다).
  • 시스템이 정의상 테스트 가능해지므로, 미래의 테스트 작성 비용이 줄어듭니다.
  • 테스트 비용이 개발 과정 전반에 분산되어, 일정 산정이 더 정확해집니다.
  • 다음에 무엇을 해야 할지 고민할 필요가 없어 플로우(flow) 상태가 유지됩니다.

그래서, 어떻게 시작해야 할까요?

시작이 가장 어렵습니다! 하지만 한번 테스트 주도 코드 작성의 리듬에 들어오면 멈추기가 오히려 어려워집니다.

다음에 새 기능을 개발할 때 위의 TDD 단계를 따라 해 보세요. 최소한의 노력으로 거의 100%에 가까운 코드 커버리지를 달성하고, 튼튼한 기반 위에서 개발을 진행하며, 내년에 코드를 수정해야 할 때—혹은 오늘 요구사항이 또 바뀔 때—테스트 스위트가 여러분을 확실히 지켜줄 거라는 완벽한 확신을 얻게 될 것입니다.

직관적이고 명확한 테스팅 프로세스를 따르면, 단순한 결정 내리고 버그를 추적하는 데 쓰는 시간을 줄이고, 고객과 비즈니스의 요구를 해결하는 코드를 작성하는 데 더 많은 시간을 쓸 수 있습니다.