Skip to content

adamples/verbose-assertions

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

11 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

= VerboseAssertions

== example.rb

$:.unshift("./lib")

require 'test/verbose_unit'


# Basic problem with test/unit and test-unit frameworks for Ruby which this code
# tries to address is that:
#
#   assert(checkSomething())
#
# results in most helpful message like this:
#
#   <false> is not <true>
#
# Of course libraries enable us to pass additional message as last parameter
# of assertion, but that's not very convinient. In fact it's not convinient
# at all. And as we consider ourselves lazy (but in a good way!) we just need
# to spent few hours to find a solution -- code that will generate more
# appropriate error messages.
#
# In C we could do something like this:
#
#   assert(a) ( (a) ? (1) : ( printf ("%s:%d: %s(): assertion failed (%s).\n", \
#     __FILE__, __LINE__, __FUNCTION__, #a), 0) )
#
# Preprocessor will replace #a with actual *code* that we passed to macro, eg.
#
#   assert(2 * 2 == 5)
#
# Will produce output:
#
#   file.c:44: function(): assertion failed(2 * 2 == 5).
#
# Now we know what is wrong at a glance. But we cannot simply apply this approach
# to Ruby, unless we use some custom preprocessor -- typical ruby interpreter from
# typical ruby installation has no preprocessor.
#
# On the other hand Ruby is interpreted language. Source files can be accessed
# from code like any other files. Actually Ruby always passes IO handler to actual
# file as global constant (__DATA__). So why not in case of failed assertion just
# open file which called it and read params list?
#
# This simple idea does have some problems, but we will talk them later. For now
# we will just define interface that we *want* to use. To find out how it works
# proceed to Test::VerboseUnit::TestCase class documentation.
#
# Code of following class should be familiar to everyone, who have ever had any
# interaction with unit testing frameworks in Ruby. It inherits some base class
# from framework, which usually provides assertion methods and some magic/logic
# that runs tests.
#
# Our work is to define test methods.
class ExampleTestCase < Test::VerboseUnit::TestCase


  # Test method is identified by its name (beginning with "test" string).
  def test_1
    # Here we do some calulcations; usually we use some library that we want
    # to test
    b = 0
    1000000.times do
      b += 0.000004
    end

    # And here we test is result is correct. Two expressions are passed to
    # assertion method. If it fails (and it will), we expect error message in
    # following hair:
    #
    #   assert_equal failed in test_1!
    #   2 * 2 != b
    #     2 * 2 = 4
    #     b = 3.9999...
    #   backtrace follows...
    assert_equal(Math.exp(Math.log(4)), b)
  end


  # This method in turn will do assertion with somewhat more complex invocation
  def test_2
    # This assertion arguments contain many syntax structures that are hard
    # to parse without actual ruby parser, eg. strings and comments with brackets,
    # even whole loops, as they return value too.
    assert_equal(

      "Jakis\") tekst".kind_of?(
          10.class.name.class
        ),
      # Some (comment "string"
      123
    )
  end


  # In this example we use even more complex syntax, but it aims to show other
  # thing. Statements below can be described in Ruby in more than one way. For
  # example strings can be denoted using apostrophes or quotation marks. If you
  # run this test, you will see, that syntax used in exception message differs
  # from original code (this can be considered as another limitation or a feature
  # of library).
  def test_3
    assert_equal(
      %w[warsaw berlin paris budapest].select { |c| c.index('i').nil? },
      ['warsaw', 'budapest'].collect do |c|
        c.capitalize
      end
    )
  end

end


# To keep code dry, logic that runs tests automagicly is not included. Instead
# run method is invoked directly.
ExampleTestCase.run

== lib/test/verbose_unit/test_case.rb

require 'test/verbose_unit/assertions'


module Test
  module VerboseUnit


    # Here is simpliest possible base class for unit testing. It includes module
    # with assertions and defines one method, used to run testing methods.
    class TestCase

      include Assertions


      # Run method enumerates instance methods of a class and runs those
      # named test*; for each invocation a new object is created. Failure of
      # assertion is passed as exception from that method -- all we need to do
      # is to print message and backtrace. This means, that message must be
      # created inside assertion method, where exception is thrown. Definition
      # of those methods can be found in Assertions module.
      def self.run

        # Get instance methods named test*
        methods = self.instance_methods.select do |m|
          m.to_s.index("test") == 0
        end

        # For each test method:
        #   - create new object
        #   - call setup
        #   - call test method
        #   - call teardown
        #   - call cleanup
        methods.each do |m|
          instance = self.new

          begin
            instance.setup if instance.respond_to?(:setup)
            instance.send(m)
            instance.teardown if instance.respond_to?(:teardown)
          rescue AssertionFailedError => e
            puts
            puts "Assertion failed in `#{instance.class.name}##{m}'"
            puts e.message
            puts "backtrace:"
            puts e.backtrace.join("\n")
          ensure
            instance.cleanup if instance.respond_to?(:cleanup)
          end
        end
      end

    end


  end # module VerboseUnit
end # module Test

== lib/test/verbose_unit/assertions.rb

require 'rubygems'
require "bundler/setup"
require 'ruby_parser'
require 'ruby2ruby'
require 'test/verbose_unit/assertion_failed_error'


module Test
  module VerboseUnit


    # Assertions module is where all the magick is hidden. It contains only one
    # assertion: #assert_equal, which is not very complicated by itself.
    module Assertions

      public

      # This method compares its arguments using standard == operator. Operands
      # order is the same as params in method invocation.
      #
      # @param a  first operand of comparison
      # @param b  second operand of comparison
      # @raise AssertionFailedException   if comparison evaluates as false
      def assert_equal(a, b)
        # Here you can see it with your own eyes:
        if a == b
        else
          # When assertion fails, try to get code for arguments. This task is
          # delegated to another method, in case we may want to write more
          # assertions.
          args = get_arguments_code

          # Something *may* go wrong. Always. Especially with this sort of code.
          # In that case we rely on our method returning nil and fallback to
          # usual message.
          if args.nil? || args.size != 2
            # Prepared backtrace is provided for exception object to not include
            # following line as source of an error, but rather line where
            # assertion was called from.
            raise AssertionFailedError.new("#{a.inspect} != #{b.inspect}", caller)
          else
            # But when everything is ok, args should contain code of two arguments,
            # and we still can provide their values as additional information.
            raise AssertionFailedError.new("#{args[0]} != #{args[1]}\nwhere:\n" +
              "\t#{args[0]} = #{a.inspect}\n" +
              "\t#{args[1]} = #{b.inspect}\n",
            caller)
          end
        end
      end


      protected

      # Returns code of arguments of calling method as an array of strings. When
      # fails to find this data, returns nil.
      #
      # Hic sunt leones
      #
      # @return [Array]   code of arguments of calling method or nil in case of
      #   failure.
      def get_arguments_code

        # Stack trace certainly will be useful.
        stack_trace = caller

        # First of all, calling method name will be needed. It's simply parsed
        # from stack trace using regexp.
        method = stack_trace[0].match(/:in `(.*)'/)[1]

        # Going deeper, file path and line, where previous method was called are
        # parsed.
        t = stack_trace[1].match(/([^:]+):(\d+):/)
        path = t[1]
        line_number = t[2].to_i

        # Read the file
        lines = []

        begin
          File.open(path, "r") do |io|
            lines = io.readlines
          end
        rescue Errno::ENOENT => e
          # May this file not exist at all? I don't know, but it will be foolish
          # to assume, that if I don't know, it cannot happen.
          return nil
        rescue IOError
          # This can happen always for several reasons
          return nil
        end

        # Now some folklore. It happens that the most widely used Ruby
        # implementation has bug in implementation of Kernel#caller. If method
        # invocation takes more than one line, instead of line where the name
        # of method was, we get one of the following lines (I suspect that it's
        # always the one with first argument, but dont know exactly).
        #
        # As workaround we go up the file until we find line containing method
        # name.
        #
        # Another digression: what if one of the lines contain method name which
        # is not its invocation, eg. inside string or comment? Well, then all
        # this will fail, and you get "false != true". It is a limitation, but
        # I can live with that.
        i = line_number - 1

        while i > 0 && lines[i].index(method).nil?
          i -= 1
        end

        # Code to search is crated as invocation line and all following code.
        # First line is trimmed to begin with method name.
        #
        # After that part of code from begin is moved to anther variable and
        # removed from code itself. This part will be called _fragment_.
        #
        # Initial fragment contains method name and opening bracket, eg:
        #
        #   | fragment  | code                                  |
        #   assert_equal(arg1, arg2)\n# Some comment in next line
        code = lines[i..-1].join
        start = code.index(method)
        fragment = code[start, method.length + 1]
        code = code[(start + method.length + 1)..-1]

        sexp = nil

        # Now in loop we check if fragment contains valid (parsable) Ruby code.
        # Syntax error in code cause exception to be thrown. In that case, we just
        # add another char from code to fragment and check again, until success
        # or and of code.
        code.each_char do |ch|
          begin
            parser = RubyParser.new
            sexp = parser.process(fragment)
            break
          rescue Exception => e
            fragment += ch
          end
        end

        # Code ended, but invocation cannot be parsed. Again: can this actually
        # happen?
        return nil if sexp.nil?

        # Parsed code results in structure called s-expression (sexps). It has
        # a tree-like structure that describes Ruby code and thus can be turned
        # into code again (+ all the whitespace will be removed).
        ruby2ruby = Ruby2Ruby.new
        result = []

        sexp[3].each_with_index do |arg, i|
          next if i == 0
          result << ruby2ruby.process(arg)
        end

        # Here we can return array of source code strings
        return result
      end

    end # module Assertions


  end # module VerboseUnit
end # module Test

== lib/test/verbose_unit/assertion_failed_error.rb

module Test
  module VerboseUnit


    # This class faciliate two tasks for us:
    #   - allows us to distinguish VerboseUnit exception from other exceptions,
    #   - lets us create an exception with custom backtrace in one line.
    class AssertionFailedError < Exception

      # Constructor sets message and stack trace for exception
      # @param [String] message     human-readable message explaining cause of exception
      # @param [Array] stack_trace  backtrace for exception
      def initialize(message, stack_trace)
        super(message)
        set_backtrace(stack_trace)
      end

    end


  end # module VerboseUnit
end # module Test

== test/test_assertions.rb

$:.unshift("./lib")

require 'test/verbose_unit'
require 'test/unit'


class TestedTestCase < Test::VerboseUnit::TestCase
end


class AssertionsTestCase < Test::Unit::TestCase


  def setup
    @test_case = TestedTestCase.new
  end


  def cleanup
  end


  def equal_message(code_a, value_a, code_b, value_b)
<<EOS
#{code_a} != #{code_b}
where:
\t#{code_a} = #{value_a.inspect}
\t#{code_b} = #{value_b.inspect}
EOS
  end


  def test_equal_1
    assert_raise(Test::VerboseUnit::AssertionFailedError) do
      @test_case.assert_equal(true, false)
    end

    begin
      @test_case.assert_equal(true, false)
    rescue Test::VerboseUnit::AssertionFailedError => e
      expected_message = equal_message("true", true, "false", false)
      assert_equal(expected_message, e.message)
    end
  end


  def test_equal_2
    assert_raise(Test::VerboseUnit::AssertionFailedError) do
      @test_case.assert_equal(NilClass, Class)
    end

    begin
      @test_case.assert_equal(NilClass, Class)
    rescue Test::VerboseUnit::AssertionFailedError => e
      expected_message = equal_message("NilClass", NilClass, "Class", Class)
      assert_equal(expected_message, e.message)
    end
  end


  def test_equal_3
    assert_raise(Test::VerboseUnit::AssertionFailedError) do
      @test_case.assert_equal(
        Math::PI,
        3.14
      )
    end

    begin
      @test_case.assert_equal(
        Math::PI,
        3.14
      )
    rescue Test::VerboseUnit::AssertionFailedError => e
      expected_message = equal_message("Math::PI", Math::PI, "3.14", 3.14)
      assert_equal(expected_message, e.message)
    end
  end


  def test_equal_4
    assert_raise(Test::VerboseUnit::AssertionFailedError) do
      @test_case.assert_equal(
        # This test checks if additional tokens, like comments doesn't have
        # negative impact on library operation.
        Math::PI,
=begin
  Antoher multi-
  line comment
=end
        3.14
      )
    end

    begin
      @test_case.assert_equal(
        # This test checks if additional tokens, like comments doesn't have
        # negative impact on library operation.
        Math::PI,
=begin
  Antoher multi-
  line comment
=end
        3.14
      )
    rescue Test::VerboseUnit::AssertionFailedError => e
      expected_message = equal_message("Math::PI", Math::PI, "3.14", 3.14)
      assert_equal(expected_message, e.message)
    end
  end


  def test_equal_5
    assert_raise(Test::VerboseUnit::AssertionFailedError) do
      @test_case.assert_equal(
        %w[warsaw berlin paris budapest].select { |c| c.index('i').nil? },
        ['warsaw', 'budapest'].collect do |c|
          c.capitalize
        end
      )
    end

    begin
      @test_case.assert_equal(
        %w[warsaw berlin paris budapest].select { |c| c.index('i').nil? },
        ['warsaw', 'budapest'].collect do |c|
          c.capitalize
        end
      )
    rescue Test::VerboseUnit::AssertionFailedError => e
      expected_message = equal_message(
        "[\"warsaw\", \"berlin\", \"paris\", \"budapest\"].select { |c| c.index(\"i\").nil? }",
        %w[warsaw berlin paris budapest].select { |c| c.index("i").nil? },
        "[\"warsaw\", \"budapest\"].collect { |c| c.capitalize }",
        ['warsaw', 'budapest'].collect do |c|
          c.capitalize
        end
      )
      assert_equal(expected_message, e.message)
    end
  end


end

About

Some unit testing ideas and hacking

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages