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:
-
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.
Constants
- VERSION
Public Class Methods
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
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
# 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
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
Give help, then quit.
# File lib/zentest.rb, line 559 def self.usage_with_exit self.usage exit 0 end
Public Instance Methods
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
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
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
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
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 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
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
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
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 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
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
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
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
# File lib/zentest.rb, line 77 def missing_methods; raise "Something is wack"; 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).
# 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
presents results in a readable manner.
# File lib/zentest.rb, line 520 def result return @result.join("\n") 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.
# 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