이번 글에는 MyMinitest에 Minitest에서 지원하는 여러 어설션 메서드들 중 하나인 assert_equal를 추가해 보자.
아래 코드를 보면 MyMinitest 모듈 안에 Assertions 모듈을 추가했고 그 안에 assert_equal 메서드를 정의하였다.
assert_equal 메서드는 첫 번째 인수와 두 번째 인수를 == 비교 연산자로 비교하여 같으면 true를 반환하고 다르면 Failure 예외를 던진다.
예외 메시지 내용에 assert_equal 메서드가 호출된 소스의 위치 정보를 포함시키기 위해 Kernel 모듈에서 정의한 caller 메서드를 사용하였다.
assert_equal 메서드 안에서 caller 메서드를 호출하면 해당 assert_equal 메서드가 호출된 경로를 역순으로 포함한 배열을 반환해 준다.
module MyMinitest
class Failure < RuntimeError
end
module Assertions
def assert_equal(expected, actual, message = nil)
return true if expected == actual
source = caller[0].match(/(.+):in/)[1]
message = message || "Expected: #{expected}\n Actual: #{actual}"
raise Failure, "#{test_method} [#{source}]:\n#{message}"
end
end
class Test
include Assertions
attr_reader :name
@test_classes = []
def self.inherited(test_class)
@test_classes << test_class
end
def self.test_classes
@test_classes
end
def initialize(name)
@name = name
end
def run
begin
send(name)
rescue Failure => e
puts "Failure:\n#{e.message}\n\n"
rescue Exception => e
puts "Error:\n#{test_method}:\n#{e.class}: #{e.message}\n #{e.backtrace[0]}\n\n"
end
end
def test_method
"#{self.class.name}##{name}"
end
end
def self.run
Test.test_classes.each do |test_class|
test_methods = test_class.instance_methods(false).grep(/test_/)
test_methods.each do |test_method|
test_class.new(test_method).run
end
end
end
end
at_exit do
MyMinitest.run
end
실제 caller 메서드의 결괏값을 확인해 보기 위해 아래 코드를 D:/blog/ruby/test 폴더 아래 test_caller.rb 파일에 저장한 후 실행해 보자.
코드는 foo, bar, baz 세 개의 메서드를 정의한 후 baz 메서드를 호출하는 코드인데, baz 메서드 안에서는 bar 메서드를 호출하고,
bar 메서드는 foo 메서드를 호출한다. 그리고 foo 메서드는 caller 메서드를 호출한 결괏값을 화면에 출력하도록 되어 있다.
def foo
p caller
end
def bar
foo
end
def baz
bar
end
baz
결과를 보면 caller 메서드를 호출한 foo 메서드가 어떤 경로로 호출이 되었는지 역순으로 표시되는 게 보인다.
이렇게 메서드의 호출 또는 예외가 발생한 경로 등을 추적하여 그 경로에 대한 정보를 표현한 것을 백트레이스(backtrace)라고 한다.
이번에는 다음 코드를 D:/blog/ruby/test 폴더 아래 test_backtrace.rb 파일에 저장한 후 실행해 보자.
def foo
1 / 0
end
def bar
foo
end
def baz
bar
end
begin
baz
rescue Exception => e
pp e.backtrace
end
결과를 보면 예외가 어디에서 발생했는지 발생한 경로를 역순으로 보여주는데, 이 정보는 예외 객체의 backtrace 메서드를 통해 얻을 수 있다.
이제 MyMinitest에 추가한 어설션 메서드를 테스트해 보기 위해 test_calc.rb 코드를 아래처럼 수정하고 실행해 보자.
require './my_minitest'
class TestCalc < MyMinitest::Test
def test_add
assert_equal 7, 5 + 1
end
def test_sub
assert_equal 2, 5 - 2
end
def test_mul
assert_equal 10, 5 * 2
end
def test_div
assert_equal 0, 5 / 0
end
end
결과를 보면 예상대로 test_mul 메서드를 제외한 나머지 테스트 메서드에서 작성한 검증 구문이 모두 실패한 것을 볼 수 있다.
끝으로 MyMinitest에 훅(Hook) 기능을 추가해 보자.
아래 코드를 보면 MyMinitest 모듈의 Test 클래스 안에 LifecycleHooks 모듈을 추가하고 그 안에 6개의 훅(Hook) 메서드들을 정의했다.
그리고 LifecycleHooks 모듈을 Test 클래스 안에서 인클루드했고 실제 훅(Hook)이 실행되도록 Test 클래스의 run 메서드를 수정하였다.
Minitest를 따라 Assertions 모듈은 MyMinitest 모듈 바로 아래에서 정의했고 LifecycleHooks 모듈은 Test 클래스 안에서 정의했는데, 아무래도 Assertions 모듈과 달리 LifecycleHooks 모듈은 테스트 메서드와 더욱 밀접한 관계를 갖기 때문이 아닐까 생각한다.
물론 LifecycleHooks 모듈로 따로 분리하지 않고 그냥 Test 클래스 안에 바로 훅(Hook) 메서드들을 정의해도 되지만 훅(Hook) 기능 자체가 Test 클래스가 제공해야 할 필수 기능은 아니기 때문에 별도 모듈로 분리한 것 같다.
module MyMinitest
class Failure < RuntimeError
end
module Assertions
def assert_equal(expected, actual, message = nil)
return true if expected == actual
source = caller[0].match(/(.+):in/)[1]
message = message || "Expected: #{expected}\n Actual: #{actual}"
raise Failure, "#{test_method} [#{source}]:\n#{message}"
end
end
class Test
include Assertions
attr_reader :name
@test_classes = []
def self.inherited(test_class)
@test_classes << test_class
end
def self.test_classes
@test_classes
end
def initialize(name)
@name = name
end
def run
begin
before_setup
setup
after_setup
send(name)
before_teardown
teardown
after_teardown
rescue Failure => e
puts "Failure:\n#{e.message}\n\n"
rescue Exception => e
puts "Error:\n#{test_method}:\n#{e.class}: #{e.message}\n #{e.backtrace[0]}\n\n"
end
end
def test_method
"#{self.class.name}##{name}"
end
module LifecycleHooks
def setup
end
def before_setup
end
def after_setup
end
def teardown
end
def before_teardown
end
def after_teardown
end
end
include LifecycleHooks
end
def self.run
Test.test_classes.each do |test_class|
test_methods = test_class.instance_methods(false).grep(/test_/)
test_methods.each do |test_method|
test_class.new(test_method).run
end
end
end
end
at_exit do
MyMinitest.run
end
추가한 훅(Hook) 기능이 잘 동작하는지 확인해 보기 위해서 기존 test_calc.rb 파일을 아래처럼 수정한 후 다시 실행해 보자.
require './my_minitest'
class TestCalc < MyMinitest::Test
def setup
puts "#{name}: setup - #{object_id}"
end
def before_setup
puts "#{name}: before_setup - #{object_id}"
end
def after_setup
puts "#{name}: after_setup - #{object_id}"
end
def teardown
puts "#{name}: teardown - #{object_id}"
end
def before_teardown
puts "#{name}: before_teardown - #{object_id}"
end
def after_teardown
puts "#{name}: after_teardown - #{object_id}"
end
def test_add
puts "#{name} - #{object_id}"
assert_equal 7, 5 + 2
end
def test_sub
puts "#{name} - #{object_id}"
assert_equal 3, 5 - 2
end
end
아래 실행 결과를 보면 테스트마다 TestCalc 클래스의 서로 다른 객체에서 실행이 되고, 테스트 메서드 실행 전후에 관련 훅(Hook) 메서드들도 잘 실행되는 것을 확인할 수 있다.
Minitest와 비교해 보면 아직 해야 할 것들이 한참 더 남아 있지만, Minitest와 유사한 기능을 직접 구현해 봄으로써 Minitest를 어떻게 구현했는지 조금이나마 살펴볼 수 있었다는 정도에서 마무리를 하려고 한다.
여러분이 MyMinitest 소스를 기반으로 조금 더 진행을 해봐도 좋을 것 같다.
See you again~~