¿Alguna vez has tenido esta condición?
Quiero mostrarle cómo TDD puede mejorar la calidad del código utilizando un ejemplo específico.
Porque todo lo que conocí mientras estudiaba el tema fue bastante teórico.
Dio la casualidad de que escribí dos aplicaciones casi idénticas: una escrita en el estilo clásico, ya que no conocía TDD en ese momento, y la segunda, solo usando TDD.
A continuación, mostraré dónde estaban las mayores diferencias.
Personalmente, esto era importante para mí, porque cada vez que alguien encontraba un error en mi código, me ponía un fuerte inconveniente por la autoestima. Sí, entendí que los errores son normales, todos los escriben, pero el sentimiento de inferioridad no desapareció. Además, en el proceso de evolución del servicio, a veces me di cuenta de que yo mismo escribí uno que me picaba las manos para tirar todo y volver a escribirlo. Y cómo sucedió es incomprensible. De alguna manera, todo estaba bien al principio, pero después de un par de características y después de un tiempo no se puede mirar la arquitectura sin lágrimas. Aunque parece que cada paso del cambio fue lógico. La sensación de que no me gustaba el producto de mi propio trabajo fluyó suavemente en la sensación de que el programador era mío, lo siento, como una bala de mierda.
Resultó que no soy el único y muchos de mis colegas tienen sensaciones similares. Y luego decidí que aprendería a escribir normalmente o que era hora de cambiar de profesión. Intenté el desarrollo basado en pruebas en un intento de cambiar algo en mi enfoque de programación.
Mirando hacia el futuro, según los resultados de varios proyectos, puedo decir que TDD proporciona una arquitectura más limpia, pero ralentiza el desarrollo. Y no siempre es adecuado y no para todos.
¿Qué es TDD nuevamente?
TDD: desarrollo a través de pruebas. Artículo de Wiki
aquí .
El enfoque clásico es primero escribir una aplicación, luego cubrirla con pruebas.
Enfoque TDD: primero escribimos pruebas para la clase, luego la implementación. Nos movemos a través de los niveles de abstracción, desde el más alto hasta el aplicado, al mismo tiempo dividiendo la aplicación en capas-clases a partir de las cuales
ordenamos el comportamiento que necesitamos, al estar libres de una implementación específica.
Y si tuviera que leer esto por primera vez, tampoco entendería nada.
Demasiadas palabras abstractas: veamos un ejemplo.
Escribiremos una aplicación de primavera real en Java, la escribiremos en TDD, e intentaré mostrar mi proceso de pensamiento durante el proceso de desarrollo y al final sacar conclusiones si tiene sentido pasar tiempo en TDD o no.
Tarea práctica
Supongamos que somos tan afortunados de tener los términos de referencia de lo que necesitamos desarrollar. Por lo general, los analistas no se molestan con eso, y se ve más o menos así:
Es necesario desarrollar un microservicio que calcule la posibilidad de vender productos con entrega posterior al cliente en el hogar. La información sobre esta función debe enviarse a un sistema de DATOS de terceros.La lógica de negocios es la siguiente: un artículo está disponible para la venta con entrega si:
- El producto esta en stock
- El contratista (por ejemplo, la empresa DostavchenKO) tiene la oportunidad de llevarlo al cliente.
- Color del producto: no azul (no nos gusta el azul)
Nuestro microservicio será notificado de un cambio en la cantidad de productos en el estante de la tienda a través de una solicitud http.
Esta notificación es un disparador para calcular la disponibilidad.
Además, para que la vida no parezca miel:
- El usuario debería poder desactivar manualmente ciertos productos.
- Para no enviar spam a los DATOS, solo necesita enviar datos de disponibilidad para aquellos productos que han cambiado.
Leímos un par de veces TK, y nos vamos.
Prueba de integración
En TDD, una de las preguntas más importantes que tiene que hacer a todo lo que escribe es: "¿Qué quiero de ...?"
Y la primera pregunta que hacemos es solo para toda la aplicación.
Entonces la pregunta es:
¿Qué quiero de mi microservicio?La respuesta es:
En realidad muchas cosas. Incluso una lógica tan simple ofrece muchas opciones, un intento de escribir que, y aún más para crear pruebas para todos ellos, puede ser una tarea imposible. Por lo tanto, para responder la pregunta a nivel de aplicación, elegiremos solo los casos de prueba principales.
Es decir, suponemos que todos los datos de entrada tienen un formato válido, los sistemas de terceros responden normalmente y anteriormente no había información sobre el producto.
Entonces quiero:- Ha llegado un evento de que no hay productos en el estante. Notifique que la entrega no está disponible.
- Llegó el caso de que el producto amarillo está en stock, DostavchenKO está listo para tomarlo. Notificar sobre la disponibilidad de bienes.
- Dos mensajes llegaron seguidos, ambos con una cantidad positiva de productos en la tienda. Enviado solo un mensaje.
- Llegaron dos mensajes: en el primero hay un producto en la tienda, en el segundo, ya no está allí. Enviamos dos mensajes: primero - disponible, luego - no.
- Puedo desactivar el producto manualmente y ya no se envían notificaciones.
- ...
Lo principal aquí es detenerse a tiempo: como ya escribí, hay demasiadas opciones, y no tiene sentido describirlas todas aquí, solo las
más básicas. En el futuro, cuando escribamos pruebas para la lógica de negocios, es probable que su combinación cubra todo lo que se nos ocurre aquí. La motivación principal aquí es asegurarse de que si pasan estas pruebas, la aplicación funciona como lo necesitamos.
Toda esta lista de deseos ahora la destilaremos en pruebas. Además, dado que esta es la Lista de Deseos a nivel de aplicación, tendremos pruebas para elevar el contexto de primavera, es decir, bastante pesado.
Y esto, desafortunadamente, para muchos fines de TDD, porque para escribir una prueba de integración de este tipo, se necesita un gran esfuerzo que la gente no siempre está dispuesta a gastar. Y sí, este es el paso más difícil, pero, créame, después de que lo revise, el código casi se escribirá solo, y estará seguro de que su aplicación funcionará de la manera que desee.
En el proceso de responder la pregunta, ya puede comenzar a escribir código en la clase spring initializr generada. Los nombres de las pruebas son solo nuestra lista de deseos. Por ahora, solo cree métodos vacíos:
@Test public void notifyNotAvailableIfProductQuantityIsZero() {} @Test public void notifyAvailableYellowProductIfPositiveQuantityAndDostavchenkoApproved() {} @Test public void notifyOnceOnSeveralEqualProductMessages() {} @Test public void notifyFirstAvailableThenNotIfProductQuantityMovedFromPositiveToZero() {} @Test public void noNotificationOnDisabledProduct() {}
En cuanto a la denominación de los métodos: le recomiendo encarecidamente que los haga informativos, en lugar de test1 (), test2 (), porque más tarde, cuando olvide qué clase escribió y de qué es responsable, tendrá la oportunidad en lugar de intente analizar directamente el código, simplemente abra la prueba y lea el método de contrato que satisface la clase.
Comience a completar las pruebas
La idea principal es emular todo lo externo para verificar lo que sucede dentro.
"Externo" en relación con nuestro servicio es todo lo que NO es el microservicio en sí, sino que se comunica directamente con él.
En este caso, lo externo es:
- El sistema que nuestro servicio notificará sobre cambios en la cantidad de bienes.
- Cliente que desconectará bienes manualmente
- Sistema de dostavchenKO de terceros
Para emular las solicitudes de los dos primeros, utilizamos MockMvc elástico.
Para emular DostavchenKO utilizamos wiremock o MockRestServiceServer.
Como resultado, nuestra prueba de integración se ve así:
Prueba de integración @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(
¿Qué acaba de pasar?
Escribimos una prueba de integración, cuyo paso nos garantiza la operabilidad del sistema de acuerdo con las principales historias de usuarios. Y lo hicimos ANTES de comenzar a implementar el servicio.
Una de las ventajas de este enfoque es que durante el proceso de escritura tuve que ir al DostavchenKO
real y obtener una respuesta
real desde allí a la solicitud
real que hicimos en nuestro trozo. Es muy bueno que nos hayamos ocupado de esto al comienzo del desarrollo, y no después de todo el código está escrito. Y aquí resulta que el formato no es el especificado en los TOR, o el servicio generalmente no está disponible, o algo más.
También me gustaría señalar que no solo no hemos escrito
una sola línea de código que luego irá al producto, sino que ni siquiera hemos hecho
una suposición sobre cómo se organizará nuestro microservicio en el interior: qué capas habrá, si utilizamos la base, si es así, cuál, etc. En el momento de escribir la prueba, estamos abstraídos de la implementación y, como veremos más adelante, esto puede dar una serie de ventajas arquitectónicas.
A diferencia del TDD canónico, donde la implementación se escribe inmediatamente después de la prueba, la prueba de integración no tomará mucho tiempo. De hecho, no se volverá verde hasta el final del desarrollo, hasta que se haya escrito absolutamente todo, incluidos los archivos.
Vamos más lejos
Controlador
Después de que escribimos la prueba de integración y ahora estamos seguros de que después de pasar la tarea, podemos dormir tranquilos por la noche, es hora de comenzar a programar las capas. Y la primera capa que implementaremos es el controlador. ¿Por qué exactamente él? Porque este es el punto de entrada al programa. Necesitamos pasar de arriba a abajo, desde la primera capa con la que interactuará el usuario, hasta la última.
Esto es importante
Y nuevamente, todo comienza con la misma pregunta:
¿Qué quiero del controlador?La respuesta es:
Sabemos que el controlador se dedica a la comunicación con el usuario, la validación y la conversión de los datos de entrada y no contiene lógica empresarial. Entonces la respuesta a esta pregunta podría ser algo como esto:
Quiero:- BAD_REQUEST devuelto al usuario cuando intenta desconectar un producto con una identificación no válida
- BAD_REQUEST al intentar notificar sobre un cambio de mercancías con una identificación no válida
- BAD_REQUEST al intentar notificar una cantidad negativa
- INTERNAL_SERVER_ERROR si DostavchenKO no está disponible
- INTERNAL_SERVER_ERROR, si no puede enviar a DATA
Dado que queremos ser fáciles de usar, para todos los elementos anteriores, además del código http, debe mostrar un mensaje personalizado que describa el problema para que el usuario comprenda cuál es el problema.
- 200 si el procesamiento fue exitoso
- INTERNAL_SERVER_ERROR con un mensaje predeterminado en todos los demás casos, para no brillar stackrace
Hasta que comencé a escribir en TDD, lo último en lo que estaba pensando era en lo que mi sistema aportaría al usuario en algún caso especial y, a primera vista, poco probable. No pensé por una simple razón: es muy difícil escribir una implementación, para tener en cuenta absolutamente todos los casos extremos, a veces no hay suficiente RAM en el cerebro. Y después de la implementación por escrito, analizar el código en busca de algo que quizás no haya considerado de antemano sigue siendo un placer: todos pensamos que estamos escribiendo el código perfecto de inmediato). Si bien no hay implementación, no hay necesidad de pensarlo, y no hay dolor para cambiarlo, si eso es así. Una vez escrita la prueba primero, no tiene que esperar hasta que las estrellas converjan, y después de retirarse al producto, un cierto número de sistemas fallará, y el cliente acudirá a usted con una solicitud para arreglar algo. Y esto se aplica no solo al controlador.
Comienza a escribir exámenes
Todo está claro con los primeros tres: utilizamos la validación de primavera, si llega una solicitud no válida, la aplicación arrojará una excepción, que atraparemos en un controlador de excepciones. Aquí, como dicen, todo funciona por sí solo, pero ¿cómo sabe el controlador que algún sistema de terceros no está disponible?
Está claro que el controlador en sí no debería saber nada acerca de los sistemas de terceros, porque qué sistema preguntar y cuál es la lógica de negocios, es decir, debe haber algún tipo de intermediario. Este intermediario es el servicio. Y escribiremos pruebas en el controlador utilizando la simulación de este servicio, emulando su comportamiento en ciertos casos. Por lo tanto, el servicio debe informar de alguna manera al controlador que el sistema no está disponible. Puede hacerlo de diferentes maneras, pero es la forma más fácil de lanzar una ejecución personalizada. Escribiremos una prueba para este comportamiento del controlador.
Prueba de error de comunicación con un sistema de datos de terceros @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(
En esta etapa, varias cosas aparecieron por sí mismas:
- Un servicio que se inyectará en el controlador y al que se le delegará el procesamiento de un mensaje entrante para una nueva cantidad de bienes.
- El método de este servicio y, en consecuencia, su firma, que llevará a cabo este procesamiento.
- La constatación de que el método debería arrojar una ejecución personalizada cuando el sistema no está disponible.
- Esta ejecución personalizada en sí.
¿Por qué por sí mismos? Porque, como recordarán, todavía no hemos escrito una implementación. Y todas estas entidades aparecieron en el proceso de cómo programamos las pruebas. Para que el compilador no jure, en código real, tendremos que crear todo lo descrito anteriormente. Afortunadamente, casi cualquier IDE nos ayudará a generar las entidades necesarias. Por lo tanto, escribimos una prueba, y la aplicación está llena de clases y métodos.
En total, las pruebas para el controlador son las siguientes:
Pruebas @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(
Ahora podemos escribir la implementación y asegurarnos de que todas las pruebas pasen con éxito:
Implementació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)); } }
Controlador de excepciones @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()))); } }
¿Qué acaba de pasar?
En TDD, no tiene que tener todo el código en su cabeza.De nuevo: no mantengamos toda la arquitectura en RAM. Solo mira una capa. El es simple.
En el proceso habitual, el cerebro no es suficiente, porque hay muchas implementaciones. Si eres un superhéroe que puede tener en cuenta todos los matices de un gran proyecto en tu cabeza, entonces TDD no es necesario. No se como. Cuanto más grande es el proyecto, más me equivoco.
Después de darse cuenta de que solo necesita comprender lo que necesita la siguiente capa, la iluminación cobra vida. El hecho es que este enfoque le permite no hacer cosas innecesarias. Aquí estás hablando con una chica. Ella dice algo sobre un problema en el trabajo. Y si piensas cómo resolverlo, te encoges el cerebro. Y ella no necesita resolverlo, solo necesita decirlo. Y eso es todo. Ella solo quería compartir algo. Aprender sobre esto en la primera etapa de listen () no tiene precio. Para todo lo demás ... bueno, ya sabes.
Servicio
A continuación implementamos el servicio.
¿Qué queremos del servicio?Queremos que se ocupe de la lógica empresarial, es decir:
- Sabía cómo desconectar bienes, y también notificó sobre :
- La disponibilidad, si el producto no está desconectado, está en stock, el color del producto es amarillo y DostavchenKO está listo para realizar la entrega.
- Inaccesibilidad, si los bienes no están disponibles independientemente de nada.
- Inaccesibilidad, si el producto es azul.
- Inaccesibilidad si DostavchenKO se niega a llevarlo.
- Inaccesibilidad si las mercancías se desconectan manualmente.
- A continuación, queremos que el servicio ejecute la ejecución si alguno de los sistemas no está disponible.
- Y también, para no enviar spam a los DATOS, debe organizar el envío diferido de mensajes, a saber:
- Si solíamos enviar bienes disponibles para bienes y ahora hemos calculado lo que está disponible, entonces no enviamos nada.
- Y si antes no estaba disponible, pero ahora está disponible, lo enviamos.
- Y necesitas escribirlo en alguna parte ...
¡ALTO!¿No crees que nuestro servicio está empezando a hacer demasiado?
A juzgar por nuestra lista de deseos, sabe cómo desactivar los productos, considera la accesibilidad y se asegura de no enviar mensajes enviados anteriormente. Esto no es alta cohesión. Es necesario mover funcionalidades heterogéneas a diferentes clases y, por lo tanto, ya debería haber tres servicios: uno se ocupará de la desconexión de los bienes, el otro calculará la posibilidad de entrega y lo pasará a un servicio que decidirá si enviarlo o no. Por cierto, de esta manera, el servicio de lógica de negocios no sabrá nada sobre el sistema DATA, que también es una ventaja definitiva.
En mi experiencia, con bastante frecuencia, habiendo ido de frente a la implementación, es fácil pasar por alto los momentos arquitectónicos. Si escribiéramos el servicio de inmediato, sin pensar en lo que debería hacer y, lo que es más importante, de lo que NO debería, aumentaría la probabilidad de que se superpongan áreas de responsabilidad. Me gustaría agregar en mi propio nombre que fue este ejemplo el que me sucedió en la práctica real y la diferencia cualitativa entre los resultados de TDD y los enfoques de programación secuencial que me inspiraron a escribir esta publicación.
Lógica de negocios
Pensando en el servicio de lógica de negocios por las mismas razones que la alta cohesión, entendemos que necesitamos un nivel más de abstracción entre él y el verdadero DostavchenKO. Y, dado que
primero diseñamos el servicio, podemos exigirle al cliente de DostavchenKO el contrato interno que queremos. En el proceso de redacción de una prueba de lógica empresarial, entenderemos lo que queremos del cliente con la siguiente firma:
public boolean isAvailableForTransportation(Long productId) {...}
A nivel de servicio, no nos importa cómo responda el verdadero DostavchenKO: en el futuro, la tarea del cliente de alguna manera le sacará esta información. Una vez puede ser simple, pero en algún momento será necesario hacer varias solicitudes: en este momento estamos abstraídos de esto.
Queremos una firma similar de un servicio que se ocupe de productos desconectados:
public boolean isProductEnabled(Long productId) {...}
Entonces, las preguntas "¿Qué quiero del servicio de lógica de negocios?" Registrado en las pruebas son las siguientes:
Pruebas de servicio @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); } }
En esta etapa, nacieron solos:- Cliente DostavchenKO con singatura amigable con el servicio
- Un servicio en el que será necesario implementar la lógica del envío diferido, a quien el servicio diseñado transmitirá los resultados de su trabajo.
- Servicio de bienes desconectados y su firma.
ImplementaciónImplementación @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); } }
Productos deshabilitantes
Ha llegado el momento de una de las fases inevitables de TDD: la refactorización. Si recuerda, después de la implementación del controlador, el contrato de servicio se veía así: public void disableProduct(long productId)
Y ahora decidimos mover la lógica de desconexión a un servicio separado.De este servicio en esta etapa queremos lo siguiente:- La capacidad de apagar bienes.
- Queremos que regrese que los bienes se desconectan si se desconectó antes.
- Queremos que regrese que los productos están disponibles si no hubo desconexión antes.
En cuanto a la lista de deseos, que son una consecuencia directa del contrato entre el servicio de lógica de negocios y el proyectado, me gustaría señalar lo siguiente:- En primer lugar, puede ver de inmediato que la aplicación puede tener problemas si alguien quiere apagar el producto desconectado, porque en este momento este servicio simplemente no sabe cómo hacerlo. Y esto significa que quizás valga la pena discutir este tema con el analista que estableció la tarea para el desarrollo. Entiendo que en este caso esta pregunta debería haber surgido justo después de la primera lectura de los Términos de Referencia, pero estamos diseñando un sistema bastante simple, en proyectos más grandes esto podría no ser tan obvio. Además, no sabíamos que tendríamos una entidad que se encargara solo de la funcionalidad de desconectar los productos: recuerdo que nacimos solo en el proceso de desarrollo.
- -, . — , . , , , , , , . . ProductAvailability. , . . ., , god object, , , , TDD, , . , , «» — : « ...» , , TDD, .
Las pruebas y la implementación son muy simples:Pruebas @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)); } }
Implementación @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)); } }
Servicio de presentación diferida
Entonces, llegamos al último servicio, lo que garantizará que el sistema de DATOS no sea spam con los mismos mensajes.Permítame recordarle que el resultado del trabajo del servicio de lógica de negocios, es decir, el objeto ProductAvailability, en el que solo hay dos campos: productId y isAvailable, ya se ha transferido a él.De acuerdo con la buena tradición, comenzamos a pensar en lo que queremos de este servicio:- Enviar una notificación por primera vez de todos modos.
- Enviar una notificación si la disponibilidad del producto ha cambiado.
- No enviamos nada si no.
- Si el envío a un sistema de terceros terminó con una excepción, la notificación que causó la excepción no debe incluirse en la base de datos de notificaciones enviadas.
- Además, cuando se ejecuta desde el lado DATOS, el servicio debe lanzar su DataCommunicationException.
Todo aquí es relativamente simple, pero me gustaría señalar un punto:necesitamos información sobre lo que enviamos anteriormente, lo que significa que tendremos un repositorio en el que guardaremos los cálculos anteriores sobre la disponibilidad de bienes.El objeto ProductAvailability no es adecuado para guardar, porque al menos no hay un identificador, lo que significa que es lógico crear otro. Lo principal aquí es no asustarse y no agregar este identificador, junto con @Document (usaremos MongoDb como base) e índices en ProductAvailability en sí.Debe comprender que el objeto ProductAvailability con todos los pocos campos se creó en la etapa de diseño de clases que son más altas en la jerarquía de llamadas que la que estamos diseñando ahora. Estas clases no necesitan saber nada sobre los campos específicos de la base de datos, ya que esta información no era necesaria al diseñar.Pero todo esto es hablar.Curiosamente, debido al hecho de que ya hemos escrito un montón de pruebas con la Disponibilidad del producto que estamos transfiriendo al servicio ahora, agregar nuevos campos significará que estas pruebas también deberán ser refactorizadas, lo que puede requerir un poco de esfuerzo. Esto significa que habrá muchas menos personas que quieran hacer un objeto divino de ProductAvailability que si escribieran la implementación de inmediato: allí, por el contrario, agregar un campo a un objeto existente sería más fácil que crear otra clase.Pruebas @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()))); } }
Implementación @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()); } }
Conclusión
Una aplicación similar tuvo que ser escrita en la práctica. Y resultó que al principio se escribió sin TDD, luego el negocio dijo que no era necesario, y después de seis meses los requisitos cambiaron, y se decidió volver a escribirlo desde cero (el beneficio es la arquitectura de microservicios, y no fue tan aterrador desechar algo) .Al escribir la misma aplicación usando diferentes técnicas, puedo apreciar sus diferencias. En mi práctica, vi cómo TDD ayuda a construir la arquitectura, me parece, más correctamente.Puedo suponer que la razón de esto no es la creación de pruebas antes de la implementación, sino que, después de haber escrito las pruebas al principio, primero pensamos en lo que hará la clase creada. Además, aunque no hay implementación, realmente podemos "ordenar" en los objetos llamados el contrato exacto que necesita el objeto que los llama, sin la tentación de agregar rápidamente algo en algún lugar y obtener una entidad que se encargará de muchas tareas al mismo tiempo.Además, una de las principales ventajas de TDD para mí, puedo resaltar que realmente me sentí más seguro en el producto que produzco. Esto puede deberse al hecho de que el código promedio escrito en TDD probablemente esté mejor cubierto por las pruebas, pero fue después de que comencé a escribir en TDD que mi número de ediciones al código se redujo después de que di su prueba casi a cero.Y en general, había una sensación de que, como desarrollador, mejoré.El código de la aplicación se puede encontrar aquí . Para aquellos que quieran comprender cómo se creó en pasos, les recomiendo prestar atención al historial de confirmaciones, luego de analizar cuál, espero, el proceso de creación de una aplicación TDD típica será más comprensible.Aquí hay un muy útilun video que recomiendo ver a cualquiera que quiera sumergirse en el mundo de TDD.El código de la aplicación reutiliza una cadena formateada como json. Esto es necesario para verificar cómo la aplicación analizará json en objetos POJO. Si usa IDEA, entonces, de forma rápida y sin problemas, se puede lograr el formato necesario utilizando inyecciones de lenguaje JSON.¿Cuáles son las desventajas del enfoque?
Es mucho tiempo para desarrollarse. Programando en el paradigma estándar, mi colega podía permitirse el lujo de poner el servicio a los probadores para que lo probaran sin pruebas en absoluto, agregándolos en el camino. Fue muy rapido. En TDD esto no funcionará. Si tiene plazos ajustados, sus gerentes no estarán contentos. Aquí el intercambio entre hacerlo bien de inmediato, pero durante mucho tiempo y no muy bueno, pero rápido. Elijo el primero para mí, porque el segundo como resultado es más largo. Y con grandes nervios.Según mis sentimientos, TDD no es adecuado si necesita hacer muchas refactorizaciones: porque a diferencia de una aplicación creada desde cero, no es obvio qué forma de abordar y qué comenzar a hacer primero. Puede resultar que esté trabajando en una prueba de clase, que, como resultado, la elimina.TDD no es una bala de plata. Esta es una historia sobre código claro y legible que puede crear problemas de rendimiento. Por ejemplo, creó N clases, que, como en Fowler, cada una hace lo suyo. Y luego resulta que para hacer su trabajo, necesitan que todos vayan a la base. Y tendrá N consultas en la base de datos. En lugar de hacer, por ejemplo, 1 objeto de dios y pasar 1 vez. Si lucha por milisegundos, entonces, usando TDD, debe tener esto en cuenta: el código legible no siempre es el más rápido.Y, por último, es bastante difícil cambiar a esta metodología: debe aprender a pensar de manera diferente. La mayor parte del dolor está en la primera etapa. La primera prueba de integración que escribí 1.5 días.Bueno, el ultimo. Si usa TDD y su código aún no es muy, entonces el asunto puede no estar en la metodología. Pero me ayudó.