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에 붙여넣을 수 있다. 레시피를 따라가면서 독자는 레시피 솔루션에 사용된 테크닉들을 시도해볼 수 있다. 각각의 중요한 단계마다 독자들은 코드를 올바르게 이해했을 때 볼 수 있는 결과와 자신의 결과를 비교해 볼 수 있다. 각 레시피의 끝에 도달하면, 독자는 앞으로의 실험에 사용할 수 있는 라이브러리와 객체들을 갖게 된다.
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이 없는 루비 코드를 의미한다.
#!/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 객체의 배열로 변환한다.
# 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의 관계를 연결한다.
# 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 endirb 세션 스크립팅
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) endirb 인터프리터는 코드의 각 라인을 평가할 때 마다 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 endreport_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 0Irb에서 예외를 만나면 표준 출력에 에러 메시지를 출력하고, gets 호출까지 이를 유지한다. 위 예제에서 10을 0으로 나누려고 시도했으며, "결과"에 output_value를 호출하는 대신에 표준 출력으로 예외를 출력하고, 다시 gets를 호출한다. assertion에는 더 이상 실행할 코드가 없기 때문에 두번째 gets 호출은 test_recipe.rb를 충돌하게 만든다. 나의 원래 스크립트는 이러한 조건을 발견하고, 표준 출력과 예상되는 값을 비교한다.
("a".."z").each { |x| puts x } # a # b # c # ...우리는 책의 예제 출력에서 중요하지 않은 부분을 생략하기 위해 생략부호(...)를 사용했다. 예상되는 출력과 실제 출력을 비교하려 할 때 내 코드는 생략 부호를 이후의 모든 출력 결과와 일치하는 와일드 카드로 간주했다.
최신 콘텐츠