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 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 556 556: def self.autotest(*klasses) 557: zentest = ZenTest.new 558: klasses.each do |klass| 559: zentest.process_class(klass) 560: end 561: 562: zentest.analyze 563: 564: zentest.missing_methods.each do |klass,methods| 565: methods.each do |method,x| 566: warn "autotest generating #{klass}##{method}" 567: end 568: end 569: 570: zentest.generate_code 571: code = zentest.result 572: puts code if $DEBUG 573: 574: Object.class_eval code 575: end
Runs ZenTest over all the supplied files so that they are analysed and the missing methods have skeleton code written.
# File lib/zentest.rb, line 544 544: def self.fix(*files) 545: zentest = ZenTest.new 546: zentest.scan_files(*files) 547: zentest.analyze 548: zentest.generate_code 549: return zentest.result 550: end
# File lib/zentest.rb, line 69 69: def initialize 70: @result = [] 71: @test_klasses = {} 72: @klasses = {} 73: @error_count = 0 74: @inherited_methods = Hash.new { |h,k| h[k] = {} } 75: # key = klassname, val = hash of methods => true 76: @missing_methods = Hash.new { |h,k| h[k] = {} } 77: end
Adds a missing method to the collected results.
# File lib/zentest.rb, line 323 323: def add_missing_method(klassname, methodname) 324: @result.push "# ERROR method #{klassname}\##{methodname} does not exist (1)" if $DEBUG and not $TESTING 325: @error_count += 1 326: @missing_methods[klassname][methodname] = true 327: 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 453 453: def analyze 454: # walk each known class and test that each method has a test method 455: @klasses.each_key do |klassname| 456: self.analyze_impl(klassname) 457: end 458: 459: # now do it in the other direction... 460: @test_klasses.each_key do |testklassname| 461: self.analyze_test(testklassname) 462: end 463: 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 339 339: def analyze_impl(klassname) 340: testklassname = self.convert_class_name(klassname) 341: if @test_klasses[testklassname] then 342: methods, testmethods = methods_and_tests(klassname,testklassname) 343: 344: # check that each method has a test method 345: @klasses[klassname].each_key do | methodname | 346: testmethodname = normal_to_test(methodname) 347: unless testmethods[testmethodname] then 348: begin 349: unless testmethods.keys.find { |m| m =~ /#{testmethodname}(_\w+)+$/ } then 350: self.add_missing_method(testklassname, testmethodname) 351: end 352: rescue RegexpError => e 353: puts "# ERROR trying to use '#{testmethodname}' as a regex. Look at #{klassname}.#{methodname}" 354: end 355: end # testmethods[testmethodname] 356: end # @klasses[klassname].each_key 357: else # ! @test_klasses[testklassname] 358: puts "# ERROR test class #{testklassname} does not exist" if $DEBUG 359: @error_count += 1 360: 361: @klasses[klassname].keys.each do | methodname | 362: self.add_missing_method(testklassname, normal_to_test(methodname)) 363: end 364: end # @test_klasses[testklassname] 365: 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 370 370: def analyze_test(testklassname) 371: klassname = self.convert_class_name(testklassname) 372: 373: # CUT might be against a core class, if so, slurp it and analyze it 374: if $stdlib[klassname] then 375: self.process_class(klassname, true) 376: self.analyze_impl(klassname) 377: end 378: 379: if @klasses[klassname] then 380: methods, testmethods = methods_and_tests(klassname,testklassname) 381: 382: # check that each test method has a method 383: testmethods.each_key do | testmethodname | 384: if testmethodname =~ /^test_(?!integration_)/ then 385: 386: # try the current name 387: methodname = test_to_normal(testmethodname, klassname) 388: orig_name = methodname.dup 389: 390: found = false 391: until methodname == "" or methods[methodname] or @inherited_methods[klassname][methodname] do 392: # try the name minus an option (ie mut_opt1 -> mut) 393: if methodname.sub!(/_[^_]+$/, '') then 394: if methods[methodname] or @inherited_methods[klassname][methodname] then 395: found = true 396: end 397: else 398: break # no more substitutions will take place 399: end 400: end # methodname == "" or ... 401: 402: unless found or methods[methodname] or methodname == "initialize" then 403: self.add_missing_method(klassname, orig_name) 404: end 405: 406: else # not a test_.* method 407: unless testmethodname =~ /^util_/ then 408: puts "# WARNING Skipping #{testklassname}\##{testmethodname}" if $DEBUG 409: end 410: end # testmethodname =~ ... 411: end # testmethods.each_key 412: else # ! @klasses[klassname] 413: puts "# ERROR class #{klassname} does not exist" if $DEBUG 414: @error_count += 1 415: 416: @test_klasses[testklassname].keys.each do |testmethodname| 417: @missing_methods[klassname][test_to_normal(testmethodname)] = true 418: end 419: end # @klasses[klassname] 420: 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 187 187: def convert_class_name(name) 188: name = name.to_s 189: 190: if self.is_test_class(name) then 191: if $r then 192: name = name.gsub(/Test($|::)/, '\1') # FooTest::BlahTest => Foo::Blah 193: else 194: name = name.gsub(/(^|::)Test/, '\1') # TestFoo::TestBlah => Foo::Blah 195: end 196: else 197: if $r then 198: name = name.gsub(/($|::)/, 'Test\1') # Foo::Blah => FooTest::BlahTest 199: else 200: name = name.gsub(/(^|::)/, '\1Test') # Foo::Blah => TestFoo::TestBlah 201: end 202: end 203: 204: return name 205: end
create a given method at a given indentation. Returns an array containing the lines of the method.
# File lib/zentest.rb, line 439 439: def create_method(indentunit, indent, name) 440: meth = [] 441: meth.push indentunit*indent + "def #{name}" 442: meth.last << "(*args)" unless name =~ /^test/ 443: indent += 1 444: meth.push indentunit*indent + "raise NotImplementedError, 'Need to write #{name}'" 445: indent -= 1 446: meth.push indentunit*indent + "end" 447: return meth 448: 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 469 469: def generate_code 470: @result.unshift "# Code Generated by ZenTest v. #{VERSION}" 471: 472: if $DEBUG then 473: @result.push "# found classes: #{@klasses.keys.join(', ')}" 474: @result.push "# found test classes: #{@test_klasses.keys.join(', ')}" 475: end 476: 477: if @missing_methods.size > 0 then 478: @result.push "" 479: @result.push "require 'test/unit/testcase'" 480: @result.push "require 'test/unit' if $0 == __FILE__" 481: @result.push "" 482: end 483: 484: indentunit = " " 485: 486: @missing_methods.keys.sort.each do |fullklasspath| 487: 488: methods = @missing_methods[fullklasspath] 489: cls_methods = methods.keys.grep(/^(self\.|test_class_)/) 490: methods.delete_if {|k,v| cls_methods.include? k } 491: 492: next if methods.empty? and cls_methods.empty? 493: 494: indent = 0 495: is_test_class = self.is_test_class(fullklasspath) 496: klasspath = fullklasspath.split(/::/) 497: klassname = klasspath.pop 498: 499: klasspath.each do | modulename | 500: m = self.get_class(modulename) 501: type = m.nil? ? "module" : m.class.name.downcase 502: @result.push indentunit*indent + "#{type} #{modulename}" 503: indent += 1 504: end 505: @result.push indentunit*indent + "class #{klassname}" + (is_test_class ? " < Test::Unit::TestCase" : '') 506: indent += 1 507: 508: meths = [] 509: 510: cls_methods.sort.each do |method| 511: meth = create_method(indentunit, indent, method) 512: meths.push meth.join("\n") 513: end 514: 515: methods.keys.sort.each do |method| 516: next if method =~ /pretty_print/ 517: meth = create_method(indentunit, indent, method) 518: meths.push meth.join("\n") 519: end 520: 521: @result.push meths.join("\n\n") 522: 523: indent -= 1 524: @result.push indentunit*indent + "end" 525: klasspath.each do | modulename | 526: indent -= 1 527: @result.push indentunit*indent + "end" 528: end 529: @result.push '' 530: end 531: 532: @result.push "# Number of errors detected: #{@error_count}" 533: @result.push '' 534: end
obtain the class klassname, either from Module or using ObjectSpace to search for it.
# File lib/zentest.rb, line 96 96: def get_class(klassname) 97: begin 98: klass = Module.const_get(klassname.intern) 99: puts "# found class #{klass.name}" if $DEBUG 100: rescue NameError 101: ObjectSpace.each_object(Class) do |cls| 102: if cls.name =~ /(^|::)#{klassname}$/ then 103: klass = cls 104: klassname = cls.name 105: break 106: end 107: end 108: puts "# searched and found #{klass.name}" if klass and $DEBUG 109: end 110: 111: if klass.nil? and not $TESTING then 112: puts "Could not figure out how to get #{klassname}..." 113: puts "Report to support-zentest@zenspider.com w/ relevant source" 114: end 115: 116: return klass 117: 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 152 152: def get_inherited_methods_for(klass, full) 153: klass = self.get_class(klass) if klass.kind_of? String 154: 155: klassmethods = {} 156: if (klass.class.method_defined?(:superclass)) then 157: superklass = klass.superclass 158: if superklass then 159: the_methods = superklass.instance_methods(true) 160: 161: # generally we don't test Object's methods... 162: unless full then 163: the_methods -= Object.instance_methods(true) 164: the_methods -= Kernel.methods # FIX (true) - check 1.6 vs 1.8 165: end 166: 167: the_methods.each do |meth| 168: klassmethods[meth.to_s] = true 169: end 170: end 171: end 172: return klassmethods 173: 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 124 124: def get_methods_for(klass, full=false) 125: klass = self.get_class(klass) if klass.kind_of? String 126: 127: # WTF? public_instance_methods: default vs true vs false = 3 answers 128: # to_s on all results if ruby >= 1.9 129: public_methods = klass.public_instance_methods(false) 130: public_methods -= Kernel.methods unless full 131: public_methods.map! { |m| m.to_s } 132: public_methods -= %(pretty_print pretty_print_cycle) 133: 134: klass_methods = klass.singleton_methods(full) 135: klass_methods -= Class.public_methods(true) 136: klass_methods = klass_methods.map { |m| "self.#{m}" } 137: klass_methods -= %(self.suite new) 138: 139: result = {} 140: (public_methods + klass_methods).each do |meth| 141: puts "# found method #{meth}" if $DEBUG 142: result[meth] = true 143: end 144: 145: return result 146: end
Check the class klass is a testing class (by inspecting its name).
# File lib/zentest.rb, line 177 177: def is_test_class(klass) 178: klass = klass.to_s 179: klasspath = klass.split(/::/) 180: a_bad_classpath = klasspath.find do |s| s !~ ($r ? /Test$/ : /^Test/) end 181: return a_bad_classpath.nil? 182: end
load_file wraps require, skipping the loading of $0.
# File lib/zentest.rb, line 80 80: def load_file(file) 81: puts "# loading #{file} // #{$0}" if $DEBUG 82: 83: unless file == $0 then 84: begin 85: require file 86: rescue LoadError => err 87: puts "Could not load #{file}: #{err}" 88: end 89: else 90: puts "# Skipping loading myself (#{file})" if $DEBUG 91: end 92: 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 332 332: def methods_and_tests(klassname, testklassname) 333: return @klasses[klassname], @test_klasses[testklassname] 334: end
# File lib/zentest.rb, line 66 66: 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 212 212: def process_class(klassname, full=false) 213: klass = self.get_class(klassname) 214: raise "Couldn't get class for #{klassname}" if klass.nil? 215: klassname = klass.name # refetch to get full name 216: 217: is_test_class = self.is_test_class(klassname) 218: target = is_test_class ? @test_klasses : @klasses 219: 220: # record public instance methods JUST in this class 221: target[klassname] = self.get_methods_for(klass, full) 222: 223: # record ALL instance methods including superclasses (minus Object) 224: # Only minus Object if full is true. 225: @inherited_methods[klassname] = self.get_inherited_methods_for(klass, full) 226: return klassname 227: end
presents results in a readable manner.
# File lib/zentest.rb, line 537 537: def result 538: return @result.join("\n") 539: 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 234 234: def scan_files(*files) 235: assert_count = Hash.new(0) 236: method_count = Hash.new(0) 237: klassname = nil 238: 239: files.each do |path| 240: is_loaded = false 241: 242: # if reading stdin, slurp the whole thing at once 243: file = (path == "-" ? $stdin.read : File.new(path)) 244: 245: file.each_line do |line| 246: 247: if klassname then 248: case line 249: when /^\s*def/ then 250: method_count[klassname] += 1 251: when /assert|flunk/ then 252: assert_count[klassname] += 1 253: end 254: end 255: 256: if line =~ /^\s*(?:class|module)\s+([\w:]+)/ then 257: klassname = $1 258: 259: if line =~ /\#\s*ZenTest SKIP/ then 260: klassname = nil 261: next 262: end 263: 264: full = false 265: if line =~ /\#\s*ZenTest FULL/ then 266: full = true 267: end 268: 269: unless is_loaded then 270: unless path == "-" then 271: self.load_file(path) 272: else 273: eval file, TOPLEVEL_BINDING 274: end 275: is_loaded = true 276: end 277: 278: begin 279: klassname = self.process_class(klassname, full) 280: rescue 281: puts "# Couldn't find class for name #{klassname}" 282: next 283: end 284: 285: # Special Case: ZenTest is already loaded since we are running it 286: if klassname == "TestZenTest" then 287: klassname = "ZenTest" 288: self.process_class(klassname, false) 289: end 290: 291: end # if /class/ 292: end # IO.foreach 293: end # files 294: 295: result = [] 296: method_count.each_key do |classname| 297: 298: entry = {} 299: 300: next if is_test_class(classname) 301: testclassname = convert_class_name(classname) 302: a_count = assert_count[testclassname] 303: m_count = method_count[classname] 304: ratio = a_count.to_f / m_count.to_f * 100.0 305: 306: entry['n'] = classname 307: entry['r'] = ratio 308: entry['a'] = a_count 309: entry['m'] = m_count 310: 311: result.push entry 312: end 313: 314: sorted_results = result.sort { |a,b| b['r'] <=> a['r'] } 315: 316: @result.push sprintf("# %25s: %4s / %4s = %6s%%", "classname", "asrt", "meth", "ratio") 317: sorted_results.each do |e| 318: @result.push sprintf("# %25s: %4d / %4d = %6.2f%%", e['n'], e['a'], e['m'], e['r']) 319: end 320: end
# File lib/zentest.rb, line 422 422: def test_to_normal(_name, klassname=nil) 423: super do |name| 424: if defined? @inherited_methods then 425: known_methods = (@inherited_methods[klassname] || {}).keys.sort.reverse 426: known_methods_re = known_methods.map {|s| Regexp.escape(s) }.join("|") 427: 428: name = name.sub(/^(#{known_methods_re})(_.*)?$/) { $1 } unless 429: known_methods_re.empty? 430: 431: name 432: end 433: end 434: end
Disabled; run with --debug to generate this.
Generated with the Darkfish Rdoc Generator 1.1.6.