2.3 测试
从理论上讲,重构不应破坏任何未曾破坏的代码,然而在实践中并不是那样。在某种程度上,本书后面会向你展示哪些改动是安全的。但无论是人还是工具都会犯错误,重构也有可能引入新错误,所以重构过程就需要一个良好的自动化测试套件(suite)。进行任何重构之后,你更希望可以通过单击一个按钮和扫视一眼就能看到是否有代码被破坏了。
尽管测试驱动开发(test-driven development)在传统程序员中取得了巨大的成功,但在Web开发者中还是不太常见,尤其对前端开发者而言。实际上,网站的任何自动化测试都可能只是特例而不是规定,尤其是在对待HTML上。现在是让Web开发者开始编写和运行测试套件,以及使用测试驱动开发的时候了。
测试驱动开发的基本要点如下所述。
(1) 给某个功能写测试。
(2) 编写最简单的尽可能运作的代码。
(3) 运行所有测试。
(4) 如果测试通过,跳到第(1)点。
(5) 否则,跳到第(2)点。
为达到重构目的,这个过程尽可能地自动化是非常重要的。特别是:
- 测试套件不应有任何复杂的配置。理想情况是只需单击按钮就能运行。不要因为难以运行就让开发者跳过了测试。
- 测试应该足够快,便于频繁运行。理想情况是只需90秒甚至更少的时间。不要因为测试占用时间太长,而让开发者跳过了测试。
- 结果只能是通过或失败,而且它们都应该是显而易见的。如果测试失败了,应该显示更多的输出,解释出错的源头。但通过的测试不应产生输出,当然“所有测试通过”这样的信息除外。谁也不希望从成千上万的通过测试输出中遗漏掉一两个失败测试的输出。
为Web应用程序编写测试比为传统应用程序编写测试要困难得多。部分原因是Web应用程序的测试工具并没有传统应用程序的工具那么成熟,也因为涉及呈现而且要指出呈现是否正确,对计算机来说这是非常困难的(对人来说是非常容易的,但我们的目标是把人类从重复中解救出来)。因此Web测试或许达不到像Java或.NET程序测试那样的覆盖面。尽管如此,有测试总比没测试好,而且实际上你可以进行更多的测试。
你会发现,向Web标准(比如XHTML)重构代码能简化测试。更进一步说,为良构和有效的XHTML页面写测试比为乱构的(malformed)页面写测试容易得多。这是因为与乱构的页面相比,良构页面更方便处理代码。这样检查浏览器的呈现结果也更容易,因为浏览器对良构的页面一视同仁,但对乱构页面的处理各有千秋。因此重构的一大益处就是提升了可测试性,也让测试驱动开发成为可能。实际上,由于大量网站未曾经过测试,所以在进行下一步之前,你可能得先进行足够的重构才可能进行测试。
测试网页的工具很多,有合适的,也有糟糕的,有免费的,也有昂贵的。有些是为程序员设计的,有些是为Web开发者准备的,也有些是给业务领域的专家使用的。它们包括:
- HtmlUnit
- JsUnit
- HttpUnit
- JWebUnit
- FitNesse
- Selenium
实际上,这些工具具备的先进性,有助于富有经验的敏捷程序员(agile programmer)开发少量的初始测试和测试框架。一旦有了适当的自动化测试套件,增加更多的测试就易如反掌了。
2.3.1 JUnit
JUnit是Java框架的标准单元测试框架,也是其他大量特定框架(比如HtmlUnit和HttpUnit)的基石。说它不能用来测试Web应用程序是没有理由的,你把Java代码当做是浏览器的网页代码就好了,实际上也没有想象中那么困难。
举个例子,最常见的基本测试之一就是检测网站的每一个页面是否良构。你当然可以只通过XML解析器处理页面,并观察是否抛出异常来达到。但对于网站的每一个页面,如果只需写一个通用的测试方法,你就可以不用前面说到的手工检查,从而实现测试的自动化了。
代码清单2-2演示了一个简单的JUnit测试,用来检查我的博客的良构性。它所做的就是把URL交给XML解析器,并观察它是否会出错。如果没有出错,则通过了测试。这段代码要求classpath中有Sun JDK 1.5或以上版本和JUnit 3.8或以上版本。在其他的环境中,可能需要稍做修改才能运行。
代码清单2-2 一个用于检查网站良构的JUnit测试
import java.io.IOException;
import junit.framework.TestCase;
import org.xml.sax.*;
import org.xml.sax.helpers.XMLReaderFactory;
public class WellformednessTests extends TestCase {
private XMLReader reader;
public void setUp() throws SAXException {
reader = XMLReaderFactory.createXMLReader(
"com.sun.org.apache.xerces.internal.parsers.SAXParser");
}
public void testBlogIndex()
throws SAXException, IOException {
reader.parse("http://www.elharo.com/blog/");
}
}
你可以在如Eclipse或NetBeans等IDE内执行这个测试,或是使用下面的命令行来运行:
$ java -cp .:junit.jar
junit.swingui.TestRunner WellformednessTests
如果通过了所有的测试,你会看到如图2-3所示的绿色条栏。
要测试更多页面的良构性,就要添加更多的方法,其中每个方法都跟testBlogIndex方法基本相同,只是URL不同而已。当然你还可以编写更复杂的测试,也可以通过为解析器设置http://xml.org/sax/features/validation特性测试有效性,然后加上错误处理程序以便在碰到错误的时候抛出异常。
你可以使用DOM、XOM(XML对象模型)、SAX或者其他一些API载入页面并检查它的内容,比如可以编写测试来检验页面的所有链接是否都是可用的。如果你使用TagSoup作为解析器,还可以为非良构的HTML页面编写这类测试。
你还可以使用HttpURLConnection类来提交表单,也可以使用Java 6内建的Rhino引擎来运行JavaScript。这都是一些非常底层的内容,虽然有一定难度,但绝对值得去做,卷起袖子开工吧。
如果开发者不对网站做常规的改动,你可以配置定期运行的测试套件,这样在错误不意出现的时候能给你发送电子邮件。(期望每一个编写者或设计者在提交之前都运行全部的测试套件或许是不现实的。)你也可以使用如Hudson或Cruise Control之类的产品持续运行套件。但这也有可能会使你的日志充满了大量的测试内容,因此你可能认为在开发服务器上运行这些测试更为合适。
其他语言和平台也有很多类似的测试框架,如Python有PyUnit、C++有CppUnit、.NET有NUnit等。使用标准的xUnit形式是它们的共同点之一。无论哪一个,只要你和你的团队用得舒心,那对编写Web测试套件来说就是好的。Web服务器并不关心使用什么语言来编写测试。有了一键测试的装备和足够的支持编写测试的HTTP客户端后,你就可以随心所欲了。
2.3.2 HtmlUnit
HtmlUnit是一个用于测试HTML页面的开源JUnit扩展。对于使用过JUnit测试驱动开发的Java程序员来说,应该很熟悉和很顺心。跟纯粹的JUnit相比,HtmlUnit主要的优势有两个。
WebClient类使它更易于模拟Web浏览器。
HTMLPage类有检查HTML文档常用部分的方法。
例如,HtmlUnit在客户端返回页面之前会运行由onLoad处理程序指定的JavaScript,跟浏览器的执行行为一致。如果只是像代码清单2-2那样简单地使用XML解析器载入页面,那是不会运行JavaScript的。
代码清单2-3演示了HtmlUnit的应用,它检查页面的所有链接是否可用。其实我也可以使用原始的解析器和DOM来实现,但复杂性多少会有所增加。特别是,使用如getAnchors这样的方法搜索页面所有a元素是非常有用的。
代码清单2-3 检查页面链接的HtmlUnit测试
import java.io.IOException;
import java.net.*;
import java.util.*;
import com.gargoylesoftware.htmlunit.*;
import com.gargoylesoftware.htmlunit.html.*;
import junit.framework.TestCase;
public class LinkCheckTest extends TestCase {
public void testBlogIndex()
throws FailingHttpStatusCodeException, IOException {
WebClient webClient = new WebClient();
URL url = new URL("http://www.elharo.com/blog/");
HtmlPage page = (HtmlPage) webClient.getPage(url);
List links = page.getAnchors();
Iterator iterator = links.iterator();
while (iterator.hasNext()) {
HtmlAnchor link = (HtmlAnchor) iterator.next();
URL u = new URL(link.getHrefAttribute());
// Check that we can download this page.
// If we can't, getPage throws an exception and
// the test fails.
webClient.getPage(u);
}
}
}
这个测试不仅仅是单元测试而已。它检查页面的所有链接,而真正的单元测试可能只检查一个。进一步说,它跟外部服务器之间产生了连接,这对单元测试来说是不常见的。无论怎么说,这是一个值得尝试的测试,它让我们了解到,如果一个外部网站由于重整页面而导致链接失效,那么就该修复页面了。
2.3.3 HttpUnit
HttpUnit也是一个开源JUnit扩展,用于测试HTML页面。同样,对于使用过JUnit进行测试驱动开发的Java程序员来说,HttpUnit也是最合适的,并且它在很多方面都跟HtmlUnit非常相近。有些程序员喜欢HttpUnit,但有些喜欢HtmlUnit。如果真要找出它们之间的区别,那就是HttpUnit偏底层些,它更倾向于原始的HTTP连接,而HtmlUnit更像浏览器。如果需要考虑JavaScript的测试,HtmlUnit的支持要好一些。当然,两者的重叠还是很多的。
代码清单2-4演示了HttpUnit测试,用于验证页面中H1标头的唯一性,以及其文本是否跟网页的标题匹配。虽然并不是所有页面都必须这样,但也存在必须一致的情形。例如对于新闻报纸类网站来说,这是十分聪明的做法。
代码清单2-4 检查标题和唯一的H1标头是否匹配的HttpUnit测试
import java.io.IOException;
import org.xml.sax.SAXException;
import com.meterware.httpunit.*;
import junit.framework.TestCase;
public class TitleChecker extends TestCase {
public void testFormSubmission()
throws IOException, SAXException {
WebConversation wc = new WebConversation();
WebResponse wr = wc.getResponse(
"http://www.elharo.com/blog/");
HTMLElement[] h1 = wr.getElementsWithName("h1");
assertEquals(1, h1.length);
String title = wr.getTitle();
assertEquals(title, h1[0].getText());
}
}
也可以使用HtmlUnit来做到,代码清单2-3中的例子自然也可以使用HttpUnit来编写。如何抉择很大程度上取决于个人偏好。当然,这类框架不止这两个。还有更多其他的测试框架,包括不是用Java编写的。用你所爱,但无论怎样,一定要选择一个。
2.3.4 JWebUnit
JWebUnit是位于HtmlUnit和JUnit之上的高层API。通常来说,JWebUnit测试涉及更多的断言(assertion)和更少直接的Java代码。因此,这些测试在一定程度上更容易编写,也无需太多的Java经验,并且对于Web开发者来说或许更方便使用。此外,测试可以随着你单击链接、提交表单并在一个Web应用程序的完整路径中进行,在多个页面中也非常容易扩展。
代码清单2-5演示了一个JWebUnit测试,用于检查网站的搜索引擎。它会填写主页的表单并提交,然后检查预期的结果是否会出现。
代码清单2-5 提交表单的JWebUnit测试
import junit.framework.TestCase;
import net.sourceforge.jwebunit.junit.*;
public class LinkChecker extends TestCase {
private WebTester tester;
public LinkChecker(String name) {
super(name);
tester = new WebTester();
tester.getTestContext().setBaseUrl(
"http://www.elharo.com/");
}
public void testFormSubmission() {
// start at this page
tester.beginAt("/blog/");
// check that the form we want is on the page
tester.assertFormPresent("searchform");
/// check that the input element we expect is present
tester.assertFormElementPresent("s");
// type something into the input element
tester.setTextField("s", "Linux");
// send the form
tester.submit();
// we're now on a different page; check that the
// text on that page is as expected.
tester.assertTextPresent("Windows Vista");
}
}
2.3.5 FitNesse
FitNesse其实是一个Wiki,它的目的是让业务用户能够以表格的形式编写测试。业务用户喜欢用电子表格。FitNesse的基本概念是,测试可以使用类似电子表格的形式来编写。因此,它不用使用Java,而是在Wiki中用表格来编写FitNesse测试。
为网站安装和配置FitNesse确实需要程序员。一旦运行起来并编写了一些夹具(fixture)样例后,有领悟能力的业务用户就有可能编写更多的测试。FitNesse也非常适合于一个协作的坏境中,程序员和商业用户可以一起定义业务规则和编写测试。
对于Web应用程序的验收测试,可以安装Joseph Bergin的HtmlFixture。它也是基于HtmlUnit的,它提供的说明非常有用,可以作为Web应用程序的测试指导,比如填写表单、提交表单和检查页面文本等。
代码清单2-6演示了一个简单的FitNesse测试,用于检查head中的http-equiv元标签是否正确指定为UTF-8。代码前3行设置了类路径,第4行为空行,第5行确定夹具类型是HtmlFixture。(测试Web应用程序当然还有更多其他类型,但HtmlFixture是最常见的。)
然后载入一个外部页面。在这个页面中,我们把注意力集中到一个名为meta、id值为charset的元素上。这就是我们测试的主题。
这个测试接着检查这个元素的两个属性。首先检查content属性,并断言它的值是text/html;charset=utf-8。接着检查meta元素的http-equiv属性,并断言它的值是content-Type。
代码清单2-6 对<metaname="charset"http-equiv="Content-Type"
content="text/html;charset=UTF-8"/>的FitNesse测试
!path fitnesse.jar
!path htmlunit-1.5/lib/*.jar
!path htmlfixture20050422.jar
!|com.jbergin.HtmlFixture|
|http://www.elharo.com/blog/|
|Element Focus|charset |meta|
|Attribute |content |text/html; charset=utf-8|
|Attribute |http-equiv |content-type|
这个测试会嵌入到一个Wiki页面中。你可以按如图2-4所示通过在浏览器中单击测试按钮来运行它。如果所有的断言都通过并且没有其他的错误,测试会在运行完毕后显示为绿色,否则会显示为粉红色。你还可以使用页面的其他Wiki标记来描述测试。
2.3.6 Selenium
Selenium是一个开源的基于浏览器的测试工具,跟单元测试相比更侧重于功能测试和验收测试。与HttpUnit和HtmlUnit不同,Selenium测试直接在浏览器内运行。被测试的页面被嵌入到iframe中,而且Selenium的测试代码是使用JavaScript编写的。尽管用于编写测试的IDE只能在Firefox上使用,但它基本上是跨浏览器和跨平台的,运行界面如图2-5所示。
尽管在Selenium中可以远程控制手工编写的测试,但它确实是一个偏传统的GUI记录和回放工具。这对于已经编写好但又不适用于测试驱动开发的应用程序是非常合适的。
对于习惯使用JavaScript和HTML的前端开发者来说,Selenium可能用得更舒心。对专业测试员来说也可能更适合,因为它跟一些常用的客户端GUI测试工具非常相似。
代码清单2-7演示了Selenium测试,用于验证在Google中搜索“Elliotte”的结果中,www.elharo.com是否出现在第一页上。把这个脚本录入Selenium IDE中并作了少量的手工编辑后,你就可以在浏览器中载入和运行了。跟上述给出的例子不同,这既不是Java代码,也不需要很多的编程技巧来维护。Selenium更像是宏(macro)语言而不是编程语言。
代码清单2-7 测试elharo.com是否排在“Elliotte”搜索结果的前列
<html>
<head>
<meta http-equiv="Content-Type"
content="text/html; charset=UTF-8">
<title>elharo.com is a top search results for Elliotte</title>
</head>
<body>
<table cellpadding="1" cellspacing="1" border="1">
<thead>
<tr><td rowspan="1" colspan="3">New Test</td></tr>
</thead><tbody>
<tr>
<td>open</td>
<td>/</td>
<td></td>
</tr>
<tr>
<td>type</td>
<td>q</td>
<td>elliotte</td>
</tr>
<tr>
<td>clickAndWait</td>
<td>btnG</td>
<td></td>
</tr>
<tr>
<td>verifyTextPresent</td>
<td>www.elharo.com/</td>
<td></td>
</tr>
</tbody></table>
</body>
</html>
很明显,代码清单2-7是一个真正的HTML文档,可以在Firefox中用Selenium IDE打开它并运行测试。因为这个测试直接在浏览器内运行,所以Selenium能帮助你找出在某个浏览器中才会出现的错误。给浏览器CSS、HTML和JavaScript更广泛的支持是非常有用的,但HtmlUnit、HttpUnit和JWebUnit之类的测试框架使用的是它们自身的JavaScript引擎,跟浏览器的引擎行为并不总能保持一致。Selenium使用的不是外在的模仿而是浏览器本身真正的引擎。
这个IDE还能将测试导出为C#、Java、Perl、Python或是Ruby代码,因此可以把Selenium测试集成到其他环境中去,这对测试的自动化特别重要。代码清单2-8展示了与代码清单2-7相同的测试,只不过它是使用Ruby编写的。不过对于直接在浏览器中运行测试发生的跨浏览器错误,这种方式是捕获不了的。
代码清单2-8 自动化测试“Elliotte”搜索结果中elharo.com是否在排在前列
require "selenium"
require "test/unit"
class GoogleSearch < Test::Unit::TestCase
def setup
@verification_errors = []
if $selenium
@selenium = $selenium
else
@selenium = Selenium::SeleneseInterpreter.new("localhost",
4444, *firefox", "http://localhost:4444", 10000);
@selenium.start
end
@selenium.set_context("test_google_search", "info")
end
def teardown
@selenium.stop unless $selenium
assert_equal [], @verification_errors
end
def test_google_search
@selenium.open "/"
@selenium.type "q", "elliotte"
@selenium.click "btnG"
@selenium.wait_for_page_to_load "30000"
begin
assert @selenium.is_text_present("www.elharo.com/")
rescue Test::Unit::AssertionFailedError
@verification_errors << $!
end
end
end
2.3.7 测试入门
因为你正在重构,所以我假设你已经有了一个网站或应用程序。如果跟我所看到的大部分情况一样,这个网站或应用程序的前端测试非常有限,但别因这种情况泄气。拿起你喜欢的工具,为一些基本功能编写少量的测试。即使少,有测试也总比没有好。早期的测试是线性的。你编写的测试明显改善了代码覆盖率和质量。不过千万别认为必须测试所有的代码。如果能进行测试,那当然好,不能就算了,因为还有其他的事情可做。
在重构网站的一个具体的页面、子目录或路径之前,可以花一个小时为该部分编写两三个测试。如果没有别的,这都是些冒烟测试(smoke test),它们让你可以了解建模(mock up)是否完毕。等有了充分的时间后再对测试进行扩展。
如果找到错误,在修复前一定先为它编写一个测试。这能帮你掌握错误修复的时间,并防止在做了其他修改后,这个错误又不经意地出现。因为前端测试并非一元的,除了一些错误代码的具体之处外,该测试很有可能还间接测试到其他地方。
最后,除重构之外的新特性和新开发,先想方设法编写一些测试。这保证网站新部分能进行测试,并且通常也会把旧页面和旧脚本的问题暴露出来。
自动化测试对开发健壮、可扩展的应用程序是至关重要的。开发测试套件可能一开始让人灰心丧气,但值得去做。一旦你把第一个测试框架配置好了,把第一个测试也写好了,接下来的测试就容易多了。就像可以通过小型、自动化的重构线性地改善网站一样,你也可以通过每周添加一个测试来改善测试套件。比你预料的要早的是,你会拥有一个坚固的测试套件,它会告诉你在出现问题时需要修复的东西,帮你保证网站的可靠性。