rubygem/lib/zeus/m.rb

Summary

Maintainability
A
2 hrs
Test Coverage
# This is very largely based on @qrush's M, but there are many modifications.

# we need to load all dependencies up front, because bundler will
# remove us from the load path soon.
require "rubygems"
require "zeus/m/test_collection"
require "zeus/m/test_method"

# the Gemfile may specify a version of method_source, but we also want to require it here.
# To avoid possible "you've activated X; gemfile specifies Y" errors, we actually scan
# Gemfile.lock for a specific version, and require exactly that version if present.
gemfile_lock = ROOT_PATH + "/Gemfile.lock"
if File.exist?(gemfile_lock)
  version = File.read(ROOT_PATH + "/Gemfile.lock").
    scan(/\bmethod_source\s*\(([\d\.]+)\)/).flatten[0]

  gem "method_source", version if version
end

require 'method_source'

module Zeus
  #`m`  stands for metal, which is a better test/unit test runner that can run
  #tests by line number.
  #
  #[![m ci](https://secure.travis-ci.org/qrush/m.png)](http://travis-ci.org/qrush/m)
  #
  #![Rush is a heavy metal band. Look it up on Wikipedia.](https://raw.github.com/qrush/m/master/rush.jpg)
  #
  #<sub>[Rush at the Bristol Colston Hall May 1979](http://www.flickr.com/photos/8507625@N02/3468299995/)</sub>
  ### Install
  #
  ### Usage
  #
  #Basically, I was sick of using the `-n` flag to grab one test to run. Instead, I
  #prefer how RSpec's test runner allows tests to be run by line number.
  #
  #Given this file:
  #
  #     $ cat -n test/example_test.rb
  #      1    require 'test/unit'
  #      2
  #      3    class ExampleTest < Test::Unit::TestCase
  #      4      def test_apple
  #      5        assert_equal 1, 1
  #      6      end
  #      7
  #      8      def test_banana
  #      9        assert_equal 1, 1
  #     10      end
  #     11    end
  #
  #You can run a test by line number, using format `m TEST_FILE:LINE_NUMBER_OF_TEST`:
  #
  #     $ m test/example_test.rb:4
  #     Run options: -n /test_apple/
  #
  #     # Running tests:
  #
  #     .
  #
  #     Finished tests in 0.000525s, 1904.7619 tests/s, 1904.7619 assertions/s.
  #
  #     1 tests, 1 assertions, 0 failures, 0 errors, 0 skips
  #
  #Hit the wrong line number? No problem, `m` helps you out:
  #
  #     $ m test/example_test.rb:2
  #     No tests found on line 2. Valid tests to run:
  #
  #      test_apple: m test/examples/test_unit_example_test.rb:4
  #     test_banana: m test/examples/test_unit_example_test.rb:8
  #
  #Want to run the whole test? Just leave off the line number.
  #
  #     $ m test/example_test.rb
  #     Run options:
  #
  #     # Running tests:
  #
  #     ..
  #
  #     Finished tests in 0.001293s, 1546.7904 tests/s, 3093.5808 assertions/s.
  #
  #     1 tests, 2 assertions, 0 failures, 0 errors, 0 skips
  #
  #### Supports
  #
  #`m` works with a few Ruby test frameworks:
  #
  #* `Test::Unit`
  #* `ActiveSupport::TestCase`
  #* `MiniTest::Unit::TestCase`
  #
  ### License
  #
  #This gem is MIT licensed, please see `LICENSE` for more information.

  ### M, your metal test runner
  # Maybe this gem should have a longer name? Metal?
  module M
    M::VERSION = "1.2.1" unless defined?(M::VERSION)

    # Accept arguments coming from bin/m and run tests.
    def self.run(argv)
      Runner.new(argv).run
    end

    ### Runner is in charge of running your tests.
    # Instead of slamming all of this junk in an `M` class, it's here instead.
    class Runner
      def initialize(argv)
        @argv = argv
      end

      # There's two steps to running our tests:
      # 1. Parsing the given input for the tests we need to find (or groups of tests)
      # 2. Run those tests we found that match what you wanted
      def run
        parse
        execute
      end

      private

      def parse
        # With no arguments,
        if @argv.empty?
          @files = []
          add_file("test")
        else
          parse_options! @argv

          # Parse out ARGV, it should be coming in in a format like `test/test_file.rb:9`
          _, line = @argv.first.split(':')
          @line ||= line.nil? ? nil : line.to_i

          @files = []
          @argv.each do |arg|
            add_file(arg)
          end
        end
      end

      def add_file(arg)
        file = arg.split(':').first
        if Dir.exist?(file)
          files = Dir.glob("#{file}/**/*test*.rb")
          @files.concat(files)
        else
          files = Dir.glob(file)
          files == [] and abort "Couldn't find test file '#{file}'!"
          @files.concat(files)
        end
      end

      def parse_options!(argv)
        require 'optparse'

        OptionParser.new do |opts|
          opts.banner  = 'Options:'
          opts.version = M::VERSION

          opts.on '-h', '--help', 'Display this help.' do
            puts "Usage: m [OPTIONS] [FILES]\n\n", opts
            exit
          end

          opts.on '--version', 'Display the version.' do
            puts "m #{M::VERSION}"
            exit
          end

          opts.on '-l', '--line LINE', Integer, 'Line number for file.' do |line|
            @line = line
          end

          opts.on '-n', '--name NAME', String, 'Name or pattern for test methods to run.' do |name|
            if name[0] == "/" && name[-1] == "/"
              @test_name = Regexp.new(name[1..-2])
            else
              @test_name = name
            end
          end

          opts.parse! argv
        end
      end

      def execute
        generate_tests_to_run

        test_arguments = build_test_arguments

        # directly run the tests from here and exit with the status of the tests passing or failing
        case framework
        when :minitest5, :minitest_old
          ARGV.replace(test_arguments)
          exit
        when :testunit1, :testunit2
          exit Test::Unit::AutoRunner.run(false, nil, test_arguments)
        else
          not_supported
        end
      end

      def generate_tests_to_run
        # Locate tests to run that may be inside of this line. There could be more than one!
        all_tests = tests
        if @line
          @tests_to_run = all_tests.within(@line)
        end
      end

      def build_test_arguments
        if @line
          abort_with_no_test_found_by_line_number if @tests_to_run.empty?

          # assemble the regexp to run these tests,
          test_names = @tests_to_run.map(&:escaped_name).join('|')

          # set up the args needed for the runner
          ["-n", "/(#{test_names})/"]
        elsif user_specified_name?
          abort_with_no_test_found_by_name unless tests.contains?(@test_name)

          ["-n", test_name_to_s]
        else
          []
        end
      end

      def abort_with_no_test_found_by_line_number
        abort_with_valid_tests_msg "No tests found on line #{@line}. "
      end

      def abort_with_no_test_found_by_name
        abort_with_valid_tests_msg "No test name matches '#{test_name_to_s}'. "
      end

      def abort_with_valid_tests_msg message=""
        message << "Valid tests to run:\n\n"
        # For every test ordered by line number,
        # spit out the test name and line number where it starts,
        tests.by_line_number do |test|
          message << "#{sprintf("%0#{tests.column_size}s", test.escaped_name)}: zeus test #{@files[0]}:#{test.start_line}\n"
        end

        # fail like a good unix process should.
        abort message
      end

      def test_name_to_s
        @test_name.is_a?(Regexp)? "/#{@test_name.source}/" : @test_name
      end

      def user_specified_name?
        !@test_name.nil?
      end

      def framework
        @framework ||= begin
          if defined?(Minitest::Runnable)
            :minitest5
          elsif defined?(MiniTest)
            :minitest_old
          elsif defined?(Test)
            if Test::Unit::TestCase.respond_to?(:test_suites)
              :testunit2
            else
              :testunit1
            end
          end
        end
      end

      # Finds all test suites in this test file, with test methods included.
      def suites
        # Since we're not using `ruby -Itest -Ilib` to run the tests, we need to add this directory to the `LOAD_PATH`
        $:.unshift "./test", "./lib"

        if framework == :testunit1
          Test::Unit::TestCase.class_eval {
            @@test_suites = {}
            def self.inherited(klass)
              @@test_suites[klass] = true
            end
            def self.test_suites
              @@test_suites.keys
            end
            def self.test_methods
              public_instance_methods(true).grep(/^test/).map(&:to_s)
            end
          }
        end

        begin
          # Fire up the Ruby files. Let's hope they actually have tests.
          @files.each { |f| load f }
        rescue LoadError => e
          # Fail with a happier error message instead of spitting out a backtrace from this gem
          abort "Failed loading test file:\n#{e.message}"
        end

        # Figure out what test framework we're using
        case framework
        when :minitest5
          suites = Minitest::Runnable.runnables
        when :minitest_old
          suites = MiniTest::Unit::TestCase.test_suites
        when :testunit1, :testunit2
          suites = Test::Unit::TestCase.test_suites
        else
          not_supported
        end

        # Use some janky internal APIs to group test methods by test suite.
        suites.inject({}) do |suites, suite_class|
          # End up with a hash of suite class name to an array of test methods, so we can later find them and ignore empty test suites
          test_methods = case framework
          when :minitest5
            suite_class.runnable_methods
          else
            suite_class.test_methods
          end
          suites[suite_class] = test_methods if test_methods.size > 0
          suites
        end
      end

      # Shoves tests together in our custom container and collection classes.
      # Memoize it since it's unnecessary to do this more than one for a given file.
      def tests
        @tests ||= begin
          # With each suite and array of tests,
          # and with each test method present in this test file,
          # shove a new test method into this collection.
          suites.inject(TestCollection.new) do |collection, (suite_class, test_methods)|
            test_methods.each do |test_method|
              find_locations = (@files.size == 1 && @line)
              collection << TestMethod.create(suite_class, test_method, find_locations)
            end
            collection
          end
        end
      end

      # Fail loudly if this isn't supported
      def not_supported
        abort "This test framework is not supported! Please open up an issue at https://github.com/qrush/m !"
      end
    end
  end
end