Object
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.
See ZenTestMapping for documentation on method naming.
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 579 579: def self.autotest(*klasses) 580: zentest = ZenTest.new 581: klasses.each do |klass| 582: zentest.process_class(klass) 583: end 584: 585: zentest.analyze 586: 587: zentest.missing_methods.each do |klass,methods| 588: methods.each do |method,x| 589: warn "autotest generating #{klass}##{method}" 590: end 591: end 592: 593: zentest.generate_code 594: code = zentest.result 595: puts code if $DEBUG 596: 597: Object.class_eval code 598: 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 566 566: def self.fix(*files) 567: ZenTest.usage_with_exit if files.empty? 568: zentest = ZenTest.new 569: zentest.scan_files(*files) 570: zentest.analyze 571: zentest.generate_code 572: return zentest.result 573: end
# File lib/zentest.rb, line 74 74: def initialize 75: @result = [] 76: @test_klasses = {} 77: @klasses = {} 78: @error_count = 0 79: @inherited_methods = Hash.new { |h,k| h[k] = {} } 80: # key = klassname, val = hash of methods => true 81: @missing_methods = Hash.new { |h,k| h[k] = {} } 82: end
Provide a certain amount of help.
# File lib/zentest.rb, line 523 523: def self.usage 524: puts usage: #{File.basename $0} [options] test-and-implementation-files...ZenTest scans your target and unit-test code and writes your missingcode based on simple naming rules, enabling XP at a much quickerpace. ZenTest only works with Ruby and Minitest or Test::Unit.ZenTest uses the following rules to figure out what code should begenerated:* 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). 525: end
Adds a missing method to the collected results.
# File lib/zentest.rb, line 328 328: def add_missing_method(klassname, methodname) 329: @result.push "# ERROR method #{klassname}\##{methodname} does not exist (1)" if $DEBUG and not $TESTING 330: @error_count += 1 331: @missing_methods[klassname][methodname] = true 332: 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 444 444: def analyze 445: # walk each known class and test that each method has a test method 446: @klasses.each_key do |klassname| 447: self.analyze_impl(klassname) 448: end 449: 450: # now do it in the other direction... 451: @test_klasses.each_key do |testklassname| 452: self.analyze_test(testklassname) 453: end 454: 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 344 344: def analyze_impl(klassname) 345: testklassname = self.convert_class_name(klassname) 346: if @test_klasses[testklassname] then 347: _, testmethods = methods_and_tests(klassname, testklassname) 348: 349: # check that each method has a test method 350: @klasses[klassname].each_key do | methodname | 351: testmethodname = normal_to_test(methodname) 352: unless testmethods[testmethodname] then 353: begin 354: unless testmethods.keys.find { |m| m =~ /#{testmethodname}(_\w+)+$/ } then 355: self.add_missing_method(testklassname, testmethodname) 356: end 357: rescue RegexpError 358: puts "# ERROR trying to use '#{testmethodname}' as a regex. Look at #{klassname}.#{methodname}" 359: end 360: end # testmethods[testmethodname] 361: end # @klasses[klassname].each_key 362: else # ! @test_klasses[testklassname] 363: puts "# ERROR test class #{testklassname} does not exist" if $DEBUG 364: @error_count += 1 365: 366: @klasses[klassname].keys.each do | methodname | 367: self.add_missing_method(testklassname, normal_to_test(methodname)) 368: end 369: end # @test_klasses[testklassname] 370: 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 375 375: def analyze_test(testklassname) 376: klassname = self.convert_class_name(testklassname) 377: 378: # CUT might be against a core class, if so, slurp it and analyze it 379: if $stdlib[klassname] then 380: self.process_class(klassname, true) 381: self.analyze_impl(klassname) 382: end 383: 384: if @klasses[klassname] then 385: methods, testmethods = methods_and_tests(klassname,testklassname) 386: 387: # check that each test method has a method 388: testmethods.each_key do | testmethodname | 389: if testmethodname =~ /^test_(?!integration_)/ then 390: 391: # try the current name 392: methodname = test_to_normal(testmethodname, klassname) 393: orig_name = methodname.dup 394: 395: found = false 396: until methodname == "" or methods[methodname] or @inherited_methods[klassname][methodname] do 397: # try the name minus an option (ie mut_opt1 -> mut) 398: if methodname.sub!(/_[^_]+$/, '') then 399: if methods[methodname] or @inherited_methods[klassname][methodname] then 400: found = true 401: end 402: else 403: break # no more substitutions will take place 404: end 405: end # methodname == "" or ... 406: 407: unless found or methods[methodname] or methodname == "initialize" then 408: self.add_missing_method(klassname, orig_name) 409: end 410: 411: else # not a test_.* method 412: unless testmethodname =~ /^util_/ then 413: puts "# WARNING Skipping #{testklassname}\##{testmethodname}" if $DEBUG 414: end 415: end # testmethodname =~ ... 416: end # testmethods.each_key 417: else # ! @klasses[klassname] 418: puts "# ERROR class #{klassname} does not exist" if $DEBUG 419: @error_count += 1 420: 421: @test_klasses[testklassname].keys.each do |testmethodname| 422: @missing_methods[klassname][test_to_normal(testmethodname)] = true 423: end 424: end # @klasses[klassname] 425: 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 192 192: def convert_class_name(name) 193: name = name.to_s 194: 195: if self.is_test_class(name) then 196: if $r then 197: name = name.gsub(/Test($|::)/, '\1') # FooTest::BlahTest => Foo::Blah 198: else 199: name = name.gsub(/(^|::)Test/, '\1') # TestFoo::TestBlah => Foo::Blah 200: end 201: else 202: if $r then 203: name = name.gsub(/($|::)/, 'Test\1') # Foo::Blah => FooTest::BlahTest 204: else 205: name = name.gsub(/(^|::)/, '\1Test') # Foo::Blah => TestFoo::TestBlah 206: end 207: end 208: 209: return name 210: end
create a given method at a given indentation. Returns an array containing the lines of the method.
# File lib/zentest.rb, line 430 430: def create_method(indentunit, indent, name) 431: meth = [] 432: meth.push indentunit*indent + "def #{name}" 433: meth.last << "(*args)" unless name =~ /^test/ 434: indent += 1 435: meth.push indentunit*indent + "raise NotImplementedError, 'Need to write #{name}'" 436: indent -= 1 437: meth.push indentunit*indent + "end" 438: return meth 439: 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 460 460: def generate_code 461: @result.unshift "# Code Generated by ZenTest v. #{VERSION}" 462: 463: if $DEBUG then 464: @result.push "# found classes: #{@klasses.keys.join(', ')}" 465: @result.push "# found test classes: #{@test_klasses.keys.join(', ')}" 466: end 467: 468: if @missing_methods.size > 0 then 469: @result.push "" 470: @result.push "require 'test/unit/testcase'" 471: @result.push "require 'test/unit' if $0 == __FILE__" 472: @result.push "" 473: end 474: 475: indentunit = " " 476: 477: @missing_methods.keys.sort.each do |fullklasspath| 478: methods = @missing_methods[fullklasspath] 479: cls_methods = methods.keys.grep(/^(self\.|test_class_)/) 480: methods.delete_if {|k,v| cls_methods.include? k } 481: 482: next if methods.empty? and cls_methods.empty? 483: 484: indent = 0 485: is_test_class = self.is_test_class(fullklasspath) 486: 487: clsname = $t ? "Test::Unit::TestCase" : "MiniTest::Unit::TestCase" 488: superclass = is_test_class ? " < #{clsname}" : '' 489: 490: @result.push indentunit*indent + "class #{fullklasspath}#{superclass}" 491: indent += 1 492: 493: meths = [] 494: 495: cls_methods.sort.each do |method| 496: meth = create_method(indentunit, indent, method) 497: meths.push meth.join("\n") 498: end 499: 500: methods.keys.sort.each do |method| 501: next if method =~ /pretty_print/ 502: meth = create_method(indentunit, indent, method) 503: meths.push meth.join("\n") 504: end 505: 506: @result.push meths.join("\n\n") 507: 508: indent -= 1 509: @result.push indentunit*indent + "end" 510: @result.push '' 511: end 512: 513: @result.push "# Number of errors detected: #{@error_count}" 514: @result.push '' 515: end
obtain the class klassname, either from Module or using ObjectSpace to search for it.
# File lib/zentest.rb, line 101 101: def get_class(klassname) 102: begin 103: klass = klassname.split(/::/).inject(Object) { |k,n| k.const_get n } 104: puts "# found class #{klass.name}" if $DEBUG 105: rescue NameError 106: ObjectSpace.each_object(Class) do |cls| 107: if cls.name =~ /(^|::)#{klassname}$/ then 108: klass = cls 109: klassname = cls.name 110: break 111: end 112: end 113: puts "# searched and found #{klass.name}" if klass and $DEBUG 114: end 115: 116: if klass.nil? and not $TESTING then 117: puts "Could not figure out how to get #{klassname}..." 118: puts "Report to support-zentest@zenspider.com w/ relevant source" 119: end 120: 121: return klass 122: 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 157 157: def get_inherited_methods_for(klass, full) 158: klass = self.get_class(klass) if klass.kind_of? String 159: 160: klassmethods = {} 161: if (klass.class.method_defined?(:superclass)) then 162: superklass = klass.superclass 163: if superklass then 164: the_methods = superklass.instance_methods(true) 165: 166: # generally we don't test Object's methods... 167: unless full then 168: the_methods -= Object.instance_methods(true) 169: the_methods -= Kernel.methods # FIX (true) - check 1.6 vs 1.8 170: end 171: 172: the_methods.each do |meth| 173: klassmethods[meth.to_s] = true 174: end 175: end 176: end 177: return klassmethods 178: 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 129 129: def get_methods_for(klass, full=false) 130: klass = self.get_class(klass) if klass.kind_of? String 131: 132: # WTF? public_instance_methods: default vs true vs false = 3 answers 133: # to_s on all results if ruby >= 1.9 134: public_methods = klass.public_instance_methods(false) 135: public_methods -= Kernel.methods unless full 136: public_methods.map! { |m| m.to_s } 137: public_methods -= %(pretty_print pretty_print_cycle) 138: 139: klass_methods = klass.singleton_methods(full) 140: klass_methods -= Class.public_methods(true) 141: klass_methods = klass_methods.map { |m| "self.#{m}" } 142: klass_methods -= %(self.suite new) 143: 144: result = {} 145: (public_methods + klass_methods).each do |meth| 146: puts "# found method #{meth}" if $DEBUG 147: result[meth] = true 148: end 149: 150: return result 151: end
Check the class klass is a testing class (by inspecting its name).
# File lib/zentest.rb, line 182 182: def is_test_class(klass) 183: klass = klass.to_s 184: klasspath = klass.split(/::/) 185: a_bad_classpath = klasspath.find do |s| s !~ ($r ? /Test$/ : /^Test/) end 186: return a_bad_classpath.nil? 187: end
load_file wraps require, skipping the loading of $0.
# File lib/zentest.rb, line 85 85: def load_file(file) 86: puts "# loading #{file} // #{$0}" if $DEBUG 87: 88: unless file == $0 then 89: begin 90: require file 91: rescue LoadError => err 92: puts "Could not load #{file}: #{err}" 93: end 94: else 95: puts "# Skipping loading myself (#{file})" if $DEBUG 96: end 97: 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 337 337: def methods_and_tests(klassname, testklassname) 338: return @klasses[klassname], @test_klasses[testklassname] 339: end
# File lib/zentest.rb, line 71 71: 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 217 217: def process_class(klassname, full=false) 218: klass = self.get_class(klassname) 219: raise "Couldn't get class for #{klassname}" if klass.nil? 220: klassname = klass.name # refetch to get full name 221: 222: is_test_class = self.is_test_class(klassname) 223: target = is_test_class ? @test_klasses : @klasses 224: 225: # record public instance methods JUST in this class 226: target[klassname] = self.get_methods_for(klass, full) 227: 228: # record ALL instance methods including superclasses (minus Object) 229: # Only minus Object if full is true. 230: @inherited_methods[klassname] = self.get_inherited_methods_for(klass, full) 231: return klassname 232: end
presents results in a readable manner.
# File lib/zentest.rb, line 518 518: def result 519: return @result.join("\n") 520: 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 239 239: def scan_files(*files) 240: assert_count = Hash.new(0) 241: method_count = Hash.new(0) 242: klassname = nil 243: 244: files.each do |path| 245: is_loaded = false 246: 247: # if reading stdin, slurp the whole thing at once 248: file = (path == "-" ? $stdin.read : File.new(path)) 249: 250: file.each_line do |line| 251: 252: if klassname then 253: case line 254: when /^\s*def/ then 255: method_count[klassname] += 1 256: when /assert|flunk/ then 257: assert_count[klassname] += 1 258: end 259: end 260: 261: if line =~ /^\s*(?:class|module)\s+([\w:]+)/ then 262: klassname = $1 263: 264: if line =~ /\#\s*ZenTest SKIP/ then 265: klassname = nil 266: next 267: end 268: 269: full = false 270: if line =~ /\#\s*ZenTest FULL/ then 271: full = true 272: end 273: 274: unless is_loaded then 275: unless path == "-" then 276: self.load_file(path) 277: else 278: eval file, TOPLEVEL_BINDING 279: end 280: is_loaded = true 281: end 282: 283: begin 284: klassname = self.process_class(klassname, full) 285: rescue 286: puts "# Couldn't find class for name #{klassname}" 287: next 288: end 289: 290: # Special Case: ZenTest is already loaded since we are running it 291: if klassname == "TestZenTest" then 292: klassname = "ZenTest" 293: self.process_class(klassname, false) 294: end 295: 296: end # if /class/ 297: end # IO.foreach 298: end # files 299: 300: result = [] 301: method_count.each_key do |classname| 302: 303: entry = {} 304: 305: next if is_test_class(classname) 306: testclassname = convert_class_name(classname) 307: a_count = assert_count[testclassname] 308: m_count = method_count[classname] 309: ratio = a_count.to_f / m_count.to_f * 100.0 310: 311: entry['n'] = classname 312: entry['r'] = ratio 313: entry['a'] = a_count 314: entry['m'] = m_count 315: 316: result.push entry 317: end 318: 319: sorted_results = result.sort { |a,b| b['r'] <=> a['r'] } 320: 321: @result.push sprintf("# %25s: %4s / %4s = %6s%%", "classname", "asrt", "meth", "ratio") 322: sorted_results.each do |e| 323: @result.push sprintf("# %25s: %4d / %4d = %6.2f%%", e['n'], e['a'], e['m'], e['r']) 324: end 325: end
Disabled; run with --debug to generate this.
Generated with the Darkfish Rdoc Generator 1.1.6.