Comment économiser de l'argent sur un thérapeute en utilisant le développement piloté par les tests

Avez-vous déjà eu cette condition?

image

Je veux vous montrer comment TDD peut améliorer la qualité du code en utilisant un exemple spécifique.
Parce que tout ce que j'ai rencontré en étudiant la question était assez théorique.
Il se trouve que j'ai écrit deux applications presque identiques: l'une a été écrite dans le style classique, car je ne connaissais pas TDD à l'époque, et la seconde - en utilisant simplement TDD.

Ci-dessous, je montrerai où étaient les plus grandes différences.

Personnellement, c'était important pour moi, car chaque fois que quelqu'un trouvait un bogue dans mon code, j'attrapais un lourd inconvénient pour l'estime de soi. Oui, j'ai compris que les bugs sont normaux, tout le monde les écrit, mais le sentiment d'infériorité n'a pas disparu. Aussi, dans le processus d'évolution du service, j'ai parfois réalisé que j'en avais moi-même écrit un tel que ça me démange de tout jeter et de le réécrire. Et comment cela s'est produit est incompréhensible. D'une manière ou d'une autre, tout allait bien au début, mais après quelques fonctionnalités et après un certain temps, vous ne pouvez pas regarder l'architecture sans larmes. Bien qu'il semble que chaque étape du changement soit logique. Le sentiment que je n'aimais pas le produit de mon propre travail coulait en douceur dans le sentiment que le programmeur était de moi, excusez-moi, comme une balle de merde.

Il s'est avéré que je ne suis pas le seul et beaucoup de mes collègues ont des sensations similaires. Et puis j'ai décidé que soit j'apprendrais à écrire normalement, soit il était temps de changer de métier. J'ai essayé le développement piloté par les tests dans le but de changer quelque chose dans mon approche de programmation.

Pour l'avenir, sur la base des résultats de plusieurs projets, je peux dire que TDD fournit une architecture plus propre, mais il ralentit le développement. Et ce n'est pas toujours adapté et pas pour tout le monde.

Qu'est-ce que TDD à nouveau


image


TDD - développement par test. Article Wiki ici .
L'approche classique consiste à écrire d'abord une application, puis à la couvrir de tests.

Approche TDD - nous écrivons d'abord des tests pour la classe, puis l'implémentation. Nous passons par les niveaux d'abstraction - du plus élevé à l'appliqué, en même temps que nous divisons l'application en couches-classes à partir desquelles nous ordonnons le comportement dont nous avons besoin, sans aucune implémentation spécifique.

Et si je devais lire ceci pour la première fois, je ne comprendrais rien non plus.
Trop de mots abstraits: regardons un exemple.
Nous écrirons une vraie application de printemps en Java, nous l'écrirons en TDD, et j'essaierai de montrer mon processus de réflexion pendant le processus de développement et finalement de tirer des conclusions sur le fait de savoir s'il est judicieux de passer du temps sur TDD ou non.

Tâche pratique


Supposons que nous soyons si chanceux que nous ayons les TdR de ce que nous devons développer. En règle générale, les analystes ne s'en soucient pas, et cela ressemble à ceci:

Il est nécessaire de développer un microservice qui calculera la possibilité de vendre des marchandises avec une livraison ultérieure au client à domicile. Les informations sur cette fonction doivent être envoyées à un système de données tiers.

La logique métier est la suivante: un article est disponible à la vente avec livraison si:

  • Le produit est en stock
  • L'entrepreneur (par exemple, la société DostavchenKO) a la possibilité de le porter au client
  • Couleur du produit - pas bleu (nous n'aimons pas le bleu)

Notre microservice sera informé d'un changement de la quantité de marchandises sur le plateau du magasin via une demande http.

Cette notification est un déclencheur pour le calcul de la disponibilité.

De plus, pour que la vie ne semble pas être du miel:

  • L'utilisateur doit pouvoir désactiver manuellement certains produits.
  • Afin de ne pas spammer de DONNÉES, il vous suffit d'envoyer des données de disponibilité pour les produits qui ont changé.

Nous avons lu quelques fois TK - et c'est parti.



Test d'intégration


Dans TDD, l'une des questions les plus importantes que vous devez poser à tout ce que vous écrivez est: "Qu'est-ce que je veux de ...?"

Et la première question que nous posons concerne uniquement la demande dans son ensemble.
La question est donc:

Que veux-je de mon microservice?

La réponse est:

En fait, beaucoup de choses. Même une telle logique simple donne beaucoup d'options, une tentative d'écriture qui, et plus encore de créer des tests pour chacun d'entre eux, peut être une tâche impossible. Par conséquent, pour répondre à la question au niveau de l'application, nous ne choisirons que les principaux cas de test.

Autrement dit, nous supposons que toutes les données d'entrée sont au format valide, les systèmes tiers répondent normalement et auparavant, il n'y avait aucune information sur le produit.

Je veux donc:

  • Un événement est arrivé où il n'y a aucun produit sur l'étagère. Avertissez que la livraison n'est pas disponible.
  • L'événement est arrivé que le produit jaune est en stock, DostavchenKO est prêt à le prendre. Avertissez de la disponibilité des marchandises.
  • Deux messages sont venus d'affilée - tous deux avec une quantité positive de marchandises dans le magasin. Envoyé un seul message.
  • Deux messages sont arrivés: dans le premier, il y a un produit dans le magasin, dans le second - il ne l'est plus. Nous envoyons deux messages: d'abord - disponible, puis - non.
  • Je peux désactiver le produit manuellement et les notifications ne sont plus envoyées.
  • ...

L'essentiel ici est de s'arrêter à temps: comme je l'ai déjà écrit, il y a trop d'options, et cela n'a aucun sens de les décrire toutes ici - seulement les plus élémentaires. À l'avenir, lorsque nous rédigerons des tests de logique métier, leur combinaison couvrira probablement tout ce que nous proposons ici. La principale motivation ici est de s'assurer que si ces tests réussissent, l'application fonctionne comme nous en avons besoin.

Toutes ces listes de souhaits, nous allons maintenant les distiller en tests. De plus, comme il s'agit de Wishlist au niveau de l'application, nous aurons des tests pour élever le contexte du printemps, c'est-à-dire assez lourd.
Et cela, malheureusement, pour de nombreuses fins TDD, car pour écrire un tel test d'intégration, vous avez besoin de beaucoup d'efforts que les gens ne sont pas toujours prêts à dépenser. Et oui, c'est l'étape la plus difficile, mais, croyez-moi, après l'avoir traversée, le code s'écrira presque tout seul et vous serez sûr que votre application fonctionnera exactement comme vous le souhaitez.


Dans le processus de réponse à la question, vous pouvez déjà commencer à écrire du code dans la classe initializr de spring générée. Les noms des tests ne sont que notre liste de souhaits. Pour l'instant, créez simplement des méthodes vides:

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

Concernant la dénomination des méthodes: je vous conseille fortement de les rendre informatives, pas test1 (), test2 (), car plus tard, lorsque vous oublierez la classe que vous avez écrite et ce dont elle est responsable, vous aurez l'opportunité au lieu de essayez d'analyser directement le code, ouvrez simplement le test et lisez la méthode de contrat que la classe satisfait.

Commencez à remplir les tests


L'idée principale est d'émuler tout ce qui est externe afin de vérifier ce qui se passe à l'intérieur.

«Externe» par rapport à notre service est tout ce qui n'est PAS le microservice lui-même, mais qui communique directement avec lui.

Dans ce cas, l'externe est:

  • Le système que notre service informera des changements dans la quantité de marchandises
  • Client qui déconnectera les marchandises manuellement
  • Système DostavchenKO tiers

Pour émuler les demandes des deux premiers, nous utilisons springing MockMvc.
Pour émuler DostavchenKO, nous utilisons wiremock ou MockRestServiceServer.

En conséquence, notre test d'intégration ressemble à ceci:

Test d'intégration
 @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"))); } } 

Qu'est-ce qui vient de se passer?


Nous avons rédigé un test d'intégration, dont le passage nous garantit le fonctionnement du système selon les principales histoires d'utilisateurs. Et nous l'avons fait AVANT de commencer à mettre en œuvre le service.

L'un des avantages de cette approche est que pendant le processus d'écriture, je devais aller au vrai DostavchenKO et obtenir une vraie réponse de là à la vraie demande que nous avions faite dans notre talon. C'est très bien que nous ayons pris soin de cela au tout début du développement, et pas après tout le code est écrit. Et ici, il s'avère que le format n'est pas celui spécifié dans le mandat, ou que le service est généralement indisponible, ou autre chose.

Je voudrais également noter que non seulement nous n'avons pas écrit une seule ligne de code qui ira plus tard au prod, mais nous n'avons même pas fait une seule hypothèse sur la façon dont notre microservice sera organisé à l'intérieur: quelles couches il y aura, si nous utilisons la base, le cas échéant, laquelle, etc. Au moment de la rédaction du test, nous sommes abstraits de la mise en œuvre et, comme nous le verrons plus loin, cela peut donner un certain nombre d'avantages architecturaux.

Contrairement au TDD canonique, où l'implémentation est écrite immédiatement après le test, le test d'intégration ne prendra pas très longtemps. En fait, il ne deviendra vert qu'à la toute fin du développement, jusqu'à ce que tout soit écrit, y compris les fichiers.
Nous allons plus loin.

Contrôleur


Après avoir écrit le test d'intégration et sommes maintenant convaincus qu'après avoir réussi la tâche, nous pouvons dormir paisiblement la nuit, il est temps de commencer à programmer les couches. Et la première couche que nous allons implémenter est le contrôleur. Pourquoi exactement lui? Parce que c'est le point d'entrée du programme. Nous devons passer de haut en bas, de la toute première couche avec laquelle l'utilisateur va interagir, à la dernière.
C'est important.

Et encore une fois, tout commence par la même question:

Qu'est-ce que je veux du contrôleur?

La réponse est:

Nous savons que le contrôleur est engagé dans la communication avec l'utilisateur, la validation et la conversion des données d'entrée et ne contient pas de logique métier. La réponse à cette question pourrait donc être quelque chose comme ceci:

Je veux:

  • BAD_REQUEST retourné à l'utilisateur lors de la tentative de déconnexion d'un produit avec un identifiant non valide
  • BAD_REQUEST lors de la tentative de notification d'un changement de marchandise avec un identifiant non valide
  • BAD_REQUEST lors de la tentative de notification d'une quantité négative
  • INTERNAL_SERVER_ERROR si DostavchenKO n'est pas disponible
  • INTERNAL_SERVER_ERROR, si impossible d'envoyer à DATA

Étant donné que nous voulons être conviviaux, pour tous les éléments ci-dessus, en plus du code http, vous devez afficher un message personnalisé décrivant le problème afin que l'utilisateur comprenne quel est le problème.

  • 200 si le traitement a réussi
  • INTERNAL_SERVER_ERROR avec un message par défaut dans tous les autres cas, afin de ne pas briller stackrace

Jusqu'à ce que je commence à écrire sur TDD, la dernière chose à laquelle je pensais était ce que mon système apporterait à l'utilisateur dans un cas spécial et, à première vue, peu probable. Je ne pensais pas pour une raison simple - écrire une implémentation est si difficile, afin de prendre en compte absolument tous les cas marginaux, parfois il n'y a pas assez de RAM dans le cerveau. Et après l'implémentation écrite, analyser le code pour quelque chose que vous n'auriez peut-être pas envisagé à l'avance est toujours un plaisir: nous pensons tous que nous écrivons le code parfait tout de suite). Bien qu'il n'y ait pas de mise en œuvre, il n'est pas nécessaire d'y penser, et il n'y a aucune douleur à le changer, si cela. Après avoir écrit le test en premier, vous n'avez pas à attendre que les étoiles convergent, et après le retrait du prod, un certain nombre de systèmes échouera et le client viendra vous voir avec une demande de réparation. Et cela ne s'applique pas seulement au contrôleur.

Commencez à écrire des tests


Tout est clair avec les trois premiers: nous utilisons la validation de printemps, si une demande non valide arrive, l'application lèvera une exception, que nous intercepterons dans un gestionnaire d'exceptions. Ici, comme on dit, tout fonctionne par lui-même, mais comment le contrôleur sait-il qu'un système tiers n'est pas disponible?

Il est clair que le contrôleur lui-même ne doit rien savoir des systèmes tiers, car quel système demander et quelle est la logique métier, c'est-à-dire qu'il doit y avoir une sorte d'intermédiaire. Cet intermédiaire est le service. Et nous écrirons des tests sur le contrôleur en utilisant la maquette de ce service, en émulant son comportement dans certains cas. Ainsi, le service doit d'une manière ou d'une autre informer le contrôleur que le système n'est pas disponible. Vous pouvez le faire de différentes manières, mais le moyen le plus simple de lancer une exécution personnalisée. Nous allons écrire un test pour ce comportement de contrôleur.

Test d'erreur de communication avec un système de données tiers
 @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" + "}") ); } } 


À ce stade, plusieurs choses sont apparues par elles-mêmes:

  • Un service qui sera injecté dans le contrôleur et auquel sera délégué le traitement d'un message entrant pour une nouvelle quantité de marchandises.
  • La méthode de ce service, et en conséquence sa signature, qui effectuera ce traitement.
  • La réalisation que la méthode doit lancer une exécution personnalisée lorsque le système n'est pas disponible.
  • Cette exécution personnalisée elle-même.

Pourquoi par eux-mêmes? Parce que, comme vous vous en souvenez, nous n'avons pas encore écrit d'implémentation. Et toutes ces entités sont apparues dans le processus de programmation des tests. Pour que le compilateur ne jure pas, en vrai code, nous devrons créer tout ce qui est décrit ci-dessus. Heureusement, presque n'importe quel IDE nous aidera à générer les entités nécessaires. Ainsi, nous écrivons en quelque sorte un test - et l'application est remplie de classes et de méthodes.

Au total, les tests du contrôleur sont les suivants:

Les tests
 @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" + "}"; } } 

Nous pouvons maintenant écrire l'implémentation et nous assurer que tous les tests réussissent:
Implémentation
 @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)); } } 


Gestionnaire d'exceptions
 @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'est-ce qui vient de se passer?


Dans TDD, vous n'avez pas besoin de garder tout le code dans votre tête.

Encore une fois: ne conservez pas toute l'architecture en RAM. Regardez simplement une couche. Il est simple.

Dans le processus habituel, le cerveau ne suffit pas, car il existe de nombreuses implémentations. Si vous êtes un super-héros qui peut prendre en compte toutes les nuances d'un grand projet dans votre tête, alors TDD n'est pas nécessaire. Je ne sais pas comment. Plus le projet est grand, plus je me trompe.

Après avoir réalisé que vous ne devez comprendre que ce dont la couche suivante a besoin, l'illumination prend vie. Le fait est que cette approche vous permet de ne pas faire de choses inutiles. Ici tu parles avec une fille. Elle parle d'un problème au travail. Et vous pensez comment le résoudre, vous vous faites des cerveaux. Et elle n'a pas besoin de le résoudre, elle a juste besoin de le dire. Et c'est tout. Elle voulait juste partager quelque chose. Apprendre cela à la toute première étape de listen () est inestimable. Pour tout le reste ... eh bien, vous savez.


Le service


Ensuite, nous implémentons le service.

Que voulons-nous du service?

Nous voulons qu'il traite de la logique métier, c'est-à-dire:

  1. Il savait comment déconnecter les marchandises et a également informé :
  2. La disponibilité, si le produit n'est pas déconnecté, est en stock, la couleur du produit est jaune et DostavchenKO est prêt à effectuer la livraison.
  3. Inaccessibilité, si les marchandises ne sont pas disponibles indépendamment de quoi que ce soit.
  4. Inaccessibilité, si le produit est bleu.
  5. Inaccessibilité si DostavchenKO refuse de le porter.
  6. Inaccessibilité si les marchandises sont déconnectées manuellement.
  7. Ensuite, nous voulons que le service lance l'exécution si l'un des systèmes n'est pas disponible.
  8. Et aussi, afin de ne pas spammer de DONNÉES, vous devez organiser l'envoi de messages paresseux, à savoir:
  9. Si nous avions l'habitude d'envoyer des marchandises disponibles pour des marchandises et que nous avons maintenant calculé ce qui est disponible, nous n'envoyons rien.
  10. Et s'il n'est pas disponible avant, mais maintenant il est disponible, nous l'envoyons.
  11. Et vous devez l'écrire quelque part ...

STOP!


Ne pensez-vous pas que notre service commence à faire trop?

À en juger par notre liste de souhaits, il sait comment désactiver les marchandises, considère l'accessibilité et s'assure qu'il n'envoie pas les messages précédemment envoyés. Ce n'est pas une cohésion élevée. Il est nécessaire de déplacer des fonctionnalités hétérogènes dans différentes classes, et il devrait donc y avoir déjà trois services: l'un traitera de la déconnexion des marchandises, l'autre calculera la possibilité de livraison et la transmettra à un service qui décidera de l'envoyer ou non. Soit dit en passant, de cette façon, le service de logique métier ne saura rien du système DATA, ce qui est également un avantage certain.

D'après mon expérience, assez souvent, après avoir été implanté dans la mise en œuvre, il est facile d'oublier les moments architecturaux. Si nous écrivions le service tout de suite, sans penser à ce qu'il devrait faire, et, plus important encore, qu'il ne devrait PAS, la probabilité de chevauchement des domaines de responsabilité augmenterait. Je voudrais ajouter en mon nom que c'est cet exemple qui m'est arrivé dans la pratique et la différence qualitative entre les résultats de TDD et les approches de programmation séquentielle qui m'ont inspiré pour écrire ce billet.

Logique métier


En pensant au service de logique métier pour les mêmes raisons qu'une forte cohésion, nous comprenons que nous avons besoin d'un niveau d'abstraction supplémentaire entre celui-ci et le vrai DostavchenKO. Et, puisque nous concevons le service en premier , nous pouvons exiger du client DostavchenKO un tel contrat interne que nous voulons. Au cours de la rédaction d'un test de logique métier, nous comprendrons ce que nous attendons du client de la signature suivante:

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

Au niveau du service, peu importe la réponse du vrai DostavchenKO: à l'avenir, la tâche du client lui permettra en quelque sorte d'obtenir ces informations. Une fois que cela peut être simple, mais il faudra parfois faire plusieurs demandes: pour le moment nous en sommes abstraits.

Nous voulons une signature similaire d'un service qui traitera des marchandises déconnectées:

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

Ainsi, les questions «Que veux-je du service de logique métier?» Enregistrées dans les tests se présentent comme suit:

Tests de service
 @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. -, , , - , . . . , , , , . , , , . , , : , .
  2. -, . — , . , , , , , , . . ProductAvailability. , . . ., , god object, , , , TDD, , . , , «» — : « ...» , , TDD, .

:

Les tests
 @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 god object , : , , , .

Les tests
 @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()); } } 


Conclusion



. , TDD, , , , ( , - ).

, , . , TDD , , .

, , , , , , . , , «» , , , - - , .

, TDD , , . , , TDD, , - , , TDD, , .

, .

. , , , , , , TDD .

, , TDD.

, json. , , json POJO-. IDEA, , JSON.

?


. , , . . TDD . , . , , . , . . . .

, TDD , : , , , . , , .

TDD — . , . , N , . , , , . N . , , , 1 god object 1 . , TDD : — .

, , — - . — . 1,5 .

. TDD , , , . .

Source: https://habr.com/ru/post/fr456662/


All Articles