본문 바로가기
카테고리 없음

테스트 프레임워크 만들기2

by 경자꿈사 2025. 1. 22.

이번 글에는 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~~