测试驱动开发—程序开发人员测试指南(5)

发表于:2018-5-09 10:26

字体: | 上一篇 | 下一篇 | 我要投稿

 作者:朱少民、杨晓慧译    来源:51Testing软件测试网原创

  第15章 测试驱动开发--Mockist风格
  在前面的章节中所展示的那种测试驱动开发的方法对我们帮助很大,但事实上,这种方法在有些情况下是不适用的。很多的开发者参与的都是由多个层次构成的大型企业系统的开发--由于过于庞大的设计和偶然的复杂性,通常比必要的规模大很多。使用现有的技术,从一个企业系统的边界出发来测试驱动一个新的特性是极具挑战性的,即使对于经验丰富的测试驱动开发实践者也是如此。对于那些刚刚开始学习测试驱动开发的人来说,这类的复杂性也容易使他们失去信心。
  15.1  一种不同的方法
  比如说我们要负责插入一个简单的网络服务,用来注册新的用户和他们的付费详情。这种功能在一个典型的面向用户的企业系统中是非常普遍的。第一版解决方案的整体要求就是用户应该能够用直接的银行转账或者主流的信用卡来进行付款(Paypal和比特币的使用将会在第二版中出现)。
  受系统已有的架构和设计约定的引导,在白板面前进行的一次快速交流得出了图15.1中展示的设计构想。
  现在,假设我们想要测试驱动一个用户注册端点,而它又碰巧是一个可以与其他服务交互的RESTful Web服务,可以反过来调用资源库 和与外部方交流的客户端代码。第一次测试的assertEquals将会是什么结果呢?如果用户注册端点除了HTTP状态码之外没有返回任何东西,又会怎样呢?幸运的是,有一个解决方案。
  快速设计交流揭示了几个具有不同角色和职责的构件。它们中的一些可能已经存在于当前的系统;而有一些则需要添加进去。不过,简图告诉了我们不同的对象是如何交互和协作的。由此在深入细节比如持久性和外部集成之前,我们可以测试驱动这个设计以及对象之间的多方面互动。简图也暗示了大部分操作是所谓的"命令",也就是说,是执行一些东西的指令,而非返回一些东西。换言之,大部分设计遵从"只说不问"的原则(或是迪米特法则(Law of Demeter),如果你愿意这样说的话)。
  对专注于接口和交互并且偏好使用虚拟对象来达成效果的TDD,称为Mockist风格的TDD,这样的一个情境是理想的。它也鼓励在写测试用例之前先进行一些关于设计的思考。
  
图15.1  插入用户注册所需的构件,依旧保持系统原有的结构和设计准则
  15.1.1  测试驱动用户注册
  在这种测试驱动开发的方式中,我们通常从距离系统边界或者用户尽可能近的地方入手。之后我们会写一个能梳理出与最紧密的协作者交互的测试用例。我们迫不及待地想要开始,避免使用户注册服务于几种网络感知端点而带来的技术复杂性,仅仅专注于它的接口和与其最紧密的协作者交互。所以,第一次测试的意图是驱动这些交互。
@Test
public void personalAndCardDetailsAreSavedForCreditCardCustomers(){
CustomerRegistrationEndpoint testedEndpoint
= new CustomerRegistrationEndpoint();
CustomerService customerServiceStub = mock(CustomerService.class);
PaymentService paymentServiceMock = mock(PaymentService.class);
testedEndpoint.setCustomerService(customerServiceStub);
testedEndpoint.setPaymentService(paymentServiceMock);
RegistrationDetails details = new RegistrationDetails();
details.firstName = "Joe";
details.lastName = "Jones";
details.paymentType = "C";
details.cardType = "VISA";
details.cardNumber = "1111222233334444";
details.cvv2 = "123";
Customer customer = new Customer("Joe", "Jones");
CustomerId newCustomerId = new CustomerId(12345);
when(customerServiceStub.registerCustomer(customer))
.thenReturn(newCustomerId);
testedEndpoint.registerCustomer(details);
CreditCardDetails cardDetails
= new CreditCardDetails(CreditCardType.VISA,
1111222233334444L, 123);
verify(paymentServiceMock)
.registerCreditCard(newCustomerId, cardDetails);
}
  这是一个庞大的测试(花了将近15分钟写这个测试)。确实,它可以变得更简单一些,但是因为我们已经有了一个设计构想,我们不需要努力寻求一个可能可以工作的最简方案。至少对于我来说,基于一些构造块和一个对于解决方案的总体感觉,瞄准一个直观的API感觉更自然。
  在一个实际系统中,一些类早已存在,并且把一切组装在一起也不会很费劲,但是尽管如此,测试依旧需要大量的工作。我们可以从这第一个测试中推断出什么呢?
  ·  构建调用的方法。被测试的方法来自于一个只有公有字符串字段的类的对象。这表明我们可以期望框架将XML或者JSON转化成Java代码,以帮助填充RegistrationDetails对象。
  ·  指定协作者提供的接口。双方服务都有操作领域对象(registerCustomer, registerCreditCard)的注册方法。我们只需要决定怎样来调用协作者。
  ·  发现领域类。CustomerId、CustomerDetails、CreditCardDetails,以及CreditCardType枚举。就像前面说过的,这些类可能已经存在于系统中,或者可能刚刚被发现。
  ·  指定交互的顺序。我们不仅指定了与哪些对象协作,测试也告诉我们要先注册用户来获取一个用户ID,而这个用户ID是注册付款方法所需要的。
  遗漏的字段
  一些字段在注册细节中被遗漏,比如地址,也许是出生日期、持卡者姓名和卡的截止日期。在实际代码中它们会存在,但是我想保持样例的简短和相关性。
  现在,注意到只有一个校验来使这个测试通过,我们可以使用最简单的红-绿状态条(red-green bar)策略--伪造对象(faking)(Beck 2002)。
public class CustomerRegistrationEndpoint {
private CustomerService customerService;
private PaymentService paymentService;
public void registerCustomer(RegistrationDetails details) {
paymentService.registerCreditCard(new CustomerId(54321),
new CreditCardDetails(CreditCardType.VISA,
1111222233334444L,123));
}
public void setCustomerService(CustomerService customerService){
this.customerService = customerService;
}
public void setPaymentService(PaymentService paymentService){
this.paymentService = paymentService;
}
}
  这段代码可以通过测试;代码中忽略了用户的详细信息,使用硬编码(hard-coded)的信用卡信息进行注册。然而,通过在测试里初始化注册细节和用户细节为始终不变的合理的值,并且通过提供一个真正使用它们的CustomerService桩程序(stub),我想要为即将产生的代码创建一些机动的空间。
  使用伪造来使测试通过是可以的,但是如果你信任自己的设计,也习惯于虚拟对象,我建议:在一次彻底测试中,将整个交互过程贯穿起来。 毕竟,这种TDD的风格是最适合驱动对象间的交互,并且一个写得完善的测试应该为一个明显的接口(第二个红-绿状态条策略)提供足够的基础。在这个实例中,会有以下几行代码:
public void registerCustomer(RegistrationDetails details){
Customer customer = new Customer(details.firstName,
details.lastName);
CustomerId newCustomerId = customerService.registerCustomer(Customer);
paymentService.registerCreditCard(newCustomerId,
new CreditCardDetails(CreditCardType.valueOf(details.cardType),
Long.parseLong(details.cardNumber),
Long.parseLong(details.cvv2)));
}
  并没有那么可怕,对吗?没有东西是伪造的,而且所有值在交互对象之间被可靠地传递。但这仍然是十分粗糙的代码。它没有包含错误处理,并且解析看起来粗糙 (这意味着Customer RegistrationEndpoint绝对需要更多的测试)。然而,它确实表明注册一个使用信用卡付款的用户需要交互协作。在重构阶段,我可能为了去掉解析,会把CreditCardDetails领域对象的创建移动到一个单独的方法中,这似乎不太恰当,因为它与剩下的代码处在不同的抽象层。更有趣的是下一个测试!
  那绝非是明显的。它可能是这些中的一个:
  ·  一个带有一个想要直接使用银行转账付款的用户的CustomerRegistrationEndpoint测试。这会详述更多主要的流程。
  ·  一个为了发现带有用户资源库的交互的CustomerService测试。
  ·  一个为了探索与信用卡网关(gateway)合并而且保留结果的代码的调用的payment Service测试。
  在前面的章节中,据说我们应该选择遵循程序主逻辑的测试或者是提供给我们更多信息和知识的测试。在这个时候,测试另一种付款类型的注册将会提供很少的信息。设计简图告诉我们没有新的协作者会出现(双方服务都已经在第一次测试中被使用),所以这个测试会与那个"注册使用信用卡付款的用户"的测试十分相似。虽然无论如何探索那条路径是没有错的,但是推进其他测试中的一个会更加发人深省。
  发现数据库中不敏感的数据的持久性是什么样好像十分简单,而一个关于CustomerService的测试就是如此。
@Test
public void validCustomerIsPersistedDuringRegistration(){
CustomerServiceImpl testedService = new CustomerServiceImpl();
CustomerRepository customerRepositoryMock
= mock(CustomerRepository.class);
testedService.setCustomerRepository(customerRepositoryMock);
Customer customer = new Customer("Joe","Jones");
testedService.registerCustomer(customer);
verify(customerRepositoryMock).save(customer);
}
  这是一个典型的"直通(pass-through)"测试;它证实一层调用了另一层。在企业级应用中你会大量地写这些代码(这应该真的会使你开始思考设计与体系结构)。它还引导我们朝正确的方向努力。它使CustomerServiceImpl 类生效,而且定义了在服务与资源库之间的交互。
  因为我们依旧关心信用卡注册,下一个测试会梳理出一个具体的PaymentService接口,这个接口也具有直通性质。
@Test
public void registerNewCardDetailsAndStoreSecureIdentifier(){
final CsutomerId customerId = new CustomerId(12345);
PaymentServiceImpl testedService = new PaymentServiceImpl();
CreditCardRepository cardRepositoryMock
= mock(CreditCardRepository.class);
CreditCardGateway creditCardGatewayStub
= mock(CreditCardGateway.class);
testedService.setCreditCardRepository(cardRepositoryMock);
testedService.setCreditCardGateway(CreditCardType.VISA,
creditCardGatewayStub);
CreditCardDetails cardDetails
=new CreditCardDetails(CreditCardType.VISA,
1111222233334444L, 123);
when(creditCardGatewayStub.registerCreditCard("1111222233334444",
"123")).thenReturn("FA04BC12");
testedService.registerCreditCard(customerId, cardDetails);
verify(cardRepositoryMock).save(
new SecureCreditCardId(customerId, "FA04BC12"));
}
  这个测试比CustomerService的测试更复杂,促使我们开始思考数据是怎么呈现的。例如,信用卡网关的接口似乎是面向字符串的,然而我们的代码使用了领域对象比如SecureCredit CardId。
  15.1.2  增加更多测试
  有了包含更多构件的例子,很明显,我是按照广度优先的方式来添加测试的。在一个各种层次几乎都委托调用给更低层次的构件的系统中,这是最简便的方法。但是,当我们涉及"边缘"类比如CustomerRepository(见图15.2),匿名的集成客户端,一个领域类,或者一个执行一些计算的类时,我们该做什么?
  我们转换策略!使用虚拟对象测试这些类意义不大。如果边缘类会进行持久化操作或者调用了一个远程系统,很可能会需要一个某种类型的集成测试。 相反地,如果它仅仅执行了一些计算,一个正常的基于状态的单元测试将会满足需要。
  
图15.2  当在分层系统中使用模拟主义时,我们就在实践按广度优先的方式来添加基于模拟的测试。
  那么,我们在边缘转换策略。
  回避Mockist TDD
  在我看来,很多资料,特别是一些老的资料,通常会在Mockist和经典(Classic)TDD之间进行无关紧要的比较。通常,它们讨论一个纯算法的问题,尝试使用一个Mockist方式来解决这个问题,并且给予它较差的评价。这类资料还往往使用"总是"和"从不"这样一类的词汇。一个争议可以像这样:"一个Mockist TDD的实践者会总是使用虚拟。当测试驱动一个排序算法时,他会验证元素是否被交换了,但是事实上他的测试永远不能区分是否表单真正被排序了。"
  基于交互的测试有一些缺点,并且它们已经包括在第 12 章"测试替身"中。这类测试过度使用会锁定实现,但是谨慎使用,这类测试会帮助我们发现设计巧妙的交互。因此,恰当选择适用于解决身边问题的风格,并且不要害怕在经典的和Mockist TDD之间转换。
  15.2  双环TDD
  仅使用基于模拟交互测试来开发代码会使你感到有一点不舒服。每天结束时间,这类测试不能确定程序是否能够像一个整体那样正常工作。设计已经被测试驱动并且所有的交互都被验证,这是很好的,但是,这一切结合起来了吗?记住每个测试只检查在相邻层协作者之间的交互。
  《测试指导开发面向对象软件》这本书的作者描述了一个非常不错的解决方案(Freeman 和Pryce 2009)。他们提出了双环TDD,即使他们自己从未使用过这个术语(如果我记得没错的话)。
  15.2.1  另一个反馈环
  就像名字暗示的那样,双环测试驱动开发在开发周期中加了另一个反馈环(见图15.3)。这是通过引入一个自动化的"验收测试" 来实现的,而这个测试是在写第一个单元测试之前创建的(针对与系统边界最接近的对象)。写这个测试是为了能够端到端地操作已实现的特性。通过写这样一个测试,我们能获得三个好处:
  (1)它让我们验证了所有的交互测试和任何其他相关的单元测试,合起来可以达成一个解决方案。
  (2)它告诉我们什么时候一个更庞大的功能会真正完成。
  (3)它促使我们用现实可行的方法去配置并且调用新的特性。
  在不用的应用类型里,"端到端"可能意味着不同的事情。无论怎样,测试的意图是从外部执行系统,以便于系统的所有构件都以某种方式被配置好,并且以便于被测试的功能将会像用户那样或者其他系统那样被使用。基于多样的配置选择和一些应用栈的复杂性,创建第一个自动化的端到端验收测试是具有挑战性的,因为在写任何生产代码之前它需要整个基础设施准备就绪。然而,这个测试在上下文中放置了新的功能并且允许在开发的最开始验证技术堆栈和它的构件。再也没有后期集成问题了。
  你还记得在这一章的第一个例子,用户注册端点吗?使用端到端测试来测试它会需要你做以下这些:
  (1)初始化能为注册提供网络服务的框架或者容器
  (2)配置/初始化注册端点
  (3)传送注册详细信息到端点
  (4)验证结果
  (5)关闭所有东西
  第(1)、(2)和(5)点是必要的管道,即使它们促使你决定怎样配置端点。 为了通过第(3)点,你必须想出测试是使用一个框架来调用服务,还是使用一个更原始的方法,比如说一个手工的HTTP POST请求。第(4)点很有趣。你会怎样验证结果?在一个端到端测试中,正常情况下向底层的数据库请求查询安全标识符会是欺骗性的。然而,因为它是一个安全标识符,这可能是唯一方法(除非测试变得更加庞大,包含了对于能呈现标识符的信用卡通道的调用)。这样的难题是第18章"超越单元测试"的主题。
  
图15.3  双环测试驱动开发:外围反馈圈由一个端到端"验收测试"组成,内部圈由经典的或Mockist测试驱动支持。
  15.2.2  关闭周期
  在第2章"测试目标、方式和角色"中,我简要地通过例子提到过BDD、验收测试驱动开发和说明书--三种技术,它们有一个共同点:它们依靠来源于与用户相关的开发实例的自动化验收测试。所以,将这些技术看作双环测试驱动开发在任何方面都不会有争议。
  15.3  小结
  模拟测试驱动开发对于测试驱动开发来说是一个可选的方法(相对于"经典"测试驱动开发而存在)。这种风格主要专注于系统的设计,而不是私有类的接口。就像名字暗示的那样,虚拟对象是很重要的组成部分,因为它们被用来驱动交互和在对象之间建立接口。
  双环测试驱动开发意味着单元测试被自动化端到端验收测试提前了,这需要完整的基础设施和特性的过程配置准备就绪。这样的测试会失败,直到整个特性被实现。如果大部分测试是基于虚拟对象,并且很难判断是否所有交互的总和等价于特性的正确实现,那么将这个安全网加入测试驱动开发是特别有帮助的。

相关文章
版权声明:51Testing软件测试网获人民邮电出版社和作者授权连载本书部分章节。
任何个人或单位未获得明确的书面许可,不得对本文内容复制、转载或进行镜像,否则将追究法律责任。
《2023软件测试行业现状调查报告》独家发布~

关注51Testing

联系我们

快捷面板 站点地图 联系我们 广告服务 关于我们 站长统计 发展历程

法律顾问:上海兰迪律师事务所 项棋律师
版权所有 上海博为峰软件技术股份有限公司 Copyright©51testing.com 2003-2024
投诉及意见反馈:webmaster@51testing.com; 业务联系:service@51testing.com 021-64471599-8017

沪ICP备05003035号

沪公网安备 31010102002173号