这是一个真枪实弹的例子,如何在编程过程中进行单元测试。 本文以一个图书馆的书籍管理系统作为例子,讲述一个单元测试——编码的循环过程。这里只讲述一个独立画面的编写。画面的编码分两个部分,业务数据部分和界面表示部分。先介绍业务数据部分的编写。 下面具体的描述单元测试是如何融入到设计和编码的过程当中的,单元测试驱动着编码的过程,可以说,编码的目的就在于使单元测试能够通过。整个开发过程就象这样前进:测试——编码——再测试——再编码 本文不介绍单元测试的具体方式,在.net程序中通常采用nunit进行单元测试,nunit的具体使用方式请参见其他说明。推荐kongxx的blog:使用NUnit进行DotNet程序测试.本文中的代码以示例为主,不保障能够编译运行。 我们的应用程序是这样的:一个书目包括下面的信息:书作者、书标题、出版年份。我们要编写一个画面可以查询这些信息。设计图上,用户界面如下所示: 编码工作分为两个部分:业务数据和用户界面。业务数据的处理可以粗略的提出以下的构想:要有Document集合(“God said:Let there be light...”【创世纪1:3】——设计工作总是以相同的模式开始),每个Document描述一条书目的数据;要有一个Searcher,给他一个Query,他就返回一个Result,Result是一个Document的集合。现在我们可以构建我们的单元测试了,我们要测试如下的几个类:Document、Result、Query、Searcher。 Document Document需要体现如下数据:作者、书名和出版时间,于是我们创建下面的测试代码: public void TestDocument() { Document d = new Document("a", "t", "y"); Assert.AreEquals("a", d.Author); Assert.AreEquals("t", d.Title); Assert.AreEquals("y", d.Year); } 这个测试代码目前是不能编译的,这是肯定的,Document类还没有编写。我们可以建立一个Document类,编写必要的接口,重新编译,成功。运行,不通过。目前测试程序运行是不能pass的,但是可以证明测试程序本身是有效的,这就实现了第一步。 下面我们可以继续编写Document类,实现他的构造方法和必要的属性,使测试程序可以通过。 现在我们可以看一下,一个单元测试和编码的具体步骤 : 1、写测试程序; 2、编译测试程序,毫无疑问失败,因为程序本身还不存在; 3、实现程序,只保证测试程序能编译即可; 4、运行测试程序,失败,因为程序还没有正确的实现; 5、编写程序,试测试程序能够pass; 6、运行测试程序,看看是不是真的能够pass; 7、重构程序; 8、重复1——7的步骤。 这样的步骤可以显示测试程序从不pass到pass的过程,以确定测试程序在有效的工作,测试程序确实在进行自己的工作,以确定对程序的修改是不是在向理想的方向前进。 很多人觉得测试一些简单的属性没有必要,觉得这样的get和set是不可能发生错误的,或者这样的测试很繁琐。实际上,对这些get和set进行测试是很有好处的。首先:写这样的测试代码所花费的时间和精力并没有想象中的那么多,测试可以明确问题的解决程度,有了测试代码,我们可以“证明”问题已经被解决了;其次:测试程序比被测试程序有更长的生命周期,你可以给程序加上了缓存、改变了对象的创建方式、加上了log、等等,但是测试程序仍然在进行工作,随时检查改变是否是正确的;最后:如果某个类中充满了set和get,极有可能是这个类设计的不合理,考虑重新分配一下类之间的任务。 Result Result类要提供如下信息:条目的数量及其包括的Document列表。首先我们测试一个空的Result: public void TestEmptyResult() { Result r = new Result(); Assert.AreEqual(r.Count, 0); //count=0 for empty result } 测试程序编译失败。接着创建Result类,创建Count属性,编译成功,运行失败。在Count属性中加一句: return 0,这时运行成功。 再测试一个有两条数据的case: public void TestResultWithTwoDocuments() { Document d1 = new Document("a1", "t1", "y1"); Document d2 = new Document("a2", "t2", "y2"); Result r = new Result(new Document[2]{d1, d2}); Assert.AreEqual(r.Count, 2); Assert.AreEqual(r.Item(0), d1); Assert.AreEqual(r.Item(1), d2); } 在Result类中添加Item方法,return null,运行程序,测试失败。下面是最后的实现方式,这个程序的测试是可以通过的: public class Result { Document[] collection = new Document[0]; public Result() {} public Result(Document[] docs) { this.collection = docs; } public int Count {get {return collection.length;}} public Document Item(int i) {return collection[i];} } 测试通过,绿灯亮,Result类编写完成。 Query 最简单的Query要实现下面这样的测试: public void TestSimpleQuery() { Query q = new Query("test"); Assert.AreEquals("test", q.Value); } 下面建立一个Query类,建立构造函数和Value属性,在Value属性中返回构造函数的参数,通过测试。 Searcher Searcher类是最重要的一个类,我们先实现一个简单的case:从查找结果中得出一个空的Document集合: public void TestEmptyCollection() { Searcher searcher = new Searcher(); Result r = searcher.Find(new Query("any")); Assert.AreEquals(r.Count, 0); } 这个测试类能编译,现在创建一个假的Searcher类: public class Searcher { public Searcher() {} Result Find(Query q) {return null;} } 现在程序可以编译了,但是测试不能通过。我们修改Searcher的代码: public Result Find(Query q) {return new Result();} 我们现在遇到一个问题:Searcher类从什么地方得到Document集合?我们可以在Searcher的构造方法中传入一个Document数组。别着急实现代码,首先写测试程序: public void TestOneElementCollection() { Document d = new Document("a", "a word here", "y"); Searcher searcher = new Searcher(new Document[1]{d}); Query q1 = new Query("word"); Result r1 = searcher.Find(q1); Assert.AreEquals(r1.Count, 1); Query q2 = new Query("notThere"); Result r2 = searcher.Find(q2); Assert.AreEquals(r2.Count, 0); } 这个测试程序指明程序编写的目的:我们现在分别要查找一个存在的和一个不存在的数据。要实现这个目的,我们先为Searcher类添加新的构造函数,首先编译是失败的。下面我们就要认真的面对这个具体的实现了。 首先,Searcher类必须在Find方法运行前保存Document,所以我们修改代码: Document[] collection = new Document[0]; public Searcher(Document[] docs) { this.collection = docs; } 现在,实现一个最简单的Find,遍历Document集合,将符合Query条件的记录添加到Result集合中。 public Result Find(Query q) { Result result = new Result(); for (int i = 0; i < collection.count; i++) { if (collection[i].Matches(q)) { result.Add(collection[i]); } } return result; } 看上去不错,但是还有两点:我们的Document类没有Matches方法,Result类也没有Add方法。现在加上,先加测试代码,无论修改什么都要先测试: public void TestDocumentMatchingQuery() { Document d = new Document("1a", "t2t", "y3"); Assert.IsTrue(d.Matches(new Query("1"))); Assert.IsTrue(d.Matches(new Query("2"))); Assert.IsTrue(d.Matches(new Query("3"))); Assert.IsTrue(!d.Matches(new Query("4"))); } 实现一个查找要考虑三个方面:处理空条件、部分的匹配和忽略大小写。现在我们不考虑大小写,前面两个条件已经足够我们实现这个查询了。也许以后我们会改变做法,目前按照这样的设计实现。 public boolean Matches(Query q) { String query = q.getValue(); return author.IndexOf(query) != -1 || title.IndexOf(query) != -1 || year.IndexOf(query) != -1; } 现在TestDocumentMatchingQuery就可以运行了,但是TestOneElementCollection仍然是失败的,因为Result类还没有Add方法。在给Result类添加Add方法之前,先为这个方法写测试代码: public void TestAddingToResult() { Document d1 = new StringDocument("a1", "t1", "y1"); Document d2 = new StringDocument("a2", "t2", "y2"); StringResult r = new StringResult(); r.Add(d1); r.Add(d2); Assert.AreEquals(r.Count, 2);//2 items in result Assert.AreEquals(r.Item(0) == d1);//First item Assert.AreEquals(r.Item(1) == d2);//Second item } 现在测试不能通过。Result已经将结果集合保存在了数组中,但是实用数组并不是一个很好的办法,因为数组的元素个数不容易改变。我们可以使用ArrayList: ArrayList collection = new ArrayList(); public Result(Document[] docs) { for (int i = 0; i < docs.Length; i++) { this.collection.Add(docs[i]); } } public int Count {get{return collection.Size;}} public Document Item(int i) { return (Document)collection.Items[i]; } 现在我们已经重构了程序,只要保证以前的测试case仍然可以通过,就可以确信我们的重构是合理的。这就是测试代码的一个好处,使得程序的修改处于一种可控制的状态。现在我们重新运行下面两个测试case,看看他们是不是仍然可以通过:TestEmptyResult和TestResultWithTwoDocuments。再在Result中添加下面的方法: public void Add(Document d) { collection.AddElement(d); } 现在考虑一下Result类的构造方法:Result(Document[] docs),我们建立这个方法首先就是为了支持测试case:TestResultWithTwoDocuments,这是在Result中包含Document的唯一方式。后来,我们又在Result中加上了Add方法,这个方法就是Searcher类真正需要的。实际上Result(Document[] docs)是一个不再需要的方法。在测试case的代码中,我们可以在调用Result的构造函数的位置上替换成下面的代码: Result r = new Result(); r.Add(d1); r.Add(d2); 运行一下所有的Testcase,确信他们都可以通过。现在TestAddingToResult和TestResultWithTwoDocuments两个测试case实际上测试的内容一样,以后可以将其中一个去除。 最后,这四个类的代码都通过了测试:Document、Result、Query和Searcher。 加载Document对象 Searcher类的对象从什么地方得到Document的对象呢?目前我们调用Searcher的构造函数,将Document数组作为参数传入。现在我们想让Searcher类自动加载Document对象。 还是从测试代码开始,我们引入一个Reader类,然后调用Count属性查看是否正确加载了理想中的数据。需要注意一点,从开始到现在,我们一直将测试代码和程序编写在同一个namespace中,这样就不必为了测试程序专门将某个方法写成public形式。 public void TestLoadingSearcher() { try { String docs = "a1\tt1\ty1\na2\tt2\ty2"; // \t=field, \n=row StringReader reader = new StringReader(docs); Searcher searcher = new Searcher(); searcher.Load(reader); Assert.AreEqual(searcher.Count, 2); } catch (Exception e) { Assert.IsTrue(false); } } 目前Searcher类中仍然用一个数组保存数据,我们将进行一次重构,就像我们曾经在Result类中做的那样,将存储数据的容器改为ArrayList。 public class Searcher { ArrayList collection = new ArrayList(); public Searcher() {} public Searcher(Document[] docs) { for (int i = 0; i < docs.Length; i++) { collection.Add(docs[i]); } } public Query MakeQuery(string s) { return new Query(s); } public Result Find(Query q) { Result result = new Result(); for (int i = 0; i < collection.Size; i++) { Document doc = (Document)collection.Item[i]; if (doc.Matches(q)) { result.Add(doc); } } return result; } } 运行以前的测试case,通过,于是我们继续进行工作,准备进行内容的加载,在Searcher类中: // Searcher: public void Load(Reader reader) { BufferedReader in = new BufferedReader(reader); try { string line = in.ReadLine(); while (line != null) { collection.Add(new Document(line)); line = in.ReadLine(); } } finally { try {in.Close();} catch {} } } int Count { get{return collection.size();} } Document类中: // Document: public Document(String line) { StringTokenizer tokens = new StringTokenizer(line, "\t"); author = tokens.NextToken; title = tokens.NextToken; year = tokens.NextToken; } Searcher类中实用数组的那个构造函数已经没有用了,相应的测试方法也应该删除了。我们要调整测试程序: public void TestOneElementCollection() { Searcher searcher = new Searcher(); try { StringReader reader = new StringReader("a\ta word here\ty"); searcher.Load(reader); } catch { Assert.IsTrue(false);//Couldn't load Searcher } Query q1 = searcher.MakeQuery("word"); Result r1 = searcher.Find(q1); Assert.AreEqual(r1.Count, 1); Query q2 = searcher.MakeQuery("notThere"); Result r2 = searcher.Find(q2); Assert.AreEqual(r2.Count, 0); } SearcherFactory类 Searcher类的实例从什么地方得到?现在我们是使用Searcher类的构造函数得到他的实例。为了避免对实际实现的依靠,我们创建了一个SearcherFactory类,这个类返回一个具体的Searcher类,Searcher的调用者不关心具体的类型,只调用Searcher类的接口,测试的时候实用一个假的Searcher实现,这样就提高了程序的可测试性(参见:怎样提高代码的可测试性、工厂模式)。现在测试的时候我们创建了一个“test.dat”文件。先建立测试代码: public void TestSearcherFactory() { try { Searcher s = SearcherFactory.GetInstance("test.dat"); Assert.IsTrue (s != null); } catch { Assert.IsTrue(false);//SearcherFactory can't load } } 下面实现SearcherFactory类: public class SearcherFactory { public static Searcher GetInstance(string filename) { FileReader in = new FileReader(filename); Searcher s = new Searcher(); s.Load(in); return s; } } 现在调用者通过SearcherFactory类得到Searcher类的实例。 回顾一下 现在我们从Searcher类及其调用者的角度看看,我们现在建立了哪些主要的方法和属性:
我们可以看一下Document类和Query类,有理由说他们现在还不够完美,但是他们工作的很好,所以要克制一下自己改变的冲动。 开发过程有时候就是这样,比如我们刚才把两个数组改成了ArrayList,这是我们设计的失误吗?当然不是。我们最初使用数组的时候,他是可以满足当时的需要的,后来,我们根据另外一种需要又改变了他们。修改本身不可怕,怕的是我们设计了一个不容易修改的僵化的结构。我们不是先知,有时候我们必须改变方法。关键是我们不能一开始就做出一个僵化的设计,也要避免使设计变得过于复杂。 再进一步,改用接口 我们上面写的程序已经很不错了,但是这个程序还不是我们最终的形式。在实际系统中,书目的数据不一定保存在一个文本文件里,他可能是保存在一个XML里面,一个数据库里面,或者别的什么地方。我们希望调用者不必关系这些具体的形式。 在刚才那个表中,Searcher的调用者所调用的方法和属性中,有一些可以改成接口。Query类直接调用也是可以的,调用者亲自创建Query的实例是必须的,但是Searcher、Result和Document通过接口调用则更好一些。我们下面将要进行一个“提取接口”的重构。 很麻烦的是,我们已经把一些公共性质的名称给类用了。站在为调用者着想的角度,我们必须改变类的名称,比如Searcher类应该更名为StringSearcher,其他同理。腾出来的名字给接口用。 下面将Searcher类更名为StringSearcher,修改所有的应用之处,运行测试程序,通过。再创建一个接口: public interface Searcher { Result Find(Query q); } 运行测试程序,修改StringSearcher的申明使他成为Searcher的实现,再运行测试程序。现在,只有一个地方必须声名使用StringSearcher类——SearcherFactory中。关于怎样弱化类之间的依赖关系这里就不多说了。 Result类也照此办理,将Result改为StringResult,再建立Result接口: public interface Result { Document Item(int i); int Count; } StringSearcher类可以构造一个StringResult类,但是在返回的时候仍然作为Reslut类型,没有必要让调用者知道这个改变。 最后创建Document接口: public interface Document { String Author; String Title; String Year; } 现在,调用者直接调用的具体类型只有Query和SearchFactory,其他类都是通过接口调用的,不关系具体类型。 结论 我们以一种典型的叠代方式完成了一个书目管理画面的业务数据的编码,突出的表达了简单的设计和测试——编码的过程。单元测试可以帮助我们进行设计、编码和重构。业务数据就可以完工了,用户界面部分可以与此很好的分离。 参考文献 译自:The Test/Code Cycle in XP: Part 1, Model William C. Wake |
|