借用此例,阐述一下我会如何实施TDD,
好的,现在我打算实现一个用户注册登录系统,假设我现在项目中有0个类,目前我考虑当前的需求里有两个用例,一个注册一个登录,我想它们分别对应两个Service或者一个Service的两个方法,后者看起来不是那么复杂,所以我只想写一个Service类,即便我觉得将来有什么不妥,我可以重构它。
于是我想了一个类名LoginService和两个方法名register和login,首先为此编写测试,这是这个项目中的第一个类,先来写register的测试吧,根据需求用户注册需要输入自己的信息,用户会输入很多信息,于是register方法接受用户输入的所有信息做为参数,我要基于需求构思了一些测试用例,比如Tom和Andy,Tom填写了完全正确的用户信息,而Andy则因为没有提供Email而导致注册失败,把所有Tom的信息做为参数传入register,它会返回true,而对于Andy而言,会得一个false。
Void testRegisterTom() {
....
assertTrue(loginService.register( Tom's information));
}
void testRegisterAndy() {
...
assertFalse(logingService.register( Andy's information));
}
现在开始着手设计实现,不过我觉得这里有些问题,把用户所有信息当做参数实在不太OO,于是我重构了一下,我现在就写一个UserViewObject,重构伴随于TDD整个过程之中,并且我还想到我最好是针对接口编程,于是LoginService变成了一个接口,并且我告诉我的同事,如果你需要调用它请使用这个接口,一个类对应一个接口好像没有什么意义但它成了我与我同事之间的约定,现在我终于要实现它了。
我在实现LoginService的register接口过程中,我发现我要做一些数据库的操作,我需要一个插入操作,把用户信息插入到数据库,因为我对于DAO模式有一些经验,于是我思考把这个操作包装到DAO中,但是现在没有这个DAO,没关系,我先定义一个这样的接口UserDAO,有一个createUser(UserViewObject)接口方法,这里又是一个接口并且将来它极有可能只有一种实现,它出现的意义就是我对于代码将来的规划,我暂时不急着实现它,这并不会影响我写LoginService的逻辑,好,现在重新回到LoginService的实现,现在做一下UserViewObject里所有字段的必要性检查,然后调用userDao.createUser()插入,这里有一个假设,假设userDao.createUser()工作得很好,现在看来我之前写的测试可能要改点东西,我需要mock一个UserDAO的实例再做一些其它假设的工作,然后运行测试,最终把两个测试全部通过。
我的另一个同事告诉我,如果有另一个叫Tom的人也来注册,你怎么办?于是我又加了一个测试,对这“又一个Tom”进行测试,它应该返回false,
void testAnotherTom() {
....
assertFalse(logingService.register( AnotherTomObject));..
}
这个测试没法通过,因为LoginService里没判断这是不是another Tom。现在我在UserDAO中加了一个方法getUserByName(),然后改进了一下LoginService的逻辑,先调用userDao.getUserByName(),如果返回不为空就说明已经有一个叫Tom的人注册了,现在我又要改一下我的测试,要对getUserByName做必要的Mock,对于testAnotherTom的测试,这个Mock应该是 EasyMock.expectLastCall().andReturn(aNotNullObject).times(1)
我跑了一下测试,现在又全部通过了。我认为这样可以了,如果还有需要,我会再加新的测试,一般来说,写测试前仔细思考各种情况,这种增加测试用例的步骤会少很多。
事情并没有完,UserDAO没有实现,现在得把它实现了,它是一个DAO,经验告诉我使用一些工具来测试它,会比较快。单元测试,集成式单元测试,是的,我没有必要像LoginService那样做Mock测试,我的DAO可以直接访问数据库来进行测试,它是一个集成式单元测试,我可能选择DBUnit或者Spring的TransactionalTest来进行测试,反正它是一个运行在测试环境下的集成性的单元测试,今天跑可能成功,明天跑就失败了,因为测试环境里有一些对于这个测试而言的“脏”数据,这没有关系,集成式单元测试本来就依赖外部环境,这种测试可以用经验判断测试是否成功,不过最好保持测试环境的纯洁,所以测试结束时记得销毁那些“脏”数据。
我用同样的方式实现了login接口,不过我和我同事在鉴定用户登录许可方式上有分歧,我通过用户名和密码联合查询用判断Count是否为0来决定是否允许用户登录,我的同事认为此事不妥,他认为通过查询用户名取出纪录再比对密码是否一致来决定是否允许用户登录,我最后认为他的方法比较容易理解于是我改了我的实现,但我不需要修改测试用例,因为测试用例基于需求。
我又TDD了一次,于是我总结一下。
1. 其实我不需要严格地先完成测试再写实现,TDD指测试驱动设计,一切基于测试用例,围绕需求产生,认真对待测试用例,TDD代表对测试严谨的态度,精心准备测试用例比严格地先写测试再写实现要实际得多。
2. 我写某类时不需要关注写这个类能给我带来多少额外的功能,所有的类都由于某个测试而产生,我根本不考虑是否应该重载某个方法以得到其它的便利,我也很少运用继承来重写什么功能,当我需要一个工具类时,我只考虑那些与测试用例有关的真正需要的工具方法,而不考虑把这个工具类写得多么强大,所以只写那些能保障所有测试通过的代码,这些才是真正必要的代码。
3. 随时随地重构,只要觉得有需要。就像我编写LoginService的过程中,我觉得验证那些非空值验证可以提取出来,或者用使用验证框架,反正我重构了它们并让测试也通过了。
4. 我始终认为TDD始于黑盒,测试先行,没有实现也就看不到实现,我编写LoginService引入了一个UserDAO,于是我不得不修改测试代码做一些Mock工作,实际上Mock同样在描述测试用例,比如,
userDao.getUserByName(“Tom”);
EasyMock.expectLastCall().andReturn(null).times(1);
这就表示当前没有存在一个叫做Tom的注册用户,这也是一个测试用例的一部分,它不是输入而是一个前置条件。