逸言

Tasking in TDD

| Comments

我和同事李彦辉今天结对实现了一个User Story,这个故事的需求在昨天已经讨论得比较清楚,其中一部分工作因为数据安全以及部署基础设施的原因,由澳洲的客户来实现完成。因此,我们的工作就变成为将消息propogate到指定的external queue。这事实上可以理解为两部分工作,第一是生成或组装Queue希望获得的消息,第二才是消息的propogation。

消息的获得大约要经历如下步骤。首先是通过GlobalCustomer的Id,获得对应的指定产品的ProductCustomer(可能包含多个)。由于GlobalCustomer与ProductCustomer之间存在多对多的关系,我们还需要根据获得的每个ProductCustomer,逆向反推出它所对应的GlobalCustomer(可能包含多个)。在得到ProductCustomer对应的GlobalCustomer后,再调用GetConsent的Web Service,获得每个GlobalCustomer对应的Consent信息。之后,再根据Consent信息中包含的某些Indicator值,运用业务规则,获得最终external queue需要的由indicator值以及客户的基本信息组成的消息。

在分解任务时,我的直觉告诉我可以通过这个分析出来的执行步骤来划分任务,而且我们事先已经获知,查询GlobalCustomer与ProductCustomer之间的信息可以通过调用系统已有的EjbBean来实现。根据这样的任务分解,我们进行测试驱动,似乎可以编写的第一个测试用例为:

@Test
public void should_retieve_associate_product_customers_by_global_customer_id(){
}

然而,在开始测试驱动时,我首先想到的是我们自己的Service如何与已有的CustomerDao(其内部又调用了FindProfile的EjbBean)进行交互,所以我打算先写一个基础的测试用例,表现这种对象的协作关系,例如:

@Test
public void should_invoke_CustomerDao(){
  CustomerDao dao = createMock(CustomerDao.class);
  expect(dao.findProductCustomersBy(customerId).andReturn(productCustomers);
  replay(dao);
  
  retrieveService = new RetrieveCustomerService(dao);
  retrieveService.retrieve(customerId);
  
  verify();
}

然而,这似乎只是一个简单的职责委派,编写这样的一个测试用例并没有任何价值。同事认为,我们在分解任务时,应该从功能上分析,即根据指定的GlobalCustomer的id获得一个具有ProductCustomer与GlobalCustomer映射关系的最终对象,这个映射关系需要一个概念来表示。经过分析,这个概念其实就是最终我们要得到的GlobalCustomer,区别在于这个retrieve的行为,它需要表现领域特征。这个行为的输入是GlobalCustomerId,输出则是List。这样分解的好处是可以直接寻找到我们需要的接口,再通过这个测试用例去驱动我们的设计。例如,我们编写出这样的测试用例:

@Test
public void should_retrieve_associate_global_customers_which_have_same_product_customer() {
  List<GlobalCustomer> gCustomers = service.retrieve(gCustomerId);
  assertThat(gCustomers.size(), is(5);
}

我们对retrieve()这个名称并不满意,但目前我们没有找到合适的行为概念,所以就保留了这一定义。因为我们有测试保证,一旦找到正确的行为特征,我们可以及时重构。在这个测试用例中,我们对返回结果进行的断言是不合理的,因为这个断言断定了返回的结果数目。这可能是不稳定的。为了保证测试的稳定性,我们需要为其准备数据。我们分别为GlobalCustomer与ProductCustomer定义了Builder来构建测试数据。而随着对这个测试用例的逐渐完善,我们发现了在CustomerDao中还需要提供一个方法findGlobalCustomerBy(productId, productCustomerId)。我们可以继续对CustomerDao提供Mock实现,但最好的方式是转移阵地,先为CustomerDao编写单元测试。

我忠实地记录了今天结对时,进行任务分解的过程。然而我一直在思考,可否通过我建立的职责模型来帮助我们进行业务分解呢?这个职责模型如下图所示:

我在这个模型中将职责分为三个层次:业务价值、业务功能与业务实现。这里有个前提,就是在划分职责时,需要基于某个特定的场景。就好像我们在识别用例时,需要识别用例的边界一般。通常意义上,业务价值就是在实现这个需求时,你的消费者(可以是UI的视图、控制器,又或者是领域层,或者客户端)所希望调用的接口。这个消费者可以理解为角色,它是参与这个场景的入口,识别职责就是从角色的视角出发,理解需求。

在编写User Story时,我们有一个固定的模板,即as……,I want to……,So that……。这里的as子句就是要我们寻找的角色。注意,这里的角色不一定是具体映射到现实世界中的人,而应该是参与者,可能是人,也可能是系统的某个对象。So that就是这个User Story所要体现的价值。这与User Story的INVEST原则中的V是完全一致的。

当我们识别出业务价值后,它就成为了我们需要识别的最外部接口,我们就可以按照这个业务价值来进行测试驱动。当然,谨慎的做法是再继续细分,在识别价值后,分析实现这些价值应该由哪些业务功能组成。我的同事李彦辉认为这就是寻找解决方案的过程。不同的解决方案可能导致不同的测试驱动方向。他提到的一个例子是消息转换。一种解决方案是通过jaxb将消息转换为Java对象,然后再定义转换映射的Transformer,通过硬编码或者反射的方式将其转换为相关的领域对象。在执行了业务操作后,再将返回的结果转换为另一个Jaxb对象。而另一种解决方案则是通过引入模板,例如StringTemplate或者Velocity,定义转换的模板,然后进行替换实现。这两种解决方案的区别,直接影响了我们划分任务的方式。

因此,我们可以将这个识别功能的过程,看做是寻找解决方案的过程。在这个过程中,若有对技术不了解的环节,则需要做一定程度的Spike。Spike的过程仍然可以通过TDD来完成。在《测试驱动开发的艺术(Test Driven-Practical TDD and Acceptance TDD for Java Developers)》一书中,将其称之为学习测试(Learning Test)。此外,Spike需要设定一个TimeBox,以避免陷入无休止的探索中。

我们识别的这些业务功能,组成了实现整个业务价值的每个环节。多个业务功能可能体现的是一个业务流程,也可能随着角色的场景变换(因为场景也可以是嵌套的,即在大的场景中为业务功能的内容,放在小的场景中其实是业务价值),体现不同的设计意图。举例来说,从业务价值看,我们需要提供邮件转发的业务;而在分析邮件转发的业务价值时,又可以得到发送邮件的业务功能。站在实现者角度看,所谓邮件的转发就是发送邮件,但二者在业务概念上还是存在层次上的差别。站在最外层场景的角度来看,转发才是场景消费者真正关心的业务目标。

模型中最里面的一圈为业务实现,它往往关注的是在实现每个功能时,需要通过什么方式来实现,这就可能牵涉到对基础设施的访问,例如对xml文件、数据库、网络方面的调用。分析到这里时,基本上我们已经可以编码实现了。

借助这个模型,我们可以从业务价值这一层开始测试驱动。不过最佳方式应该是在充分地理解需求后,通过探索解决方案以获得业务功能后,再根据功能划分任务。注意,这个模型是有层次的,场景也是可以嵌套的,因而这个过程是一个渐进的过程,是一个层层递进的过程,可以由外及内,也需要在合适的时候,先跳到模型的内层,在对其实现进行测试驱动时,再回到外部一层,关注这个业务价值中各个业务功能对象之间的协作。注意,这种协作本身也可能体现一种业务逻辑。倘若仅仅是单纯的职责委派,就没有编写测试的必要了。

让我们采用这个模型来识别我们要实现的User Story的职责,列举如下:

传播处理后的Consent消息给指定Queue 
    组装并生成需要的Consent消息; 
        根据GlobalCustomerId获得对应的ProductCustomers以及它们所对应的GlobalCustomers; 
            调用FindProfileEjb根据GlobalCustomerId获得对应的ProductCustomers对象; 
            遍历ProductCustomer对象,并调用FindProfileEjb获得对应的GlobalCustomer对象;
        调用WebService获得GlobalCustomer对应的Consent信息; 
        根据业务规则获得该GlobalCustomer正确的Indicator值; 
        根据Indicator值与GlobalCustomer信息组装消息;
    传播消息给指定Queue

整体看来,这是一个嵌套四层的任务分解列表。最外层的任务就是我们的业务价值,它又被分解为两个业务功能,相当于业务价值实现的两个部分,但却分别承担了两个不同的职责。如果从封装职责的角度来看,这里识别出来的第二个功能所承担的主要职责为消息的propagation,从职责上看,它与承担业务价值的对象(可以命名为PropagateConsentService)有着非常紧密的关系,因而可以直接将该功能分配给它。除非单独的消息传播功能还有其他重用的必要。根据业务价值的第一个功能的描述,其实我们可以定义一个ConsentMessageAssembler,并根据识别出来的下一级功能,得到RetrieveCustomerService。依此类推,这里不再赘述。通过这种业务职责分析模型,在一定程度上也可以帮助我们进行任务分解。最重要的一点还是在于我们要深入地分析需求,理清思路,并通过探寻获得合理的解决方案。

Comments