메뉴 바로가기 검색 및 카테고리 바로가기 본문 바로가기

한빛출판네트워크

한빛랩스 - 지식에 가능성을 머지하다 / 강의 콘텐츠 무료로 수강하시고 피드백을 남겨주세요. ▶︎

IT/모바일

문서의 단위 테스트

한빛미디어

|

2006-09-12

|

by HANBIT

12,282

제공: 한빛 네트워크
저자: Leonard Richardson, 한동훈 역
원문: http://www.onlamp.com/pub/a/onlamp/2006/09/07/unit-testing-docs.html

오라일리의 편집자 마이크 라우키데스(Mike Loukides)씨는 내게 Ruby Cookbook 공동 집필을 의뢰했을 때 나는 불안했다. 프로젝트의 크기를 걱정한 것이 아니라 수준에 대해 걱정했다. 내가 오라일리의 도서들에서 기대하는 수준의 품질만큼 내가 일할 수 있을까, 특히 파이선 쿡북, 펄 쿡북 같은 책들을 사람들이 어떻게 평가하는지 알고 있다. 나는 오라일리의 수준에 맞추지 못한 책들의 끔찍한 이야기들을 들은적이 있다. 책의 코드들은 버그가 많아서 신속하게 2판이 나오곤 했다. 나는 내 책에서 이런 일이 발생하는 것을 원하지 않았다.(지금도 그렇지 않기를 희망한다)

먼저, 쿡북의 품질을 보장한다는 게 특별히 어렵다고 생각했다. 몇가지 관련된 주제를 설명하기 위해 응용프로그램 규모의 예제(예를 들어, 자바에서의 데이터베이스 액세스) 대신에 350개의 폭넓은 주제들에 대한 350개의 개별적인 코드들을 테스트해야 했다. 소프트웨어 프로젝트와 마찬가지로 우리는 데드라인을 맞춰야 했다.

최악인 것은 계약의 구조나 교정자의 시간이 부족했기 때문에 이 책은 거의 폭포수(waterfall) 프로젝트였다. 마지막까지 우리의 초점은 모든 것을 쓰는 데에 맞춰져 있었고, 그 이후에 텍스트와 코드를 편집할 수 있는 잠깐의 시간이 주어졌다. 나쁜 텍스트가 나쁜 코드 보다 강행하기 쉽기 때문에 불가능한 것은 아니었지만, 이는 테스트 인프라스트럭처를 구축하는데 사용할 충분한 시간은 없었다는 것을 의미한다.

다행히도, 초기에 했던 나의 걱정에도 불구하고, 쿡북의 형식은 개별 레시피를 테스트하기 쉽게 만들었다. 나는 레시피들을 단위 테스트로 변환할 수 있었으며, irb 세션을 사용해서 테스트를 실행하고, 실패한 내용을 표시하는 보고서를 생성할 수 있었다.

테스트 프레임워크에 고마움을 전하며, 30개 레시피에 대한 교정, 디버그, 검증까지 할 수 있었다. 손으로 일일이 하는 것 보다 훨씬 더 빠르게 처리할 수 있었고, 더 높은 신뢰도를 가진 코드를 만들어 낼 수 있었다. 나는 테스트 결과를 나의 비공식 루비 쿡북 홈페이지에 각 레시피에 대해 계산한 "신뢰도 점수(confidence score)"로 만들었다.

이 기사에서는 내 테스팅 스크립트를 단순화하고, 깔금하게 정리한 버전을 소개할 것이다. 이는 레시피 텍스트를 코드 조각으로 파스하고, irb 세션에서 코드 조각들을 실행하고, assertion과 실제 결과를 비교한다. 이는 파이선의 doctest 라이브러리와 비슷한 방식으로 동작한다.

문제 정의

루비 쿡북에 있는 대부분의 레시피는 단일 irb 세션에서 설명할 수 있는 일련의 코드 샘플로 되어 있다. 이들 예제에서 중요한 루비 표현에 대해 설명을 붙이고, 결과에 대해 코멘트를 달았다. 레시피 1.15, "텍스트의 라인을 워드 랩하기"의 예제를 보면 다음과 같다.
def wrap(s, width=78)
  s.gsub(/(.{1,#{width}})(s+|)/, "\1
")
end

wrap("This text is too short to be wrapped.")
# => "This text is too short to be wrapped.
"

puts wrap("This text is not too short to be wrapped.", 20)
# This text is not too
# short to be wrapped.
아스키 화살표(=>)는 irb에서 하는 것처럼 wrap에 대한 첫번째 호출의 결과값을 가리킨다. 두번째 호출은 puts 문에 사용한 것이며, 표준 출력에서 출력되는 문자열을 보여준다. 결과값과 출력 모두 코멘트로 숨겨져 있기 때문에 독자는 예제 코드를 바로 복사해서 irb에 붙여넣을 수 있다. 레시피를 따라가면서 독자는 레시피 솔루션에 사용된 테크닉들을 시도해볼 수 있다. 각각의 중요한 단계마다 독자들은 코드를 올바르게 이해했을 때 볼 수 있는 결과와 자신의 결과를 비교해 볼 수 있다. 각 레시피의 끝에 도달하면, 독자는 앞으로의 실험에 사용할 수 있는 라이브러리와 객체들을 갖게 된다.

모든 코드를 실행하고, 코멘트의 결과와 비교하면서 검토하는 것은 오랜 시간이 걸릴 것이다. 그러나, 이는 머리를 많이 써야하는 것도 아니므로 자동화하면 되지 않을까?

우리는 예제 코드에 있는 Test::Unit 호출을 고수하지 않았다. 이는 레시피의 요점들을 방해만 할 뿐이다. 코드에 덧붙인 코멘트들은 이전에 정의한 코드를 사용할 때 무슨 결과를 얻게 될지 얘기해주는 assertion이며, 단위 테스트이다. 코멘트들을 교육의 목적도 있지만, 품질을 검증하는 데 도움이 되기도 한다.

레시피 포맷

레시피의 영문 텍스트로부터 코드를 추출해내는 것이 첫번째 단계다. 다행히도, 우리는 루비 쿡북을 RedCloth와 유사한 위키 포맷으로 썼다. ```를 포함한 라인들은 코드 조각을 의미한다.
 This is the English text of the book

 ```
 puts "This is Ruby code."
 # "This is Ruby code."
 ```

 This is more English text.
루비 코드의 포맷은 무엇인가? 화살표를 포함한 코멘트로 끝나는 라인이 있다면, 이는 해당 라인에 대한 표현식의 값에 대한 assertion을 의미한다.
"pen" + "icillin"                # => "penicillin"
["pen", "icill"] << "in"         # => ["pen", "icill", "in"]
화살표를 포함한 코멘트로 시작하는 라인이 있다면, 이전 라인의 표현식의 값에 대한 assertion을 의미한다.
"banana" * 10
# => "bananabananabananabananabananabananabananabananabananabanana"
화살표가 없는 코멘트로 시작하는 라인이 있다면, 이전 표현식의 출력에 대한 assertion을 의미한다. 표현식은 출력 결과를 여러 줄로 표시할 수 있다.
puts "My life is a lie."
# My life is a lie.

puts ["When", "in", "Rome"].join("
")
# When
# in
# Rome
코드 조각의 다른 라인은 관련된 assertion이 없는 루비 코드를 의미한다.

레시피를 Assertion으로 파스하기

이글의 나머지는 test_recipe.rb에 대한 것이다. 이 코드는 내가 레시피의 assertion을 파스하고 테스트하기 위해 사용한 루비 스크립트를 수정한 것이다. 이 코드는 코드 조각과 이와 관련된 assertion을 담기 위한 간단한 struct class로 시작한다.
#!/usr/bin/ruby
# test_recipe.rb

Assertion = Struct.new(:code, :should_give, :how)
class Assertion
  SEPARATOR = "
#{"-" * 50}
"

  def inspect(against_actual_value=nil)
    s = "*** When I run this code: ***" +
      SEPARATOR + code.join("
") + SEPARATOR +

      "*** I expect this #{how}: ***" +
      SEPARATOR + should_give.join("
") + SEPARATOR

    if against_actual_value
      s += "*** What I get is this: ***" +
        SEPARATOR + against_actual_value + SEPARATOR + "
"
    end
  end
end
각 레시피는 독립된 파일로 취급할 수 있다. AssertionParser 클래스는 이들 파일을 irb 세션에 사용할 데이터를 의미하는 Assertion 객체의 배열로 변환한다.

```로 된 레시피를 나누고, 각 코드 조각을 검사한다. 루비 쿡북에서 대부분의 코드 조각은 레시피의 irb 세션의 부분으로 되어 있지만, 몇가지는 쉘 세션, 독립된 루비 파일, 루비가 아닌 다른 언어로 된 코드로 되어있다. 프로그램은 이러한 코드 조각들을 걸러낼 필요가 있다. 예제를 단순히 하기 위해 이 부분의 코드들을 생략했다.
# Parses a Ruby Cookbook-formatted recipe into a set of assertions
# about chunks of code.
class AssertionParser
  attr_reader :assertions

  EXPRESSION_VALUE_COMMENT = /#s+=>/
  EXPRESSION_OUTPUT_COMMENT = /^#s+(?!=>)/

  def initialize(code)
    @assertions = []
    create_assertion

    # Strip out the code snippets from the English text.
    snippets = []
    code.split(/```s*
/).each_with_index do |x, i|
      # Not shown: filter snippets that aren"t part of the irb session.
      snippets << x if (i % 2 == 1)
    end
두번째 단계는 루비 코드를 코드 조각(chunks)으로 나누는 것이다. 각 코드 조각은 검사할 assertion으로 끝난다. AssertionParser는 루비 코드를 라인별로 검사하고, 코드 조각을 모으고, 각 assertion을 찾고, 코드 조각과 assertion의 관계를 연결한다.

이 섹션에서는 표현식의 예상 출력에 대한 assertion을 포함한 라인을 처리한다.
    # Divide the code into assertions.
    snippets.join("
").each do |loc|
      loc.chomp!
      if loc.size > 0
        if EXPRESSION_OUTPUT_COMMENT.match(loc)
          # The code so far is supposed to write to standard output.
          # The expected output begins on this line and may continue
          # in subsequent lines.
          @assertion.how = :stdout if @assertion.should_give.empty?

          # Get rid of the comment symbol, leaving only the expected output.
          loc.sub!(EXPRESSION_OUTPUT_COMMENT, "")
          @assertion.should_give << loc
다른 섹션은 표현식의 예상되는 값에 대한 assertion을 포함한 라인을 처리한다.
        elsif EXPRESSION_VALUE_COMMENT.match(loc)
          # The Ruby expression on this line is supposed to have a
          # certain value. If there is no expression on this line,
          # then the expression on the previous line is supposed to
          # have this value.

          # The code up to this line may have depicted the standard
          # output of a Ruby statement. If so, that"s at an end now.
          create_assertion if @assertion.how == :stdout and @assertion.code

          expression, value = 
            loc.split(EXPRESSION_VALUE_COMMENT, 2).collect { |x| x.strip }
          @assertion.should_give = [value]
          @assertion.code << expression unless expression.empty?
          create_assertion
이 섹션에서는 코드의 다른 라인들을 처리한다.
       else
          # This line of code is just another Ruby statement.

          # The code up to this line may have depicted the result or
          # standard output of a Ruby statement. If so, that"s now at
          # an end.
          create_assertion unless @assertion.should_give.empty?

          @assertion.code << loc unless loc.empty?
        end
      end
    end
    create_assertion # Finish up the last assertion
  end

  # A convenience method to append the existing assertion (if any) to the
  # list, and create a new one.
  def create_assertion
    if @assertion && !@assertion.code.empty?
      @assertions << @assertion
    end
    @assertion = Assertion.new(code=[], should_give=[], how=:value)
  end
end
irb 세션 스크립팅

이제 프로그램은 Assertion 객체들의 리스트를 갖게 되었다. Assertion 객체는 코드 조각과 코드를 실행했을 때 예상되는 값으로 구성된다. 우리는 irb 세션에서 실행할 수 있도록 루비 쿡북 코드를 작성했으므로, irb 세션에서 테스트를 수행했다. irb 세션을 스크립트하는 것이 eval로 동작 결과를 이해하려 하는 것보다 더 쉬울 것이라고 판단했다.

irb 세션 스크립팅에서 가장 어려운 부분은 오버라이드 할 부분을 아는 것이다. 수정할 클래스는 IRB::Irb이다. 입력으로 다른 소스를 허용하기 위해 irb 인스턴스를 얻기 위해 메서드 gets와 prompt=을 지원하는 입력 클래스를 만들어야 한다.

표현식의 실제 결과와 예상되는 결과를 비교하는 것은 쉽다. irb 세션에서 가장 최근 표현식의 값은 Irb 객체의 인스턴스 변수 @context를 이용할 수 있다.

입력 소스로 변수를 취하고, 이를 사용할 인터프리터를 설정할 수 있는 Irb 서브 클래스의 소스는 다음과 같다. irb 코드는 HarnessedIrb#output_value를 이를 실행하는 모든 표현식의 값으로 전달하며, output_value 구현은 단순히 harness 클래스를 delegate(위임)한 것이다.irb 세션을 조작하고, 표현식의 출력을 조사하기 위해 필요한 것은 HarnessedIrb 클래스와 적절한 harness 뿐이다.
require "irb"
class HarnessedIrb < IRB::Irb

  def initialize(harness)
    IRB.setup(__FILE__)
    # Prevent Ruby code from being echoed to standard output.
    IRB.conf[:VERBOSE] = false
    @harness = harness
    super(nil, harness)
  end

  def output_value
    @harness.output_value(@context.last_value)
  end

  def run
    IRB.conf[:MAIN_CONTEXT] = self.context
    eval_input
  end
end
다음은 Assertion 객체의 목록과 irb에 사용할 코드를 한번에 한 라인씩 제공하는 AssertionTestingHarness 클래스다.
@require "stringio"

class AssertionTestingHarness
  def initialize(assertions)
    @assertions = assertions
    @assertion_counter, @line_counter = 0
    @keep_feeding = false
    $stdout = StringIO.new
  end

  # Called when irb wants a line of input.
  def gets
    line = nil
    assertion = @assertions[@assertion_counter]
    @line_counter += 1 if @keep_feeding
    line = assertion[:code][@line_counter] + "
" if assertion
    @keep_feeding = true
    return line
  end

  # Called by irb to display a prompt to the end-user. We have no
  # end-user, and so no prompt. Strangely, irb knows that we have no
  # prompt, but it calls this method anyway.
  def prompt=(x)
  end
irb 인터프리터는 코드의 각 라인을 평가할 때 마다 output_value를 호출하지만, assertion을 평가해야 하는 코드 조각의 마지막 라인이 아니면 아무것도 일어나지 않는다.
  # Compare a value received by irb to the expected value of the
  # current assertion.
  def output_value(value)
    begin
      assertion = @assertions[@assertion_counter]
      if @line_counter < assertion[:code].size - 1
        # We have more lines of code to run before we can test an assertion.
        @line_counter += 1
인터프리터는 Ruby 표현식의 결과를 output_value 인자로 전달한다. assertion이 :value-type assertion이면, harness는 단순히 예상되는 값과 전달된 인자의 값을 비교한다. assertion이 :stdout-type이면 무시한다. 대신에, harness는 코드 조각동안 모아둔 표준 출력을 캡쳐하고, 예상되는 값과 비교한다. 이를 위해서 초기화 메서드는 $stdout을 StringIO 객체로 대체한다.
      else
        # We"re done with this code chunk; it"s time to check its assertion.
        value = value.inspect
        if assertion[:how] == :value
          # Compare expected to actual expression value
          actual = value.strip
        else
          # Compare expected to actual standard output.
          actual = $stdout.string.strip
        end
        report_assertion(assertion, actual)
        # Reset standard output and move on to the next code chunk
        @assertion_counter += 1
        @line_counter = 0
        $stdout.string = ""
      end
    rescue Exception => e
      # Restore standard output and re-throw the exception.
      $stdout = STDOUT
      raise e
    end
    @keep_feeding = false
  end
report_assertion 메서드는 assertion과 실제 결과를 비교한다. 책을 테스트할 때 나의 harness는 각 레시피에 대한 HTML 레포트를 출력했으며, 실패한 assertion은 빨간색으로 표시했다. 여기서 제시한 구현은 이보다 훨씬 단순화한 것이다. 코드 조각의 실제 값과 assertion을 조사만 한다. 세번째 구현은 Test::Unit assertion을 만드는 것이다.
  # Compare the expected value of an assertion to the actual value.
  def report_assertion(assertion, actual)
    STDOUT.puts assertion.inspect(actual)
  end
end
마지막으로, 이 코드를 스크립트로 실행할 때 표준 입력을 테스트하는 코드다.
if $0 == __FILE__
  assertions = AssertionParser.new($stdin.read).assertions
  HarnessedIrb.new(AssertionTestingHarness.new(assertions)).run
end
레시피를 코드 목록으로 추출하고, 평가하고, 테스트할 수 있는 스크립트로 실행한다. 다음은 위에서 소개한 "텍스트 라인을 워드랩하기"의 예제 코드에 대해 이 스크립트를 실행한 결과를 나타낸 것이다. 총 다섯줄의 루비 코드이며, 두 개의 assertion으로 되어 있다.
*** When I run this code: ***
def wrap(s, width=78)
  s.gsub(/(.{1,#{width}})(s+|)/, "\1
")
end
wrap("This text is too short to be wrapped.")
*** I expect this value: ***
"This text is too short to be wrapped.
"
*** What I get is this: ***
"This text is too short to be wrapped.
"

*** When I run this code: ***
puts wrap("This text is not too short to be wrapped.", 20)
*** I expect this stdout: ***
This text is not too
short to be wrapped.
*** What I get is this: ***
This text is not too
short to be wrapped.
까다로운 문제

이 스크립트는 루비 쿡북에 있는 대부분의 코드를 테스트하는데에는 무리가 없지만, 내가 사용했던 스크립트를 보다 복잡하게 만드는 몇 가지 문제가 있다. 가장 큰 문제는 예외를 발생시키는 예제들이다.
10 / 0
# ZeroDivisionError: divided by 0
Irb에서 예외를 만나면 표준 출력에 에러 메시지를 출력하고, gets 호출까지 이를 유지한다. 위 예제에서 10을 0으로 나누려고 시도했으며, "결과"에 output_value를 호출하는 대신에 표준 출력으로 예외를 출력하고, 다시 gets를 호출한다. assertion에는 더 이상 실행할 코드가 없기 때문에 두번째 gets 호출은 test_recipe.rb를 충돌하게 만든다. 나의 원래 스크립트는 이러한 조건을 발견하고, 표준 출력과 예상되는 값을 비교한다.

또 다른 문제는 매우 많은 출력을 생성해내는 코드다. 우리는 모든 출력을 코드에 담지 않았고, 처음 일부만 보여주길 원했다.
("a".."z").each { |x| puts x }
# a
# b
# c
# ...
우리는 책의 예제 출력에서 중요하지 않은 부분을 생략하기 위해 생략부호(...)를 사용했다. 예상되는 출력과 실제 출력을 비교하려 할 때 내 코드는 생략 부호를 이후의 모든 출력 결과와 일치하는 와일드 카드로 간주했다.

또한, 스크립트에서 코드가 올바르더라도 초기에 실패하게 되는 테스트를 처리할 수 있도록 스크립트를 좀 더 다듬었다. 테스트를 시작하기 전에 난수 생성기를 위한 시드(seed)를 주었기 때문에 랜덤에 대한 테스트도 항상 같은 결과를 표시하도록 했다. 또한, 특정 레시피에서 필요한 예제 파일들이 미리 들어있는 임시 작업 디렉터리에서 각 테스트를 실행했다. 일부 테스트는 새로운 파일과 디렉터리를 생성하기 때문에, 임시 디렉터리 사용은 메인 테스트 디렉터리를 깨끗하게 유지하는 데 도움이 되었다.

성공률

우리는 빡빡한 데드라인을 갖고 있었기 때문에 80 퍼센트 솔루션에 중점을 맞췄다. 364개의 레시피 중에서 자동화된 테스터는 273개(80퍼센트)를 파스할 수 있었고, 테스터는 2,279개의 테스트를 발견하고, 1,830(다시 80퍼센트)개의 예상되는 결과를 검증할 수 있었다.

몇 가지 레시피는 직접 손으로 테스트해야 했다. 자동화된 테스터는 독립 루비 프로그램, CGI 스크립트, Rakefile은 테스트하지 못한다. 또한, Curses나 Rails 같이 irb 세션과 호환되지 않는 라이브러리를 사용하는 레시피는 테스트하지 못한다. 예상한 대로, 이들 레시피는 편집하는 데 보다 긴 시간이 걸렸으며, 결과에 대해서도 낮은 신뢰도를 가졌다.

테스트할 수 있는 레시피들이 의사 실패(false failure, 실패가 아니지만 실패로 판단되는 경우)인 경우에는 직접 검증해야 했다. 예를 들어, 3장("Date and Time")의 많은 테스트들은 현재 시간에 의존하기 때문에 책에 인쇄된 결과와는 항상 다를 수 밖에 없다. 일부 실패한 테스트들은 전혀 실패가 아닌 경우도 있다. 예를 들어, 테스트 프레임워크의 설명 코멘트에 예상되는 예제 출력이 잘못 인터프리트된 경우도 있었다. 이러한 결점들은 성가신 것이었지만, 실제로 실패한 테스트들을 표시하는 정도에서만 문제를 일으키는 정도였다.

테스트 프레임워크는 우리를 위해 모든 테스트를 수행하지는 못했지만, 개발 비용을 합리화 시킬 정도의 충분한 시간 절약을 해주었다. 우리가 수작업으로 했으면 결코 발견하지 못했을 수 많은 오류를 발견하고, 수정하고, 단위 테스트가 향상되는 것을 즐기게 해주었다. 만약, 코드가 변경되었으면 테스트를 다시 실행하면서 잘못된 부분이 있는지 검증하면 되었다.

단점

물론, 우리는 단위 테스트의 문제점들을 알고 있다. 좋은 코드 커버리지를 갖고 있지 않다면, 소유한 단위 테스트는 그저 매혹적인 것에 불과하다. 우리의 주 목표는 독자에게 이 코드 조각을 사용하는 방법에 대한 아이디어를 제공하는 것이지 모든 기능과 특별한 경우를 테스트하는 방법을 제공하는 것이 아니다.

이는 우리의 책에서 독립된 테스트들을 갖는 서드 파티 라이브러리, 표준 라이브러리의 기능을 설명할 때 잘 동작했다. 이 책 안에서 소개한 코드에서도 그럭저럭 동작했다. 몇번인가 리뷰어들이 우리의 코드에서 버그들을 찾아내기 도 했다. 이 책의 텍스트에서 우리가 테스트하지 않은 특별한 경우들에 대한 것이었다.

버그가 많은 단위 테스트는 아무것도 증명하지 못한다. 나는 스스로 올바른 답들을 이해하는 대신에irb에서 코드를 실행하면서 예제들을 작성하기도 했다. 다시 말해서, 나는 테스트를 작성하기 위해 (아마도 버그가 많은) 코드를 사용했다. 때때로 코드에 버그가 있지만, 제시한 답안들이 명백히 잘못된 것은 없었다. 물론, 테스트를 통과했으며, 외부의 기술 리뷰는 버그가 있는 코드를 찾아냈다.

마지막으로, 여러분이 결과를 보지 않는 다면 단위 테스트는 쓸모 없는 것이다. 데모를 준비하고, 편집의 마지막 단계에서 까지 나는 문제가 되는 실패한 테스트 두 개를 발견했다. 정말 문제가 있었고, 코드들은 버그가 있었다. 단위 테스트는 이 문제를 수개월전부터 잘못되었다고 표시했지만, 나는 그것을 단순히 간과했던 것이다.

결론

책이나 문서화시에 코드 예제들을 자동으로 테스트할 수 있는 있는 경우가 있다. 이런 작업을 함으로써 여러분이 쓴 결과가 올바른지 검증할 수 있다. 예제들을 자동으로 테스트함으로써 예제들에서 사용했던 크도에 대한 단위 테스트 류의 커버리지를 제공할 수도 있다.

예제들이 irb 세션에서 실행되는 한, irb 세션에서 루비 코드를 테스트하는 것은 상당히 쉽다. 자동화된 예제 테스팅은 눈으로 코드를 보는 것 보다 안정성을 향상시켜주며, 여러분이 쓴 글에 대한 교정을 보다 쉽게 해준다.

레오나드 리처드슨은 8살때부터 프로그래밍을 했다. 그는 Rubyful Soup을 포함해서 다양한 언어에서 사용되는 라이브러리들을 담당하고 있다. 캘리포니아 태생이며, 현재는 뉴욕에서 일하고 있다. 그는 http://www.crummy.com 웹 사이트를 운영하고 있다
TAG :
댓글 입력
자료실

최근 본 상품0