javascript 测试工具abut发布
abut全称为annotations-based unit testing,基于注释的单元测试工具,也可以就地取此英文的原义(毗邻)称呼它。众所周知,javascript实在不好做测试,即使我这个工具现在对事件响应这东西还是无可奈何的,这只能黑盒测试。不过,能白盒测试的,我们还是进行白盒测试。javascript经近几年的迅猛发展,也涌现诸如Qunit,JSpec这些优秀的测试框架。但我最后还是决定自己搞一个。原因如下:
- 我喜欢自造轮子。
- 由于在写框架(龟速进行中),倾向于选择器,测试工具等东西都出自自家。
- 写文档是痛苦,倒不如写注释,既然写注释,就要物尽其用,一次性把注释与测试都写完。
- 其他测试框架写测试都很恶心,单个测试的代码量比较长(本来就不想写,勉为其难地写,方法易用是王道)。
- 其他测试框架写测试都是写在另一个文件上,更增加人写测试的抗拒性。
- 写在另一个文件上,万一这文件丢失了怎么办?!
顺便说一下单元测试的好处,缓解一下大家对它的厌恶。
//http://www.cnblogs.com/nuaalfm/archive/2010/02/26/1674235.html //单元测试的优点 //1、它是一种验证行为。 // 程序中的每一项功能都是测试来验证它的正确性。它为以后的开发提供支缓。就算是开发后期,我们也可以轻松的增加功能或更改程序结构,而不用担心这个过程中会破坏重要的东西。而且它为代码的重构提供了保障。这样,我们就可以更自由的对程序进行改进。 //2、它是一种设计行为。 // 编写单元测试将使我们从调用者观察、思考。特别是先写测试(test-first),迫使我们把程序设计成易于调用和可测试的,即迫使我们解除软件中的耦合。 //3、它是一种编写文档的行为。 // 单元测试是一种无价的文档,它是展示函数或类如何使用的最佳文档。这份文档是可编译、可运行的,并且它保持最新,永远与代码同步。 //4、它具有回归性。 // 自动化的单元测试避免了代码出现回归,编写完成之后,可以随时随地的快速运行测试。
基于上面的原因,我的单元测试与当前流行的测试框架有很大不同,首先测试代码与我们的执行代码是位于同一个文件,其次它是非常符号化的(汲取模板系统的经验),最后它总是对整个文件进行操作。为了获取注释,我是用AJAX的同步请求实现的(dom.abut(url))。
现在说说一些相关概念。既然是单元测试,每个测试代码都应该封闭在一个独立的环境中,通常我们用闭包收拾之。但有可能连续几个测试程序都共有一个测试数据呢,但这测试数据当然也不能丢在全局作用域下,于是就有了大闭包与小闭包之分。具体表现如下:
//第二个参数仅在浏览器支持Object.defineProperties时可用 applyIf(Object,{ create:function( proto, props ) {//ecma262v5 15.2.3.5 //略去具体实现 }, //$$$$same(Object.keys({aa:1,bb:2,cc:3}),["aa","bb","cc"]) keys: function(obj){//ecma262v5 15.2.3.14 //略去具体实现 } }); //用于创建javascript1.6 Array的迭代器 function iterator(vars, body, ret) { return eval('[function(fn,scope){'+ 'for(var '+vars+'i=0,l=this.length;i<l;i++){'+ body.replace('_', 'fn.call(scope,this[i],i,this)') + '}' + ret + '}]')[0]; }; //注释照搬FF官网 /* <<<< var arr = [1,2,3,4,5,6]; $$$$eq(arr.indexOf(2),1) $$$$eq(arr.lastIndexOf(6),5) arr.slice(3).forEach(function(el,index,aaa){ $$$$log(el,"item"); $$$$log(index,"index"); $$$$log(aaa,"array"); }); var arr2 = arr.map(function(el){ return el+1; }); $$$$same(arr2,[2,3,4,5,6,7]); >>>> **/ applyIf(Array[PROTO],{ //定位类 返回指定项首次出现的索引。 indexOf: function (el, index) { //略去具体实现 }, //定位类 返回指定项最后一次出现的索引。 lastIndexOf: function (el, index) { //略去具体实现 },
由<<<<与>>>>之间的注释我称之为大闭包,它圈着我们的测试程序与辅助函数与测试数据等,单行的以4个$开头的注释称之为小闭包。注释中的这些部分会被我的测试工具抽取出来进行加工执行。这里面涉及许多步骤,如$$$$会被替换为"dom.abut.",计算行号,统计当前执行到第几个测试程序,生成图形界面等等。既然是单元测试,就有assertTrue,assertFlase,assertEquals,assertSame等方法,不过这些方法有笨拙,Qunit简化为ok(布尔测试),equals(同值性测试),same(同一性测试)。我沿用Qunit的思路,依次为abut.ok,abut.eq,abut.same,当然我们在测试时,abut是用$$$$代替的。
方法调用 | 说明 | 补充 |
---|---|---|
$$$$或$$$$ok | 布尔测试 | |
$$$$eq | 同值性测试 | 相等于a==b |
$$$$same | 同一性测试 | 如果是简单类型则相等于===,array、object等比较其内容 |
$$$$log | 非测试debug用 | 相当于console.log |
对于AJAX,setTimeout等异步行为,我没有像Qunit那样搞个start与stop,大家看左上角的统计数字就知进行第几个测试程序了。注意,log是不统计到里面,虽然一样也显示在列表中。
剩下一个问题,众所周知,单元测试都是针对公开的接口进行测试,像闭包内的函数怎么测试?为此,abut提供了专门的手段(@@@@)用于把它们偷渡到全局作用域下。当然,这不是真正意义的暴露,而是依附于我们的命名空间对象dom,放于一个叫exports的集中箱中,好让我们可以随时卸载它。
(function(){ var s = ["XMLHttpRequest", "ActiveXObject('Msxml2.XMLHTTP.6.0')", "ActiveXObject('Msxml2.XMLHTTP.3.0')", "ActiveXObject('Msxml2.XMLHTTP')", "ActiveXObject('Microsoft.XMLHTTP')"]; if(dom.ie === 7 && location.protocol === "file:"){ s.shift(); } for(var i = 0 ,el;el=s[i++];){ try{ if(eval("new "+el)){ dom.xhr = new Function( "return new "+el) break; } }catch(e){} } //偷渡s到全局作用域下 //@@@@(s) })(); //$$$$log(dom.exports.s);
好了,比如我想测试我框架的两个模块:
<!DOCTYPE HTML"> <html> <head> <title></title> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> <script src="abut.js"></script> <script> window.onload = function(){ dom.abut("dom/lib/ecma.js"); dom.abut("dom/lib/brower.js"); } </script> <title>dom Framework</title> </head> <body> </body> </html>
只要在这些JS文件的注释中写好测试,当页面一载入,我们就可以看到效果!而且这些列表中的每一行都是可点的,点开查看详情。
最后附上源码,我已经把它从我框架独立出来。
// annotations-based unit testing by 司徒正美 // http://www.cnblogs.com/rubylouvre/archive/2010/11/02/1867655.html (function(){ if(!Object.keys){ var _dontEnum = [ 'propertyIsEnumerable', 'isPrototypeOf','hasOwnProperty','toLocaleString', 'toString', 'valueOf', 'constructor']; for (var i in { toString: 1 }) _dontEnum = false; Object.keys = function(obj){//ecma262v5 15.2.3.14 var result = [],dontEnum = _dontEnum,length = dontEnum.length; for(var key in obj ) if(obj.hasOwnProperty(key)){ result.push(key) } if(dontEnum){ while(length){ key = dontEnum[--length]; if(obj.hasOwnProperty(key)){ result.push(key); } } } return result; } } if(!String.prototype.trim){ String.prototype.trim = function(){ return this.replace(/^[\s\xa0]+|[\s\xa0]+$/g, ''); } } if(!String.prototype.quote){ String.prototype.quote = (function () { var meta = { '\b': '\\b', '\t': '\\t', '\n': '\\n', '\f': '\\f', '\r': '\\r', '"' : '\\"', '\\': '\\\\' }, reg = /[\\\"\x00-\x1f]/g, regFn = function (a) { var c = meta[a]; return typeof c === 'string' ? c : '\\u' + ('0000' + a.charCodeAt(0).toString(16)).slice(-4); }; return function(){ return '"' + this.replace(reg, regFn) + '"'; } })(); } //http://www.cnblogs.com/rubylouvre/archive/2009/08/30/1556869.html var addSheet = function(css){ if(!-[1,]){ css = css.replace(/opacity:\s*(\d?\.\d+)/g,function($,$1){ $1 = parseFloat($1) * 100; if($1 < 0 || $1 > 100) return ""; return "filter:alpha(opacity="+ $1 +");" }); } css += "\n";//增加末尾的换行符,方便在firebug下的查看。 var doc = document, head = doc.getElementsByTagName("head")[0], styles = head.getElementsByTagName("style"),style,media; if(styles.length == 0){//如果不存在style元素则创建 if(doc.createStyleSheet){ //ie doc.createStyleSheet(); }else{ style = doc.createElement('style');//w3c style.setAttribute("type", "text/css"); head.insertBefore(style,null) } } style = styles[0]; media = style.getAttribute("media"); if(media === null && !/screen/i.test(media) ){ style.setAttribute("media","all"); } if(style.styleSheet){ //ie style.styleSheet.cssText += css;//添加新的内部样式 }else if(doc.getBoxObjectFor){ style.innerHTML += css;//火狐支持直接innerHTML添加样式表字串 }else{ style.appendChild(doc.createTextNode(css)) } } var addEvent = (function () { if (document.addEventListener) { return function (el, type, fn) { el.addEventListener(type, fn, false); }; } else { return function (el, type, fn) { el.attachEvent('on' + type, function () { return fn.call(el, window.event); }); } } })(); this.dom = { // http://www.cnblogs.com/rubylouvre/archive/2010/01/20/1652646.html type : (function(){ var reg = /^(\w)/, regFn = function($,$1){ return $1.toUpperCase() }, to_s = Object.prototype.toString; return function(obj,str){ var result = (typeof obj).replace(reg,regFn); if(result === 'Object'){ if(obj===null) result = 'Null'; else if(obj.window==obj) result = 'Window'; //返回Window的构造器名字 else if(obj.callee) result = 'Arguments'; else if(obj.nodeName) result = (obj.nodeName+'').replace('#',''); //处理文档与元素节点 else if(!obj.constructor || !(obj instanceof Object)){ if("send" in obj && "setRequestHeader" in obj){//处理IE5-8的宿主对象与节点集合 result = "XMLHttpRequest" }else if("length" in obj && "item" in obj){ result = "namedItem" in obj ? 'HTMLCollection' :'NodeList'; }else{ result = 'Unknown'; } }else result = to_s.call(obj).slice(8,-1); } if(result === "document"){//返回Document的构造器名字 result = "Document"; } if(result === "Number" && isNaN(obj)){ result = "NaN"; } if(str){ return str === result; } return result; } })(), oneObject : function(array,val){ var result = {},value = val !== void 0 ? val :1; for(var i=0,n=array.length;i < n;i++) result[array[i]] = value; return result; } }; //http://www.cnblogs.com/rubylouvre/archive/2010/04/20/1716486.html (function(w,s){ s = ["XMLHttpRequest", "ActiveXObject('Msxml2.XMLHTTP.6.0')", "ActiveXObject('Msxml2.XMLHTTP.3.0')", "ActiveXObject('Msxml2.XMLHTTP')", "ActiveXObject('Microsoft.XMLHTTP')"]; if( !-[1,] && w.ScriptEngineMinorVersion() === 7 && location.protocol === "file:"){ s.shift(); } for(var i = 0 ,el;el=s[i++];){ try{ if(eval("new "+el)){ dom.xhr = new Function( "return new "+el); break; } }catch(e){} } })(window); //annotations-based unit testing 基于注释的测试系统 2010 10 31 dom.abut = function(url){ var xhr = dom.xhr(); xhr.open("GET",url+"?"+(new Date-0),false); xhr.send(null); var text = xhr.responseText|| ""; evalCode(text) } var rcomments = /\/\/([^\r\n]+)|\/\*([\s\S]+?)\*\//g; var rexports = /[\/]{2,}@{4}\(([^\r\n]+)\);?/g; var r$$$$ = /(?:^|\s+)\$\$\$\$(\d+)(\w*)\(([^\r\n]+)\);?/g; //$$$$same(countOne,{ok:1, eq:1, same:1, '':1}) var countOne = dom.oneObject(["ok","eq","same",""]); var fns = { ok:";\nabut.ok", eq:";\nabut.eq", same:";\nabut.same", log:";\nabut.log" } var getAllComments = function(text){ var m , result = []; while(m = rcomments.exec(text)){ result.push(m[1] || m[2]); } return result.join('\n'); }; //构建闭包的开头部分 var startClosure = function(index){ return "closures["+ index +"] = function(){\n var abut = window.dom.abut\n" } //构建闭包的结束部分 var endClosure = function(index,lineNumber){ return "};\nclosures["+ index+"].lineNumber = "+lineNumber+";\n"; } //针对一条测试注释的小型闭包 var smartClosure = function(str,arr,obj){ var temp = ""; str.replace(r$$$$,function($,$1,$2,$3){ var fn = fns[$2] || fns.ok, testCode = fn + "("+$3+");\n"; temp += startClosure(obj.id)+ fn +".lineNumber = " + $1 + fn + ".testCode = " + testCode.slice(1,-2).quote() + testCode + endClosure(obj.id,$1); if(countOne[$2]) obj.count++; obj.id++; }); arr.push(temp ) } //针对多条测试注释的大型闭包 var bigClosure= function(str,arr,obj){ var lineNumber; str = str.replace(/^\d+/,function(str){ lineNumber = parseInt(str,10) ; return "" }); str = str.trim().replace(r$$$$,function($,$1,$2,$3){ if(countOne[$2]) obj.count ++; var fn = fns[$2] || fns.ok, testCode = fn + "("+$3+");\n"; return fn +".lineNumber = " + $1 + fn + ".testCode = " + testCode.slice(1,-2).quote() + testCode; }); var temp = startClosure(obj.id) + str + endClosure(obj.id,lineNumber); obj.id++ arr.push(temp); } //添加行号以及暴露闭包中要测试中的数据到全局作用域下 var cleanCode = function (source) { var lines = source.split( /\r?\n/) ; for(var i=0,n = lines.length; i < n ;i++){ lines[i] = lines[i].replace(rexports,function($,$1){ dom.abut.isExports = true; return ";dom.exports = dom.exports || {}; dom.exports["+ $1.quote()+"] = " + $1+";"; }); lines[i] = lines[i].replace(/\$\$\$\$|<<<</,function(str){ return str + (i+1) }); } return lines.join('\n'); }; var evalCode = function(source){ var abut = dom.abut; abut.ULID = "abut-"+(new Date - 0); abut.time = 0; abut.isExports = false; delete dom.exports; var uneval = cleanCode(source),arr = getAllComments(uneval).trim().split("<<<<"), i=0, n=arr.length, els,segment, resolving= ["var closures = window.dom.abut.closures = [];\n"], obj ={ id:0, count:0 } while(i < n){ segment = arr[i++]; els = segment.split(">>>>"); if(segment.indexOf(">>>>") !== -1){//这里不使用el.length === 2是为了避开IE的split bug bigClosure(els[0],resolving,obj) if(els[1]){ smartClosure(els[1],resolving,obj) } }else{ smartClosure(els[0],resolving,obj) } } //构筑单元测试系统的UI var UL = document.createElement("UL"); abut.el = UL; document.body.appendChild(UL); UL.className ="dom-abut-result"; abut.render("dom-abut-title",'一共有'+obj.count+'个测试<span id="'+ abut.ULID+'"></span>'); abut.recoder = document.getElementById( abut.ULID); addEvent(UL,"click",function(e){ var target = e.target || e.srcElement; if(target.tagName ==="SPAN"){ var blockquote = target.parentNode.getElementsByTagName("blockquote")[0]; if(blockquote){ blockquote.style.display = !!(blockquote.offsetHeight || blockquote.offestWidth) ? "none": "block"; } } }); //添加样式 addSheet(".dom-abut-result {\ border:5px solid #00a7ea;\ padding:10px;\ background:#03c9fa;\ list-style-type:none;\ }\ .dom-abut-result li{\ padding:5px ;\ margin-bottom:1px;\ font-size:14px;\ }\ .dom-abut-result li span{\ cursor: pointer;\ }\ .dom-abut-result li blockquote{\ margin:0;\ padding:5px;\ display:none;\ }\ .dom-abut-title{\ background:#008000;\ }\ .dom-abut-pass{\ background:#a9ea00;\ }\ .dom-abut-unpass{\ background:red;\ color:#fff;\ }\ .dom-abut-log{\ background:#c0c0c0;\ }\ .dom-abut-log blockquote{\ background:#808080;\ }"); try { abut.isExports && eval(uneval); eval(resolving.join("")); } catch (e) { return abut.render("dom-abut-unpass","解析编译测试代码失败"); } for(var i=0,fn;fn= abut.closures[i++];){ try { fn(); } catch (e) { return abut.render("dom-abut-unpass","第"+fn.lineNumber +"行测试代码执行失败"); } } } //功能类似于Qunit的ok 布尔判定 dom.abut.ok = function(state){ var bool = !!state, self = arguments.callee, lineNumber = self.lineNumber, testCode = self.testCode; this.prepareRender(bool,lineNumber,testCode); } //功能类似于Qunit的equals 可隐式转换的等号比较 dom.abut.eq = function(actual, expected){ var bool = actual == expected, self = arguments.callee, lineNumber = self.lineNumber, testCode = self.testCode; this.prepareRender(bool,lineNumber,testCode); } //功能类似于Qunit的same 用于比较复杂的数据类型 dom.abut.same = function(actual, expected){ var bool = dom.isEqual(actual, expected), self = arguments.callee, lineNumber = self.lineNumber, testCode = self.testCode; this.prepareRender(bool,lineNumber,testCode); } //相等于firefox中的console.log dom.abut.log = function(obj, message){ var context = "<span>第" + arguments.callee.lineNumber+"行日志记录 "+ (message || "") + "</span>"; var testCode = "<pre>"+dom.inspect(obj)+"</pre>"; dom.abut.render("dom-abut-log",context,testCode); } dom.abut.prepareRender = function(bool,lineNumber,testCode){ var className = bool ? 'dom-abut-pass' : 'dom-abut-unpass', context = '<span>第'+ lineNumber+'行测试代码: '+(bool ? '通过' :'不通过' )+"</span>" ; this.recoder.innerHTML = " 已完成第"+(++this.time)+"个测试"; this.render(className,context,testCode); } dom.abut.render = function(className,context,code){ var li = document.createElement("LI"); li.className = className; this.el.appendChild(li); var blockquote = document.createElement("blockquote") li.innerHTML = context; if(code){ li.appendChild(blockquote); blockquote.innerHTML = code; } } //用于比较对象 dom.isEqual = function(a, b) { if (a === b) return true; var atype = typeof(a), btype = typeof(b); if (atype != btype) return false; if (a == b) return true; if ((!a && b) || (a && !b)) return false; if (a.isEqual) return a.isEqual(b); if (dom.type(a,"Date") && dom.type(b,"Date")) return a.valueOf() === b.valueOf(); if (dom.type(a,"NaN") && dom.type(b,"NaN")) return false; if (dom.type(a,"RegExp") && dom.type(b,"RegExp")) return a.source === b.source && a.global === b.global && a.ignoreCase === b.ignoreCase && a.multiline === b.multiline; if (atype !== 'object') return false; if (a.length && (a.length !== b.length)) return false; var aKeys = Object.keys(a), bKeys = Object.keys(b); if (aKeys.length != bKeys.length) return false; for (var key in a) if (!(key in b) || !dom.isEqual(a[key], b[key])) return false; return true; } //序列化对象(JSON.stringify对DOM对象无效,弃之) dom.inspect = function(obj, indent) { indent = indent || ""; if (obj === null) return indent + "null"; if (obj === void 0) return indent + "undefined"; if (obj.nodeType === 9) return indent + "[object Document]"; if (obj.nodeType) return indent + "[object " + (obj.tagName || "Node") +"]"; var arr = [],type = dom.type(obj),self = arguments.callee,next = indent + "\t"; switch (type) { case "Boolean":case "Number":case "NaN": case "RegExp": return indent + obj; case "String": return indent + obj.quote(); case "Function": return (indent + obj).replace(/\n/g, "\n" + indent); case "Date": return indent + '(new Date(' + obj.valueOf() + '))'; case "Unknown": case "XMLHttpRequest" : case "Window" : return indent + "[object "+type +"]"; case "NodeList":case "HTMLCollection": case "Arguments": case "Array": for (var i = 0, n = obj.length; i < n; ++i) arr.push(self(obj[i], next).replace(/^\s* /g, next)); return indent + "[\n" + arr.join(",\n") + "\n" + indent + "]"; default: for (var i in obj) { arr.push(next + self(i) + ": " + self(obj[i], next).replace(/^\s+/g, "") ); } return indent + "{\n" + arr.join(",\n") + "\n" + indent + "}"; } } })()