if (defined? RUBY_ENGINE) && RUBY_ENGINE == 'jruby'
require 'java'
JRuby.objectspace=true
end
$stdlib = {}
if ObjectSpace.respond_to?(:loaded_classes, true) then
ObjectSpace.loaded_classes(true).each do |m|
$stdlib[m.name] = true if m.respond_to? :name
end
else
ObjectSpace.each_object(Module) do |m|
$stdlib[m.name] = true if m.respond_to? :name
end
end
require 'zentest_mapping'
$:.unshift( *$I.split(/:/) ) if defined? $I and String === $I
$r = false unless defined? $r # reverse mapping for testclass names
$t ||= false # test/unit instead of minitest
if $r then
# all this is needed because rails is retarded
$-w = false
$: << 'test'
$: << 'lib'
require 'config/environment'
f = './app/controllers/application.rb'
require f if test ?f, f
end
$TESTING = true
class Module
def zentest
at_exit { ZenTest.autotest(self) }
end
end
##
# 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:
#
# * 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.
#
# See ZenTestMapping for documentation on method naming.
class ZenTest
VERSION = "4.12.2"
include ZenTestMapping
if $TESTING then
attr_reader :missing_methods
attr_accessor :test_klasses
attr_accessor :klasses
attr_accessor :inherited_methods
else
def missing_methods; raise "Something is wack"; end
end
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
# load_file wraps require, skipping the loading of $0.
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
# obtain the class klassname
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 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.
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
# 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.
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
# Check the class klass is a testing class
# (by inspecting its name).
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
# 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.
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
# 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).
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
# 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.
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
# Adds a missing method to the collected results.
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
# looks up the methods and the corresponding test methods
# in the collection already built. To reduce duplication
# and hide implementation details.
def methods_and_tests(klassname, testklassname)
return @klasses[klassname], @test_klasses[testklassname]
end
# 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
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
# 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.
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
# create a given method at a given
# indentation. Returns an array containing
# the lines of the method.
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
# Walk each known class and test that each method has
# a test method
# Then do it in the other direction...
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
# 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.
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
# presents results in a readable manner.
def result
return @result.join("\n")
end
# Provide a certain amount of help.
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
# Give help, then quit.
def self.usage_with_exit
self.usage
exit 0
end
# 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.
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
# 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.
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
end