如何使用测试驱动的开发为治疗师省钱

你有过这种情况吗?

图片

我想通过一个特定的例子向您展示TDD如何提高代码质量。
因为我在研究该问题时遇到的所有东西都是理论上的。
碰巧,我碰巧写了两个几乎相同的应用程序:一个是用古典风格编写的,因为那时我不知道TDD,第二个是只使用TDD。

在下面,我将显示最大的差异在哪里。

就我个人而言,这对我很重要,因为每次有人在我的代码中发现错误时,我都会为自尊心而感到沮丧。 是的,我知道错误是正常现象,每个人都在编写,但是自卑感并没有消失。 另外,在服务的发展过程中,有时我意识到自己写了这样的书,以至于发痒地将所有内容扔掉并重新编写。 以及如何发生是不可理解的。 刚开始的时候一切都很好,但是经过一些功能之后,过了一会儿,您就不会眼泪汪汪。 尽管看起来变更的每一步都是合乎逻辑的。 我不喜欢自己工作的产品的感觉顺理成章地变成了程序员来自我的感觉,对不起,就像狗屎一样。

事实证明,我并不是唯一的一个,许多同事也有类似的感觉。 然后我决定要么学习正常的写作,要么就该改变职业了。 我尝试了测试驱动的开发,以尝试更改编程方法。

展望未来,基于几个项目的结果,我可以说TDD提供了一种更简洁的体系结构,但是却减慢了开发速度。 而且它并不总是合适,也不适合每个人。

什么是TDD


图片


TDD-通过测试进行开发。 Wiki文章在这里
经典方法是首先编写一个应用程序,然后用测试覆盖它。

TDD方法-首先我们为类编写测试,然后执行。 我们沿着抽象的层次前进-从最高层次到应用层次,同时将应用划分为类层,从中我们订购所需的行为,而无需特定的实现。

而且,如果我是第一次阅读本文,我也不会理解。
太多的抽象词:让我们看一个例子。
我们将用Java编写一个真正的spring应用程序,用TDD编写它,并且我将尝试在开发过程中展示我的思维过程,最后得出结论,是否有必要在TDD上花费时间。

实际任务


假设我们很幸运,能够掌握需要开发的东西。 通常,分析人员不会理会它,它看起来像这样:

有必要开发一种微服务,以计算出售商品并随后将其交付给客户的可能性。 有关此功能的信息必须发送到第三方DATA系统。

业务逻辑如下:符合以下条件的商品可出售并交货:

  • 产品有现货
  • 承包商(例如,DostavchenKO公司)有机会将其带给客户
  • 产品颜色-不是蓝色(我们不喜欢蓝色)

通过http请求,我们的微服务将收到有关货架上商品数量变化的通知。

该通知是用于计算可用性的触发器。

另外,让生活似乎不再是甜蜜的:

  • 用户应该能够手动禁用某些产品。
  • 为了不向数据垃圾邮件,您只需要发送已更改产品的可用性数据。

我们读了几次传统知识-走吧。



整合测试


在TDD中,您必须对编写的所有内容提出最重要的问题之一:“我想从...获得什么?”

我们要问的第一个问题只是针对整个应用程序。
所以问题是:

我想从微服务中获得什么?

答案是:

其实很多事情。 即使是这样简单的逻辑也提供了很多选项,尝试编写这些选项,甚至为所有这些选项创建测试甚至是不可能的任务。 因此,为了在应用程序级别回答问题,我们将仅选择主要的测试用例。

也就是说,我们假设所有输入数据都是有效格式,第三方系统正常响应,并且以前没有有关该产品的信息。

所以,我想:

  • 一个事件到了,架子上没有产品。 通知无法交付。
  • 发生了黄色产品有现货的事件,DostavchenKO准备接收它。 通知有关商品的可用性。
  • 连续出现两条消息-商店中的商品数量均为正。 仅发送了一封邮件。
  • 收到两个消息:第一个消息在商店中有一种产品,第二个消息-它不再存在。 我们发送两条消息:首先-可用,然后-否。
  • 我可以手动禁用产品,并且不再发送通知。
  • ...

这里的主要目的是及时停止:正如我已经写过的,选择太多了,在这里描述所有这些选择是没有意义的-仅是最基本的选择。 将来,当我们编写业务逻辑测试时,它们的组合很可能涵盖我们在此处提出的所有内容。 这里的主要动机是确保如果这些测试通过,那么该应用程序将按我们的要求工作。

所有这些愿望清单我们现在将进行测试。 而且,由于这是应用程序级别的“愿望清单”,因此我们将通过提高spring上下文进行测试,即相当繁重。
不幸的是,对于许多TDD端来说,这是因为要编写这样的集成测试,您需要付出很多努力,而人们并不总是愿意花这些钱。 是的,这是最困难的一步,但是,相信我,在您完成之后,代码几乎会自行编写,并且您将确保您的应用程序能够按照您想要的方式运行。


在回答问题的过程中,您已经可以开始在生成的spring initializr类中编写代码了。 测试名称只是我们的愿望清单。 现在,只需创建空方法:

@Test public void notifyNotAvailableIfProductQuantityIsZero() {} @Test public void notifyAvailableYellowProductIfPositiveQuantityAndDostavchenkoApproved() {} @Test public void notifyOnceOnSeveralEqualProductMessages() {} @Test public void notifyFirstAvailableThenNotIfProductQuantityMovedFromPositiveToZero() {} @Test public void noNotificationOnDisabledProduct() {} 

关于方法的命名:我强烈建议您使它们具有信息性,而不是test1(),test2(),因为稍后,当您忘记编写了什么类以及它负责什么类时,您将有机会代替尝试直接解析代码,只需打开测试并阅读该类满足的合同方法。

开始填写测试


主要思想是模拟外部所有内容,以检查内部正在发生的事情。

与我们的服务有关的“外部”不是微服务本身,而是与之直接通信的全部。

在这种情况下,外部是:

  • 我们的服务将通知有关商品数量变化的系统
  • 手动断开商品的客户
  • 第三方DostavchenKO系统

为了模拟前两个请求,我们使用了弹簧式的MockMvc。
为了模拟DostavchenKO,我们使用wiremock或MockRestServiceServer。

结果,我们的集成测试如下所示:

整合测试
 @RunWith(SpringRunner.class) @SpringBootTest @AutoConfigureMockMvc @AutoConfigureWireMock(port = 8090) public class TddExampleApplicationTests { @Autowired private MockMvc mockMvc; @Before public void init() { WireMock.reset(); } @Test public void notifyNotAvailableIfProductQuantityIsZero() throws Exception { stubNotification( // language=JSON "{\n" + " \"productId\": 111,\n" + " \"available\": false\n" + "}"); performQuantityUpdateRequest( // language=JSON "{\n" + " \"productId\": 111,\n" + " \"color\" : \"red\", \n" + " \"productQuantity\": 0\n" + "}"); verify(1, postRequestedFor(urlEqualTo("/notify"))); } @Test public void notifyAvailableYellowProductIfPositiveQuantityAndDostavchenkoApproved() throws Exception { stubDostavchenko("112"); stubNotification( // language=JSON "{\n" + " \"productId\": 112,\n" + " \"available\": true\n" + "}"); performQuantityUpdateRequest( // language=JSON "{\n" + " \"productId\": 112,\n" + " \"color\" : \"Yellow\", \n" + " \"productQuantity\": 10\n" + "}"); verify(1, postRequestedFor(urlEqualTo("/notify"))); } @Test public void notifyOnceOnSeveralEqualProductMessages() throws Exception { stubDostavchenko("113"); stubNotification( // language=JSON "{\n" + " \"productId\": 113,\n" + " \"available\": true\n" + "}"); for (int i = 0; i < 5; i++) { performQuantityUpdateRequest( // language=JSON "{\n" + " \"productId\": 113,\n" + " \"color\" : \"Yellow\", \n" + " \"productQuantity\": 10\n" + "}"); } verify(1, postRequestedFor(urlEqualTo("/notify"))); } @Test public void notifyFirstAvailableThenNotIfProductQuantityMovedFromPositiveToZero() throws Exception { stubDostavchenko("114"); stubNotification( // language=JSON "{\n" + " \"productId\": 114,\n" + " \"available\": true\n" + "}"); performQuantityUpdateRequest( // language=JSON "{\n" + " \"productId\": 114,\n" + " \"color\" : \"Yellow\",\n" + " \"productQuantity\": 10\n" + "}"); stubNotification( // language=JSON "{\n" + " \"productId\": 114,\n" + " \"available\": false\n" + "}"); performQuantityUpdateRequest( // language=JSON "{\n" + " \"productId\": 114,\n" + " \"color\" : \"Yellow\",\n" + " \"productQuantity\": 0\n" + "}"); verify(2, postRequestedFor(urlEqualTo("/notify"))); } @Test public void noNotificationOnDisabledProduct() throws Exception { stubNotification( // language=JSON "{\n" + " \"productId\": 115,\n" + " \"available\": false\n" + "}"); disableProduct(115); for (int i = 0; i < 5; i++) { performQuantityUpdateRequest( // language=JSON "{\n" + " \"productId\": 115,\n" + " \"color\" : \"Yellow\",\n" + " \"productQuantity\": " + i + "\n" + "}"); } verify(1, postRequestedFor(urlEqualTo("/notify"))); } private void disableProduct(int productId) throws Exception { mockMvc.perform( post("/disableProduct?productId=" + productId) ).andDo( print() ).andExpect( status().isOk() ); } private void performQuantityUpdateRequest(String content) throws Exception { mockMvc.perform( post("/product-quantity-update") .contentType(MediaType.APPLICATION_JSON) .content(content) ).andDo( print() ).andExpect( status().isOk() ); } private void stubNotification(String content) { stubFor(WireMock.post(urlEqualTo("/notify")) .withHeader("Content-Type", equalTo(MediaType.APPLICATION_JSON_UTF8_VALUE)) .withRequestBody(equalToJson(content)) .willReturn(aResponse().withStatus(HttpStatus.OK_200))); } private void stubDostavchenko(final String productId) { stubFor(get(urlEqualTo("/isDeliveryAvailable?productId=" + productId)) .willReturn(aResponse().withStatus(HttpStatus.OK_200).withBody("true"))); } } 

刚刚发生了什么?


我们编写了一个集成测试,通过该测试可以根据主要的用户情况保证我们系统的可操作性。 在开始实施服务之前,我们做到了。

这种方法的优点之一是,在编写过程中,我不得不去真正的 DostavchenKO,并从那里得到对我们对存根的真实请求的真实答案。 很好的是,我们在开发之初就照顾了这一点,而不是毕竟编写了代码。 事实证明,该格式不是TOR中指定的格式,或者该服务通常不可用,或其他原因。

我还要指出的是,我们不仅没有编写一行代码供以后使用,而且甚至没有对我们的微服务将如何安排在内部进行单一假设:将有多少层,是否存在?我们使用基础(如果使用的话),基础(哪一个)等。在编写测试时,我们从实现中抽象出来,并且,正如我们将在后面看到的,这可以提供许多体系结构上的优势。

与规范的TDD(在测试后立即编写实现)相反,集成测试不会花费很长时间。 实际上,直到开发结束,直到包括文件在内的所有内容都写完,它才会变成绿色。
我们走得更远。

控制者


在编写了集成测试之后,现在我们确信在完成任务之后,我们可以在晚上安然入睡,是时候开始对层进行编程了。 我们将实现的第一层是控制器。 为什么是他? 因为这是程序的入口点。 我们需要从上到下,从与用户进行交互的第一层到最后一层。
这很重要。

再说一次,所有问题都始于相同的问题:

我想从控制器中得到什么?

答案是:

我们知道控制器参与了与用户的交流,输入数据的验证和转换,并且不包含业务逻辑。 因此,此问题的答案可能是这样的:

我要:

  • 尝试断开具有无效ID的产品时,BAD_REQUEST返回给用户
  • BAD_REQUEST尝试使用无效ID通知商品更改时
  • 尝试通知负数量时为BAD_REQUEST
  • 如果DostavchenKO不可用,则为INTERNAL_SERVER_ERROR
  • INTERNAL_SERVER_ERROR,如果无法发送到DATA

由于我们希望对用户友好,因此对于上述所有项目,除了http代码外,您还需要显示一条描述问题的自定义消息,以便用户理解问题所在。

  • 200,如果处理成功
  • INTERNAL_SERVER_ERROR,在所有其他情况下均带有默认消息,以免影响堆栈

在开始撰写TDD之前,我最后要考虑的是在某些特殊的情况(乍一看,不太可能出现的情况)下,系统将为用户带来什么。 我不认为有一个简单的原因-编写实现非常困难,为了考虑到所有极端情况,有时大脑中没有足够的RAM。 在完成书面实施后,仍然可以对一些您可能未事先考虑的内容进行分析,这仍然是一件很愉快的事情:我们都认为我们正在立即编写完美的代码。 虽然没有实现,但是没有必要考虑它,并且如果需要修改它也没有痛苦。 首先编写测试,您不必等到恒星汇聚之后,退出产品后,一定数量的系统将发生故障,并且客户将向您提出要求修复某些问题的要求。 这不仅适用于控制器。

开始编写测试


在前三个方面,一切都很清楚:我们使用spring验证,如果无效请求到达,则应用程序将引发异常,我们将在异常处理程序中捕获该异常。 就像他们所说的那样,一切都可以独立运行,但是控制器如何知道某些第三方系统不可用?

显然,控制器本身不应该对第三方系统一无所知,因为 询问什么系统以及什么是业务逻辑,即必须存在某种中介。 该中介是服务。 我们将使用此服务的模拟在控制器上编写测试,以模拟其在某些情况下的行为。 因此,该服务必须以某种方式通知控制器系统不可用。 您可以通过不同的方式来执行此操作,但这是引发自定义执行的最简单方法。 我们将为此控制器行为编写一个测试。

测试与第三方DATA系统的通信错误
 @RunWith(SpringRunner.class) @WebMvcTest @AutoConfigureMockMvc public class ControllerTest { @MockBean private UpdateProcessorService updateProcessorService; @Test public void returnServerErrorOnDataCommunicationError() throws Exception { doThrow(new DataCommunicationException()).when(updateProcessorService).processUpdate(any(Update.class)); performUpdate( //language=JSON "{\n" + " \"productId\": 1,\n" + " \"color\": \"red\",\n" + " \"productQuantity\": 10\n" + "}" ).andDo( print() ).andExpect( status().isInternalServerError() ).andExpect( content().json("{\n" + " \"errors\": [\n" + " {\n" + " \"message\": \"Can't communicate with Data system\"\n" + " }\n" + " ]\n" + "}") ); } } 


在此阶段,自己出现了几件事:

  • 一项服务将被注入到控制器中,并被委派处理新数量商品的传入消息。
  • 此服务的方法及其签名将执行此处理。
  • 当系统不可用时,该方法应引发自定义执行的实现。
  • 此自定义执行本身。

为什么要自己? 如您所记得,因为我们尚未编写实现。 所有这些实体都出现在我们编写测试程序的过程中。 为了使编译器不会发誓,在实际代码中,我们将必须创建上述所有内容。 幸运的是,几乎所有的IDE都会帮助我们生成必要的实体。 因此,我们编写了一个测试-应用程序充满了类和方法。

总体而言,对控制器的测试如下:

测验
 @RunWith(SpringRunner.class) @WebMvcTest @AutoConfigureMockMvc public class ControllerTest { @InjectMocks private Controller controller; @MockBean private UpdateProcessorService updateProcessorService; @Autowired private MockMvc mvc; @Test public void returnBadRequestOnDisableWithInvalidProductId() throws Exception { mvc.perform( post("/disableProduct?productId=-443") ).andDo( print() ).andExpect( status().isBadRequest() ).andExpect( content().json(getInvalidProductIdJsonContent()) ); } @Test public void returnBadRequestOnNotifyWithInvalidProductId() throws Exception { performUpdate( //language=JSON "{\n" + " \"productId\": -1,\n" + " \"color\": \"red\",\n" + " \"productQuantity\": 0\n" + "}" ).andDo( print() ).andExpect( status().isBadRequest() ).andExpect( content().json(getInvalidProductIdJsonContent()) ); } @Test public void returnBadRequestOnNotifyWithNegativeProductQuantity() throws Exception { performUpdate( //language=JSON "{\n" + " \"productId\": 1,\n" + " \"color\": \"red\",\n" + " \"productQuantity\": -10\n" + "}" ).andDo( print() ).andExpect( status().isBadRequest() ).andExpect( content().json("{\n" + " \"errors\": [\n" + " {\n" + " \"message\": \"productQuantity is invalid\"\n" + " }\n" + " ]\n" + "}") ); } @Test public void returnServerErrorOnDostavchenkoCommunicationError() throws Exception { doThrow(new DostavchenkoException()).when(updateProcessorService).processUpdate(any(Update.class)); performUpdate( //language=JSON "{\n" + " \"productId\": 1,\n" + " \"color\": \"red\",\n" + " \"productQuantity\": 10\n" + "}" ).andDo( print() ).andExpect( status().isInternalServerError() ).andExpect( content().json("{\n" + " \"errors\": [\n" + " {\n" + " \"message\": \"DostavchenKO communication exception\"\n" + " }\n" + " ]\n" + "}") ); } @Test public void returnServerErrorOnDataCommunicationError() throws Exception { doThrow(new DataCommunicationException()).when(updateProcessorService).processUpdate(any(Update.class)); performUpdate( //language=JSON "{\n" + " \"productId\": 1,\n" + " \"color\": \"red\",\n" + " \"productQuantity\": 10\n" + "}" ).andDo( print() ).andExpect( status().isInternalServerError() ).andExpect( content().json("{\n" + " \"errors\": [\n" + " {\n" + " \"message\": \"Can't communicate with Data system\"\n" + " }\n" + " ]\n" + "}") ); } @Test public void return200OnSuccess() throws Exception { performUpdate( //language=JSON "{\n" + " \"productId\": 1,\n" + " \"color\": \"red\",\n" + " \"productQuantity\": 10\n" + "}" ).andDo( print() ).andExpect( status().isOk() ); } @Test public void returnServerErrorOnUnexpectedException() throws Exception { doThrow(new RuntimeException()).when(updateProcessorService).processUpdate(any(Update.class)); performUpdate( //language=JSON "{\n" + " \"productId\": 1,\n" + " \"color\": \"red\",\n" + " \"productQuantity\": 10\n" + "}" ).andDo( print() ).andExpect( status().isInternalServerError() ).andExpect( content().json("{\n" + " \"errors\": [\n" + " {\n" + " \"message\": \"Internal Server Error\"\n" + " }\n" + " ]\n" + "}") ); } @Test public void returnTwoErrorMessagesOnInvalidProductIdAndNegativeQuantity() throws Exception { performUpdate( //language=JSON "{\n" + " \"productId\": -1,\n" + " \"color\": \"red\",\n" + " \"productQuantity\": -10\n" + "}" ).andDo( print() ).andExpect( status().isBadRequest() ).andExpect( content().json("{\n" + " \"errors\": [\n" + " { \"message\": \"productQuantity is invalid\" },\n" + " { \"message\": \"productId is invalid\" }\n" + " ]\n" + "}") ); } private ResultActions performUpdate(String jsonContent) throws Exception { return mvc.perform( post("/product-quantity-update") .contentType(MediaType.APPLICATION_JSON_UTF8_VALUE) .content(jsonContent) ); } private String getInvalidProductIdJsonContent() { return //language=JSON "{\n" + " \"errors\": [\n" + " {\n" + " \"message\": \"productId is invalid\"\n" + " }\n" + " ]\n" + "}"; } } 

现在,我们可以编写实现并确保所有测试成功通过:
实作
 @RestController @AllArgsConstructor @Validated @Slf4j public class Controller { private final UpdateProcessorService updateProcessorService; @PostMapping("/product-quantity-update") public void updateQuantity(@RequestBody @Valid Update update) { updateProcessorService.processUpdate(update); } @PostMapping("/disableProduct") public void disableProduct(@RequestParam("productId") @Min(0) Long productId) { updateProcessorService.disableProduct(Long.valueOf(productId)); } } 


异常处理程序
 @ControllerAdvice @Slf4j public class ApplicationExceptionHandler { @ExceptionHandler(ConstraintViolationException.class) @ResponseBody @ResponseStatus(HttpStatus.BAD_REQUEST) public ErrorResponse onConstraintViolationException(ConstraintViolationException exception) { log.info("Constraint Violation", exception); return new ErrorResponse(exception.getConstraintViolations().stream() .map(constraintViolation -> new ErrorResponse.Message( ((PathImpl) constraintViolation.getPropertyPath()).getLeafNode().toString() + " is invalid")) .collect(Collectors.toList())); } @ExceptionHandler(value = MethodArgumentNotValidException.class) @ResponseBody @ResponseStatus(value = HttpStatus.BAD_REQUEST) public ErrorResponse onMethodArgumentNotValidException(MethodArgumentNotValidException exception) { log.info(exception.getMessage()); List<ErrorResponse.Message> fieldErrors = exception.getBindingResult().getFieldErrors().stream() .map(fieldError -> new ErrorResponse.Message(fieldError.getField() + " is invalid")) .collect(Collectors.toList()); return new ErrorResponse(fieldErrors); } @ExceptionHandler(DostavchenkoException.class) @ResponseBody @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ErrorResponse onDostavchenkoCommunicationException(DostavchenkoException exception) { log.error("DostavchenKO communication exception", exception); return new ErrorResponse(Collections.singletonList( new ErrorResponse.Message("DostavchenKO communication exception"))); } @ExceptionHandler(DataCommunicationException.class) @ResponseBody @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ErrorResponse onDataCommunicationException(DataCommunicationException exception) { log.error("DostavchenKO communication exception", exception); return new ErrorResponse(Collections.singletonList( new ErrorResponse.Message("Can't communicate with Data system"))); } @ExceptionHandler(Exception.class) @ResponseBody @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ErrorResponse onException(Exception exception) { log.error("Error while processing", exception); return new ErrorResponse(Collections.singletonList( new ErrorResponse.Message(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase()))); } } 


刚刚发生了什么?


在TDD中,您不必把所有代码都牢记在心。

再次让我们:不要将整个体系结构保留在RAM中。 只看一层。 他很简单。

在通常的过程中,大脑是不够的,因为有很多实现方法。 如果您是一位超级英雄,可以考虑到脑海中一个大型项目的所有细微差别,那么就不需要TDD。 我不知道 项目越大,我就越会误解。

意识到只需要了解下一层的需求后,便会得到启发。 事实是这种方法允许您不要做不必要的事情。 在这里,您正在和一个女孩聊天。 她谈到工作中的问题。 然后您想出解决方案的方法,绞尽脑汁。 她不需要解决,她只需要告诉。 就是这样。 她只是想分享一些东西。 在listen()的第一阶段了解这一点是无价的。 对于其他一切……恩,你知道的。


服务专区


接下来,我们实现服务。

我们要从服务中得到什么?

我们希望他处理业务逻辑,即:

  1. 他知道如何断开货物, 并且还收到有关以下内容的通知
  2. 如果产品没有断开连接,则有现货供应,产品颜色为黄色,DostavchenKO准备交付。
  3. 无法访问,如果没有任何可用的商品。
  4. 如果产品为蓝色,则无法访问。
  5. 如果DostavchenKO拒绝携带它,则无法进入。
  6. 如果手动断开货物,则无法进入。
  7. 接下来,如果任何系统都不可用,我们希望该服务引发执行。
  8. 另外,为了不对数据发送垃圾邮件,您需要组织惰性发送消息,即:
  9. 如果我们过去曾经为商品发送可用商品,而现在我们已经计算了可用商品,那么我们什么也不发送。
  10. 如果以前不可用,但现在可用,我们将其发送。
  11. 你需要把它写下来

停!


您不认为我们的服务开始做得太多了吗?

根据我们的心愿单判断,他知道如何关闭商品,并考虑了可访问性,并确保他不发送以前发送的消息。 这不是很高的凝聚力。 有必要将异构功能移到不同的类别中,因此应该已经有三项服务:一项将处理货物的断开连接,另一项将计算交付的可能性并将其传递给决定是否发送的服务。 顺便说一句,以这种方式,业务逻辑服务将不了解有关DATA系统的任何信息,这也是绝对的优势。

以我的经验,很多时候,全心投入到实现中,很容易忽略架构时刻。 如果我们立即编写服务,而不考虑它应该做什么,更重要的是,比不应该做,那么责任重叠的可能性将会增加。 我想以我自己的名义补充说,这是我在实际实践中遇到的示例,而TDD的结果与顺序编程方法之间的质的差异启发了我写这篇文章。

业务逻辑


考虑到业务逻辑服务与高内聚性一样的原因,我们知道我们需要在它与真实的DostavchenKO之间进行另一层抽象。 而且,由于我们首先设计服务,所以我们可以向DostavchenKO客户要求我们想要的内部合同。 在编写业务逻辑测试的过程中,我们将了解具有以下签名的客户的要求:

 public boolean isAvailableForTransportation(Long productId) {...} 

在服务级别上,真正的DostavchenKO如何回答与我们无关紧要:将来,客户的任务将以某种方式从他那里获取这些信息。 这可能很简单,但有时需要提出几个请求:此刻我们已经抽象了。

我们希望从处理断货的服务中获得类似的签名:

 public boolean isProductEnabled(Long productId) {...} 

因此,测试中记录的问题“我希望从业务逻辑服务中获得什么?”如下所示:

服务测试
 @RunWith(MockitoJUnitRunner.class) public class UpdateProcessorServiceTest { @InjectMocks private UpdateProcessorService updateProcessorService; @Mock private ManualExclusionService manualExclusionService; @Mock private DostavchenkoClient dostavchenkoClient; @Mock private AvailabilityNotifier availabilityNotifier; @Test public void notifyAvailableIfYellowProductIsEnabledAndReadyForTransportation() { final Update testProduct = new Update(1L, 10L, "Yellow"); when(dostavchenkoClient.isAvailableForTransportation(testProduct.getProductId())).thenReturn(true); when(manualExclusionService.isProductEnabled(testProduct.getProductId())).thenReturn(true); updateProcessorService.processUpdate(testProduct); verify(availabilityNotifier, only()).notify(eq(new ProductAvailability(testProduct.getProductId(), true))); } @Test public void notifyNotAvailableIfProductIsAbsent() { final Update testProduct = new Update(1L, 0L, "Yellow"); updateProcessorService.processUpdate(testProduct); verify(availabilityNotifier, only()).notify(eq(new ProductAvailability(testProduct.getProductId(), false))); verifyNoMoreInteractions(manualExclusionService); verifyNoMoreInteractions(dostavchenkoClient); } @Test public void notifyNotAvailableIfProductIsBlue() { final Update testProduct = new Update(1L, 10L, "Blue"); updateProcessorService.processUpdate(testProduct); verify(availabilityNotifier, only()).notify(eq(new ProductAvailability(testProduct.getProductId(), false))); verifyNoMoreInteractions(manualExclusionService); verifyNoMoreInteractions(dostavchenkoClient); } @Test public void notifyNotAvailableIfProductIsDisabled() { final Update testProduct = new Update(1L, 10L, "Yellow"); when(manualExclusionService.isProductEnabled(testProduct.getProductId())).thenReturn(false); updateProcessorService.processUpdate(testProduct); verify(availabilityNotifier, only()).notify(eq(new ProductAvailability(testProduct.getProductId(), false))); verifyNoMoreInteractions(dostavchenkoClient); } @Test public void notifyNotAvailableIfProductIsNotReadyForTransportation() { final Update testProduct = new Update(1L, 10L, "Yellow"); when(dostavchenkoClient.isAvailableForTransportation(testProduct.getProductId())).thenReturn(false); when(manualExclusionService.isProductEnabled(testProduct.getProductId())).thenReturn(true); updateProcessorService.processUpdate(testProduct); verify(availabilityNotifier, only()).notify(eq(new ProductAvailability(testProduct.getProductId(), false))); } @Test(expected = DostavchenkoException.class) public void throwCustomExceptionIfDostavchenkoCommunicationFailed() { final Update testProduct = new Update(1L, 10L, "Yellow"); when(dostavchenkoClient.isAvailableForTransportation(testProduct.getProductId())) .thenThrow(new RestClientException("Something's wrong")); when(manualExclusionService.isProductEnabled(testProduct.getProductId())).thenReturn(true); updateProcessorService.processUpdate(testProduct); } } 


在这个阶段,他们是自己出生的:

  • DostavchenKO服务友好的客户
  • 一种服务,在其中必须实现延迟发送的逻辑,所设计的服务将向其发送工作结果
  • 断开货物的服务及其签名

实现方式:

实作
 @RequiredArgsConstructor @Service @Slf4j public class UpdateProcessorService { private final AvailabilityNotifier availabilityNotifier; private final DostavchenkoClient dostavchenkoClient; private final ManualExclusionService manualExclusionService; public void processUpdate(Update update) { if (update.getProductQuantity() <= 0) { availabilityNotifier.notify(getNotAvailableProduct(update.getProductId())); return; } if ("Blue".equals(update.getColor())) { availabilityNotifier.notify(getNotAvailableProduct(update.getProductId())); return; } if (!manualExclusionService.isProductEnabled(update.getProductId())) { availabilityNotifier.notify(getNotAvailableProduct(update.getProductId())); return; } try { final boolean availableForTransportation = dostavchenkoClient.isAvailableForTransportation(update.getProductId()); availabilityNotifier.notify(new ProductAvailability(update.getProductId(), availableForTransportation)); } catch (Exception exception) { log.warn("Problems communicating with DostavchenKO", exception); throw new DostavchenkoException(); } } private ProductAvailability getNotAvailableProduct(Long productId) { return new ProductAvailability(productId, false); } } 


禁用产品


现在是不可避免的TDD阶段之一-重构的时候了。如果您还记得,在执行控制器之后,服务合同将如下所示:

 public void disableProduct(long productId) 

现在,我们决定将断开逻辑转移到单独的服务中。

在这个阶段,我们需要以下服务:

  • 关闭商品的能力。
  • 如果他较早断开连接,我们希望他退回货物已断开连接的信息。
  • 如果以前没有断线,我们希望他退货。

看一下愿望清单,这是业务逻辑服务与预期的服务之间的合同的直接结果,我想指出以下几点:

  1. 首先,很明显,如果有人想关闭断开连接的产品,则应用程序可能会出现问题,因为目前此服务根本不知道该怎么做。这就意味着也许值得与制定开发任务的分析师讨论这个问题。我知道在这种情况下,应该在阅读ToR之后就出现了这个问题,但是我们正在设计一个相当简单的系统,在较大的项目中,这个问题可能并不那么明显。而且,我们不知道我们会有一个实体仅负责断开商品的功能:我记得我们只是在开发过程中诞生的。
  2. -, . — , . , , , , , , . . ProductAvailability. , . . ., , god object, , , , TDD, , . , , «» — : « ...» , , TDD, .

测试和实现非常简单:

测验
 @SpringBootTest @RunWith(SpringRunner.class) public class ManualExclusionServiceTest { @Autowired private ManualExclusionService service; @Autowired private ManualExclusionRepository manualExclusionRepository; @Before public void clearDb() { manualExclusionRepository.deleteAll(); } @Test public void disableItem() { Long productId = 100L; service.disableProduct(productId); assertThat(service.isProductEnabled(productId), is(false)); } @Test public void returnEnabledIfProductWasNotDisabled() { assertThat(service.isProductEnabled(100L), is(true)); assertThat(service.isProductEnabled(200L), is(true)); } } 


实作
 @Service @AllArgsConstructor public class ManualExclusionService { private final ManualExclusionRepository manualExclusionRepository; public boolean isProductEnabled(Long productId) { return !manualExclusionRepository.exists(productId); } public void disableProduct(long productId) { manualExclusionRepository.save(new ManualExclusion(productId)); } } 


延迟提交服务


因此,我们到达了最后一个服务,该服务将确保DATA系统不会被相同的消息发送垃圾邮件。

让我提醒您,业务逻辑服务(即ProductAvailability对象)的工作结果已经转移到该对象中,其中只有两个字段:productId和isAvailable。

根据良好的传统,我们开始考虑从此服务中获得什么:

  • 无论如何都是第一次发送通知。
  • 如果产品的可用性已更改,则发送通知。
  • 如果没有,我们不发送任何东西。
  • 如果发送到第三方系统以异常结束,则导致异常的通知不应包含在已发送通知的数据库中。
  • 同样,从DATA端执行时,服务需要引发其DataCommunicationException。

这里的一切都相对简单,但是我想指出一点:

我们需要有关我们之前发送的信息,这意味着我们将拥有一个存储库,我们将保存过去关于货物可用性的计算。

ProductAvailability对象不适合保存,因为至少没有标识符,这意味着创建另一个标识符是合乎逻辑的。这里的主要目的是不要吓跑,也不要将此标识符与@Document(我们将使用MongoDb作为基础)以及ProductAvailability本身的索引一起添加。

您需要了解,具有少数几个字段的ProductAvailability对象是在设计类的阶段创建的,这些类在调用层次结构中比我们现在正在设计的类更高。这些类不需要了解有关数据库特定字段的任何信息,因为在设计时不需要此信息。

但这就是全部。

有趣的是,由于我们已经编写了一系列测试,现在已经将其转移到服务中,并且具有ProductAvailability,因此向其中添加新字段将意味着还需要重构这些测试,这可能需要一些工作。这意味着与立即编写实现相比,想要通过ProductAvailability制作上帝对象的人要少得多:相反,向现有对象添加字段比创建另一个类要容易。

测验
 @RunWith(SpringRunner.class) @SpringBootTest public class LazyAvailabilityNotifierTest { @Autowired private LazyAvailabilityNotifier lazyAvailabilityNotifier; @MockBean @Qualifier("dataClient") private AvailabilityNotifier availabilityNotifier; @Autowired private AvailabilityRepository availabilityRepository; @Before public void clearDb() { availabilityRepository.deleteAll(); } @Test public void notifyIfFirstTime() { sendNotificationAndVerifyDataBase(new ProductAvailability(1L, false)); } @Test public void notifyIfAvailabilityChanged() { final ProductAvailability oldProductAvailability = new ProductAvailability(1L, false); sendNotificationAndVerifyDataBase(oldProductAvailability); final ProductAvailability newProductAvailability = new ProductAvailability(1L, true); sendNotificationAndVerifyDataBase(newProductAvailability); } @Test public void doNotNotifyIfAvailabilityDoesNotChanged() { final ProductAvailability productAvailability = new ProductAvailability(1L, false); sendNotificationAndVerifyDataBase(productAvailability); sendNotificationAndVerifyDataBase(productAvailability); sendNotificationAndVerifyDataBase(productAvailability); sendNotificationAndVerifyDataBase(productAvailability); verify(availabilityNotifier, only()).notify(eq(productAvailability)); } @Test public void doNotSaveIfSentWithException() { doThrow(new RuntimeException()).when(availabilityNotifier).notify(anyObject()); boolean exceptionThrown = false; try { availabilityNotifier.notify(new ProductAvailability(1L, false)); } catch (RuntimeException exception) { exceptionThrown = true; } assertTrue("Exception was not thrown", exceptionThrown); assertThat(availabilityRepository.findAll(), hasSize(0)); } @Test(expected = DataCommunicationException.class) public void wrapDataException() { doThrow(new RestClientException("Something wrong")).when(availabilityNotifier).notify(anyObject()); lazyAvailabilityNotifier.notify(new ProductAvailability(1L, false)); } private void sendNotificationAndVerifyDataBase(ProductAvailability productAvailability) { lazyAvailabilityNotifier.notify(productAvailability); verify(availabilityNotifier).notify(eq(productAvailability)); assertThat(availabilityRepository.findAll(), hasSize(1)); assertThat(availabilityRepository.findAll().get(0), hasProperty("productId", is(productAvailability.getProductId()))); assertThat(availabilityRepository.findAll().get(0), hasProperty("availability", is(productAvailability.isAvailable()))); } } 


实作
 @Component @AllArgsConstructor @Slf4j public class LazyAvailabilityNotifier implements AvailabilityNotifier { private final AvailabilityRepository availabilityRepository; private final AvailabilityNotifier availabilityNotifier; @Override public void notify(ProductAvailability productAvailability) { final AvailabilityPersistenceObject persistedProductAvailability = availabilityRepository .findByProductId(productAvailability.getProductId()); if (persistedProductAvailability == null) { notifyWith(productAvailability); availabilityRepository.save(createObjectFromProductAvailability(productAvailability)); } else if (persistedProductAvailability.isAvailability() != productAvailability.isAvailable()) { notifyWith(productAvailability); persistedProductAvailability.setAvailability(productAvailability.isAvailable()); availabilityRepository.save(persistedProductAvailability); } } private void notifyWith(ProductAvailability productAvailability) { try { availabilityNotifier.notify(productAvailability); } catch (RestClientException exception) { log.error("Couldn't notify", exception); throw new DataCommunicationException(); } } private AvailabilityPersistenceObject createObjectFromProductAvailability(ProductAvailability productAvailability) { return new AvailabilityPersistenceObject(productAvailability.getProductId(), productAvailability.isAvailable()); } } 


结论



在实践中必须编写类似的应用程序。结果发现,起初它是在没有TDD的情况下编写的,然后业务部门表示没有必要,并且六个月后需求发生了变化,因此决定从头开始再次重写它(好处是微服务体系结构,扔掉东西并不那么可怕)。 。

通过使用不同的技术编写相同的应用程序,我可以体会到它们之间的差异。在我的实践中,我看到了TDD如何更正确地构建体系结构。

我可以假设这样做的原因不是在实现之前创建测试,而是在开始编写测试之后,我们首先考虑创建的类将要做什么。同样,虽然没有实现,但是我们可以真正地在被调用的对象中“排序”调用它们的对象所需的确切合同,而不必急于在某处快速添加内容并获得可以同时处理许多任务的实体。

另外,作为我自己的TDD的主要优势之一,我可以强调一点,就是我对自己生产的产品变得更加自信。这可能是由于以下事实:测试中可以更好地覆盖用TDD编写的平均代码,但是在我开始用TDD编写之后,我对代码的编辑次数减少了其测试几乎为零。

总的来说,我觉得作为一名开发人员我会变得更好。

应用程序代码可以在这里找到。对于那些想逐步了解它的创建方式的人,我建议关注提交的历史,在分析了这些历史之后,我希望创建一个典型的TDD应用程序的过程会更容易理解。

这是一个非常有用的我强烈建议向想加入TDD世界的人们观看该视频

应用程序代码重用了像json这样的格式化字符串。这对于检查应用程序如何解析POJO对象上的json是必需的。如果您使用IDEA,那么使用JSON语言注入即可快速而轻松地完成必要的格式化。

这种方法有什么缺点?


发展很长一段时间。我的同事使用标准范例进行编程,可以负担起将服务提供给测试人员进行测试的目的,而这些测试根本不需要进行测试,并且一路添加进来。非常快。在TDD上,这将不起作用。如果您的截止日期很紧,那么您的经理将不满意。在这里,要立即做得不错,但时间长了又不是很好,而是很快。我为自己选择第一个,因为第二个会更长。并大胆地。

根据我的感觉,TDD不适合您需要进行大量重构的情况:因为与从头创建的应用程序不同,不清楚哪种方法以及首先要做什么。事实证明您正在进行类测试,因此将其删除。

TDD不是灵丹妙药。这是一个关于清晰可读的代码的故事,该代码可能会导致性能问题。例如,您创建了N个类,就像在Fowler中一样,每个类都做自己的事情。然后事实证明,为了做好工作,他们需要每个人都去基地。您将在数据库中有N个查询。而不是制作例如1个神物并走1次。如果您争夺毫秒数,那么使用TDD时需要考虑到这一点:可读代码并不总是最快的。

最后,切换到这种方法非常困难-您需要教自己换个角度思考。大部分痛苦是在第一阶段。我写了1.5天的第一次集成测试。

好吧,最后。如果您使用TDD而您的代码仍然不是很,那么问题可能不在方法论中。但这对我有所帮助。

Source: https://habr.com/ru/post/zh-CN456662/


All Articles