class ZenTest

ZenTest scans your target and unit-test code and writes your missing code based on simple naming rules, enabling XP at a much quicker pace. ZenTest only works with Ruby and Minitest or Test::Unit.

RULES

ZenTest uses the following rules to figure out what code should be generated:

See ZenTestMapping for documentation on method naming.

Constants

VERSION

Public Class Methods

autotest(*klasses) click to toggle source

Process all the supplied classes for methods etc, and analyse the results. Generate the skeletal code and eval it to put the methods into the runtime environment.

# File lib/zentest.rb, line 581
def self.autotest(*klasses)
  zentest = ZenTest.new
  klasses.each do |klass|
    zentest.process_class(klass)
  end

  zentest.analyze

  zentest.missing_methods.each do |klass,methods|
    methods.each do |method,x|
      warn "autotest generating #{klass}##{method}"
    end
  end

  zentest.generate_code
  code = zentest.result
  puts code if $DEBUG

  Object.class_eval code
end
fix(*files) click to toggle source

Runs ZenTest over all the supplied files so that they are analysed and the missing methods have skeleton code written. If no files are supplied, splutter out some help.

# File lib/zentest.rb, line 568
def self.fix(*files)
  ZenTest.usage_with_exit if files.empty?
  zentest = ZenTest.new
  zentest.scan_files(*files)
  zentest.analyze
  zentest.generate_code
  return zentest.result
end
new() click to toggle source
# File lib/zentest.rb, line 80
def initialize
  @result = []
  @test_klasses = {}
  @klasses = {}
  @error_count = 0
  @inherited_methods = Hash.new { |h,k| h[k] = {} }
  # key = klassname, val = hash of methods => true
  @missing_methods = Hash.new { |h,k| h[k] = {} }
end
usage() click to toggle source

Provide a certain amount of help.

# File lib/zentest.rb, line 525
  def self.usage
    puts <<-EO_USAGE
usage: #{File.basename $0} [options] test-and-implementation-files...

ZenTest scans your target and unit-test code and writes your missing
code based on simple naming rules, enabling XP at a much quicker
pace. ZenTest only works with Ruby and Minitest or Test::Unit.

ZenTest uses the following rules to figure out what code should be
generated:

* Definition:
  * CUT = Class Under Test
  * TC = Test Class (for CUT)
* TC's name is the same as CUT w/ "Test" prepended at every scope level.
  * Example: TestA::TestB vs A::B.
* CUT method names are used in CT, with "test_" prependend and optional "_ext" extensions for differentiating test case edge boundaries.
  * Example:
    * A::B#blah
    * TestA::TestB#test_blah_normal
    * TestA::TestB#test_blah_missing_file
* All naming conventions are bidirectional with the exception of test extensions.

options:
  -h display this information
  -v display version information
  -r Reverse mapping (ClassTest instead of TestClass)
  -e (Rapid XP) eval the code generated instead of printing it
  -t test/unit generation (default is minitest).

    EO_USAGE
  end
usage_with_exit() click to toggle source

Give help, then quit.

# File lib/zentest.rb, line 559
def self.usage_with_exit
  self.usage
  exit 0
end

Public Instance Methods

add_missing_method(klassname, methodname) click to toggle source

Adds a missing method to the collected results.

# File lib/zentest.rb, line 326
def add_missing_method(klassname, methodname)
  @result.push "# ERROR method #{klassname}\##{methodname} does not exist (1)" if $DEBUG and not $TESTING
  @error_count += 1
  @missing_methods[klassname][methodname] = true
end
analyze() click to toggle source

Walk each known class and test that each method has a test method Then do it in the other direction…

# File lib/zentest.rb, line 442
def analyze
  # walk each known class and test that each method has a test method
  @klasses.each_key do |klassname|
    self.analyze_impl(klassname)
  end

  # now do it in the other direction...
  @test_klasses.each_key do |testklassname|
    self.analyze_test(testklassname)
  end
end
analyze_impl(klassname) click to toggle source

Checks, for the given class klassname, that each method has a corrsponding test method. If it doesn’t this is added to the information for that class

# File lib/zentest.rb, line 342
def analyze_impl(klassname)
  testklassname = self.convert_class_name(klassname)
  if @test_klasses[testklassname] then
    _, testmethods = methods_and_tests(klassname, testklassname)

    # check that each method has a test method
    @klasses[klassname].each_key do | methodname |
      testmethodname = normal_to_test(methodname)
      unless testmethods[testmethodname] then
        begin
          unless testmethods.keys.find { |m| m =~ /#{testmethodname}(_\w+)+$/ } then
            self.add_missing_method(testklassname, testmethodname)
          end
        rescue RegexpError
          puts "# ERROR trying to use '#{testmethodname}' as a regex. Look at #{klassname}.#{methodname}"
        end
      end # testmethods[testmethodname]
    end # @klasses[klassname].each_key
  else # ! @test_klasses[testklassname]
    puts "# ERROR test class #{testklassname} does not exist" if $DEBUG
    @error_count += 1

    @klasses[klassname].keys.each do | methodname |
      self.add_missing_method(testklassname, normal_to_test(methodname))
    end
  end # @test_klasses[testklassname]
end
analyze_test(testklassname) click to toggle source

For the given test class testklassname, ensure that all the test methods have corresponding (normal) methods. If not, add them to the information about that class.

# File lib/zentest.rb, line 373
def analyze_test(testklassname)
  klassname = self.convert_class_name(testklassname)

  # CUT might be against a core class, if so, slurp it and analyze it
  if $stdlib[klassname] then
    self.process_class(klassname, true)
    self.analyze_impl(klassname)
  end

  if @klasses[klassname] then
    methods, testmethods = methods_and_tests(klassname,testklassname)

    # check that each test method has a method
    testmethods.each_key do | testmethodname |
      if testmethodname =~ /^test_(?!integration_)/ then

        # try the current name
        methodname = test_to_normal(testmethodname, klassname)
        orig_name = methodname.dup

        found = false
        until methodname == "" or methods[methodname] or @inherited_methods[klassname][methodname] do
            # try the name minus an option (ie mut_opt1 -> mut)
          if methodname.sub!(/_[^_]+$/, '') then
            if methods[methodname] or @inherited_methods[klassname][methodname] then
              found = true
            end
          else
            break # no more substitutions will take place
          end
        end # methodname == "" or ...

        unless found or methods[methodname] or methodname == "initialize" then
          self.add_missing_method(klassname, orig_name)
        end

      else # not a test_.* method
        unless testmethodname =~ /^util_/ then
          puts "# WARNING Skipping #{testklassname}\##{testmethodname}" if $DEBUG
        end
      end # testmethodname =~ ...
    end # testmethods.each_key
  else # ! @klasses[klassname]
    puts "# ERROR class #{klassname} does not exist" if $DEBUG
    @error_count += 1

    @test_klasses[testklassname].keys.each do |testmethodname|
      @missing_methods[klassname][test_to_normal(testmethodname)] = true
    end
  end # @klasses[klassname]
end
convert_class_name(name) click to toggle source

Generate the name of a testclass from non-test class so that Foo::Blah => TestFoo::TestBlah, etc. It the name is already a test class, convert it the other way.

# File lib/zentest.rb, line 190
def convert_class_name(name)
  name = name.to_s

  if self.is_test_class(name) then
    if $r then
      name = name.gsub(/Test($|::)/, '\1') # FooTest::BlahTest => Foo::Blah
    else
      name = name.gsub(/(^|::)Test/, '\1') # TestFoo::TestBlah => Foo::Blah
    end
  else
    if $r then
      name = name.gsub(/($|::)/, 'Test\1') # Foo::Blah => FooTest::BlahTest
    else
      name = name.gsub(/(^|::)/, '\1Test') # Foo::Blah => TestFoo::TestBlah
    end
  end

  return name
end
create_method(indentunit, indent, name) click to toggle source

create a given method at a given indentation. Returns an array containing the lines of the method.

# File lib/zentest.rb, line 428
def create_method(indentunit, indent, name)
  meth = []
  meth.push indentunit*indent + "def #{name}"
  meth.last << "(*args)" unless name =~ /^test/
  indent += 1
  meth.push indentunit*indent + "raise NotImplementedError, 'Need to write #{name}'"
  indent -= 1
  meth.push indentunit*indent + "end"
  return meth
end
generate_code() click to toggle source

Using the results gathered during analysis generate skeletal code with methods raising NotImplementedError, so that they can be filled in later, and so the tests will fail to start with.

# File lib/zentest.rb, line 458
def generate_code
  @result.unshift "# Code Generated by ZenTest v. #{VERSION}"

  if $DEBUG then
    @result.push "# found classes: #{@klasses.keys.join(', ')}"
    @result.push "# found test classes: #{@test_klasses.keys.join(', ')}"
  end

  if @missing_methods.size > 0 then
    @result.push ""
    if $t then
      @result.push "require 'test/unit/testcase'"
      @result.push "require 'test/unit' if $0 == __FILE__"
    else
      @result.push "require 'minitest/autorun'"
    end
    @result.push ""
  end

  indentunit = "  "

  @missing_methods.keys.sort.each do |fullklasspath|
    methods = @missing_methods[fullklasspath]
    cls_methods = methods.keys.grep(/^(self\.|test_class_)/)
    methods.delete_if {|k,v| cls_methods.include? k }

    next if methods.empty? and cls_methods.empty?

    indent = 0
    is_test_class = self.is_test_class(fullklasspath)

    clsname = $t ? "Test::Unit::TestCase" : "Minitest::Test"
    superclass = is_test_class ? " < #{clsname}" : ''

    @result.push indentunit*indent + "class #{fullklasspath}#{superclass}"
    indent += 1

    meths = []

    cls_methods.sort.each do |method|
      meth = create_method(indentunit, indent, method)
      meths.push meth.join("\n")
    end

    methods.keys.sort.each do |method|
      next if method =~ /pretty_print/
      meth = create_method(indentunit, indent, method)
      meths.push meth.join("\n")
    end

    @result.push meths.join("\n\n")

    indent -= 1
    @result.push indentunit*indent + "end"
    @result.push ''
  end

  @result.push "# Number of errors detected: #{@error_count}"
  @result.push ''
end
get_class(klassname) click to toggle source

obtain the class klassname

# File lib/zentest.rb, line 106
def get_class(klassname)
  klass = nil
  begin
    klass = klassname.split(/::/).inject(Object) { |k,n| k.const_get n }
    puts "# found class #{klass.name}" if $DEBUG
  rescue NameError
  end

  if klass.nil? and not $TESTING then
    puts "Could not figure out how to get #{klassname}..."
    puts "Report to support-zentest@zenspider.com w/ relevant source"
  end

  return klass
end
get_inherited_methods_for(klass, full) click to toggle source

Return the methods for class klass, as a hash with the method nemas as keys, and true as the value for all keys. Unless full is true, leave out the methods for Object which all classes get.

# File lib/zentest.rb, line 155
def get_inherited_methods_for(klass, full)
  klass = self.get_class(klass) if klass.kind_of? String

  klassmethods = {}
  if (klass.class.method_defined?(:superclass)) then
    superklass = klass.superclass
    if superklass then
      the_methods = superklass.instance_methods(true)

      # generally we don't test Object's methods...
      unless full then
        the_methods -= Object.instance_methods(true)
        the_methods -= Kernel.methods # FIX (true) - check 1.6 vs 1.8
      end

      the_methods.each do |meth|
        klassmethods[meth.to_s] = true
      end
    end
  end
  return klassmethods
end
get_methods_for(klass, full=false) click to toggle source

Get the public instance, class and singleton methods for class klass. If full is true, include the methods from Kernel and other modules that get included. The methods suite, new, pretty_print, pretty_print_cycle will not be included in the resuting array.

# File lib/zentest.rb, line 127
def get_methods_for(klass, full=false)
  klass = self.get_class(klass) if klass.kind_of? String

  # WTF? public_instance_methods: default vs true vs false = 3 answers
  # to_s on all results if ruby >= 1.9
  public_methods = klass.public_instance_methods(false)
  public_methods -= Kernel.methods unless full
  public_methods.map! { |m| m.to_s }
  public_methods -= %w(pretty_print pretty_print_cycle)

  klass_methods = klass.singleton_methods(full)
  klass_methods -= Class.public_methods(true)
  klass_methods = klass_methods.map { |m| "self.#{m}" }
  klass_methods  -= %w(self.suite new)

  result = {}
  (public_methods + klass_methods).each do |meth|
    puts "# found method #{meth}" if $DEBUG
    result[meth] = true
  end

  return result
end
is_test_class(klass) click to toggle source

Check the class klass is a testing class (by inspecting its name).

# File lib/zentest.rb, line 180
def is_test_class(klass)
  klass = klass.to_s
  klasspath = klass.split(/::/)
  a_bad_classpath = klasspath.find do |s| s !~ ($r ? /Test$/ : /^Test/) end
  return a_bad_classpath.nil?
end
load_file(file) click to toggle source

load_file wraps require, skipping the loading of $0.

# File lib/zentest.rb, line 91
def load_file(file)
  puts "# loading #{file} // #{$0}" if $DEBUG

  unless file == $0 then
    begin
      require file
    rescue LoadError => err
      puts "Could not load #{file}: #{err}"
    end
  else
    puts "# Skipping loading myself (#{file})" if $DEBUG
  end
end
methods_and_tests(klassname, testklassname) click to toggle source

looks up the methods and the corresponding test methods in the collection already built. To reduce duplication and hide implementation details.

# File lib/zentest.rb, line 335
def methods_and_tests(klassname, testklassname)
  return @klasses[klassname], @test_klasses[testklassname]
end
missing_methods() click to toggle source
# File lib/zentest.rb, line 77
def missing_methods; raise "Something is wack"; end
process_class(klassname, full=false) click to toggle source

Does all the work of finding a class by name, obtaining its methods and those of its superclass. The full parameter determines if all the methods including those of Object and mixed in modules are obtained (true if they are, false by default).

# File lib/zentest.rb, line 215
def process_class(klassname, full=false)
  klass = self.get_class(klassname)
  raise "Couldn't get class for #{klassname}" if klass.nil?
  klassname = klass.name # refetch to get full name

  is_test_class = self.is_test_class(klassname)
  target = is_test_class ? @test_klasses : @klasses

  # record public instance methods JUST in this class
  target[klassname] = self.get_methods_for(klass, full)

  # record ALL instance methods including superclasses (minus Object)
  # Only minus Object if full is true.
  @inherited_methods[klassname] = self.get_inherited_methods_for(klass, full)
  return klassname
end
result() click to toggle source

presents results in a readable manner.

# File lib/zentest.rb, line 520
def result
  return @result.join("\n")
end
scan_files(*files) click to toggle source

Work through files, collecting class names, method names and assertions. Detects ZenTest (SKIP|FULL) comments in the bodies of classes. For each class a count of methods and test methods is kept, and the ratio noted.

# File lib/zentest.rb, line 237
def scan_files(*files)
  assert_count = Hash.new(0)
  method_count = Hash.new(0)
  klassname = nil

  files.each do |path|
    is_loaded = false

    # if reading stdin, slurp the whole thing at once
    file = (path == "-" ? $stdin.read : File.new(path))

    file.each_line do |line|

      if klassname then
        case line
        when /^\s*def/ then
          method_count[klassname] += 1
        when /assert|flunk/ then
          assert_count[klassname] += 1
        end
      end

      if line =~ /^\s*(?:class|module)\s+([\w:]+)/ then
        klassname = $1

        if line =~ /\#\s*ZenTest SKIP/ then
          klassname = nil
          next
        end

        full = false
        if line =~ /\#\s*ZenTest FULL/ then
          full = true
        end

        unless is_loaded then
          unless path == "-" then
            self.load_file(path)
          else
            eval file, TOPLEVEL_BINDING
          end
          is_loaded = true
        end

        begin
          klassname = self.process_class(klassname, full)
        rescue
          puts "# Couldn't find class for name #{klassname}"
          next
        end

        # Special Case: ZenTest is already loaded since we are running it
        if klassname == "TestZenTest" then
          klassname = "ZenTest"
          self.process_class(klassname, false)
        end

      end # if /class/
    end # IO.foreach
  end # files

  result = []
  method_count.each_key do |classname|

    entry = {}

    next if is_test_class(classname)
    testclassname = convert_class_name(classname)
    a_count = assert_count[testclassname]
    m_count = method_count[classname]
    ratio = a_count.to_f / m_count.to_f * 100.0

    entry['n'] = classname
    entry['r'] = ratio
    entry['a'] = a_count
    entry['m'] = m_count

    result.push entry
  end

  sorted_results = result.sort { |a,b| b['r'] <=> a['r'] }

  @result.push sprintf("# %25s: %4s / %4s = %6s%%", "classname", "asrt", "meth", "ratio")
  sorted_results.each do |e|
    @result.push sprintf("# %25s: %4d / %4d = %6.2f%%", e['n'], e['a'], e['m'], e['r'])
  end
end