Kilka miesięcy temu nasz zespół frontendowy napotkał trudności podczas pracy ze zdalnym zespołem backendowym klienta. Dwie grupy osób, które nie mogą się ze sobą bezpośrednio komunikować i częste zmiany API doprowadziły do spowolnienia rozwoju projektu.

Spis treści:

Aktualizacje API były częste, a z powodu braku komunikacji nie byliśmy ich świadomi, dopóki nie przeprowadziliśmy kontroli całego przepływu biznesowego.

Straciliśmy zaufanie do API i zauważyliśmy, że powinniśmy byli rozpocząć rozwój od czegoś, co zapewniłoby nam, że zarówno strona producenta, jak i konsumenta korzystają z tego samego API.

Pytanie brzmiało: Skąd mamy wiedzieć, że implementujemy to samo API? co doprowadziło nas do kolejnego pytania: Czy możemy być pewni, że nasze testowe makiety zachowują się jak prawdziwe API REST?

Postanowiliśmy zagłębić się w ten temat i spróbować zrobić coś, aby mieć pewność, że mamy wyczerpującą odpowiedź na te pytania.


Nie jest to rozwiązanie

Zwykle testy End-to-End (E2E) są uważane za testy, których efektem ubocznym jest to, że wiemy, że interfejsy API po stronie frontend i backend nie są spójne. Problem polega na tym, że nie mówią one wprost, że problemem jest niespójność API. Ich głównym zadaniem jest sprawdzenie przepływu użycia, a nie samego API. Zajmuje im to również dużo czasu. Kłopotliwe jest skonfigurowanie całego kontekstu i uruchomienie testów E2E w celu sprawdzenia, czy API jest poprawnie zaimplementowane.

Ten, który rządzi wszystkimi

Testowanie kontraktów to sposób na zapewnienie, że usługi komunikują się ze sobą za pomocą tego samego "języka" API. Opiera się na umowie (przygotowanej przez producenta lub konsumenta), która gwarantuje, że obie strony implementują ten sam interfejs API. Zarówno testy frontendowe, jak i backendowe opierają się na tym samym kontrakcie. Testowanie kontraktów jest zabójcą piekła wersji API.

Testy backendu nie potrzebują całego kontekstu aplikacji. Wymagają jedynie punktów końcowych i stubów wstrzykniętych metod usług.

Z drugiej strony, testy frontendu są po prostu testami jednostkowymi ze stubami punktów końcowych usług backendu.

Złożę mu ofertę nie do odrzucenia.

Jak wygląda rozwój umów sterowanych przez konsumenta? Programista frontendowy i backendowy tworzą umowę, jak powinien wyglądać interfejs API w formie pliku umowy. Podstawową ideą jest to, że umowa jest pisana jako część testów konsumenckich. Warto zauważyć, że umowa definiuje minimalny zestaw pól żądania/odpowiedzi, które powinny być obecne podczas komunikacji, więc jeśli dodasz nowe pola, nie złamią one umowy.

PACT

PACT pomaga programistom to osiągnąć. Główną zaletą jest to, że jest dobrze obsługiwany przez Angular, a jego pliki kontraktowe, które są podstawą kontraktu do generowania testów producenta, mogą być współdzielone z aplikacją JVM.

Pakty dzielenia się między konsumentami i producentami

PACT zapewnia rozwiązanie do przechowywania plików umów o nazwie PACT Broker. Jest to repozytorium do publikowania i pobierania paktów za pomocą interfejsu API REST.

Niestety nie mieliśmy czasu na pracę z kolejnym nowym repozytorium, więc zdecydowaliśmy się przygotować prosty flow na Jenkinsie i udostępniać pakty przez repozytorium git zamiast uruchamiać brokera.

Poniższy diagram przedstawia naszą koncepcję udostępniania paktów bez pośrednika:

wpływ testowania umów na konsumentów

Umowa Spring Cloud

Spring Cloud Contract to zestaw narzędzi wspierających kontrakty konsumenckie w aplikacjach Spring. Projekt koncentruje się na niestandardowym rozwiązaniu DSL, na szczęście zapewnia również wsparcie dla PACT, dzięki czemu jesteśmy w stanie bezbłędnie współpracować z aplikacją frontendową Angular inną niż JVM.

Generowanie testów

Naszym zadaniem było zaimplementowanie API do wysyłania prostej wiadomości i zwracania jej identyfikatora. Dany kontrakt wygląda jak poniżej:

{
  "consumer": {
    "name": "frontend-app"
  },
  "provider": {
    "name": "backend-app"
  },
  "interactions": [
    {
      "description": "POST new message",
      "providerState": "provider accepts message",
      "request": {
        "method": "POST",
        "path": "/message",
        "headers": {
          "Content-Type": "application/json;charset=UTF-8"
        },
        "body": {
          "message": "Sample message"
        }
      },
      "response": {
        "status": 201,
        "headers": {
          "Content-Type": "application/json;charset=UTF-8"
        },
        "body": {
          "id": "25e3ae11-d294-4a69-9421-2816df07b531"
        },
        "matchingRules": {
          "$.body": {
            "match": "type"
          }
        }
      }
    }
  ],
  "metadata": {
    "pactSpecification": {
      "version": "2.0.0"
    }
  }
}

Załóżmy, że wygenerowany pakt znajduje się już w repozytorium producenta src/test/resources/pacts/messaging.json

Przygotowanie build.gradle

Musimy dodać zależności i włączyć wtyczkę gradle:

buildscript {
    ...
    dependencies {
        ...
        classpath("org.springframework.cloud:spring-cloud-contract-gradle-plugin:${springCloudContractVersion}")
        classpath("org.springframework.cloud:spring-cloud-contract-pact:${springCloudContractVersion}")
        ...
    }
}

...
apply plugin: 'spring-cloud-contract'
...

dependencies {
    ...
    testImplementation("au.com.dius:pact-jvm-provider-spring_2.12:${pactVersion}") // we need this one to use PACT provider annotations
    testImplementation("org.springframework.cloud:spring-cloud-starter-contract-verifier:${springCloudContractVersion}") // tests use the SpringCloudContractAssertions 
    ...
}

Następnie musimy skonfigurować contracts która jest używana do generowania testów:

import org.springframework.cloud.contract.verifier.config.TestFramework

contracts {
    targetFramework = TestFramework.JUNIT // it's default value, you can use SPOCK instead
    contractsPath = "pacts/" // default directory is `contracts/`
    baseClassForTests = 'com.inspeerity.article.contract.MessagingContractMocks' // here we specify which class will be extended by the generated tests
    basePackageForTests = 'com.inspeerity.article.contract' // base package for generated tests
}

Klasa bazowa dla testów

Musimy zaimplementować klasę bazową dla testów. Przechowuje ona informacje o definicjach testów, które chcemy uruchomić i definiuje stuby metod serwisowych.

Stwórzmy MessagingContractMocks jak zdefiniowaliśmy to jako baseClassForTests:

package com.inspeerity.article.contract;

import ...

@RunWith(SpringRestPactRunner.class)
@WebMvcTest(MessageController.class)
@PactFolder("contracts/")
@Provider("backend-app")
abstract class MessagingContractMocks {

    @TestTarget
    public final MockMvcTarget target = new MockMvcTarget();
    
    @Autowired
    private MessageControler messageControler;
    
    @Before
    public void setupBefore() {
        MockitoAnnotations.initMocks(this);
        target.setControllers(featureController);
    }
    
    @MockBean
    private MessageSender messageSender;
    
    @State("provider accepts message")
    public void aRequestToGETFeatures() {
    }
}

Można to zauważyć:

  • Korzystamy z dedykowanej biblioteki pact spring, dzięki czemu możemy używać SpringRestPactRunner zamiast PactRunner jako runner Junita. Pozwala nam to na korzystanie z adnotacji testów wiosennych.
  • Umieściliśmy @WebMvcTest ponieważ do celów testowania kontraktu potrzebujemy w kontekście aplikacji tylko komponentów związanych z siecią i będziemy kpić z fasoli z innych warstw.
  • @Provider adnotacja definiuje, że jesteśmy zainteresowani umowami backend-app dostawca.
  • Jeśli masz więcej niż jednego konsumenta lub korzystasz z PACT Proker, konieczne może być również zdefiniowanie @Consumer aby określić, która umowa ma być używana.
  • Testy kontraktowe wymagają @TestTarget z adnotacją Target która powinna rzucić wyjątek w przypadku nieoczekiwanej odpowiedzi. MockMvcTarget jest gotową implementacją, która weryfikuje odpowiedzi kontrolera. Musi być zdefiniowana, jeśli używasz SpringRestPactRunner i wszystkie testowane kontrolery muszą być ustawione, w przeciwnym razie nie zostaną one sprawdzone i test zakończy się niepowodzeniem z wynikiem Not found błąd.
  • Używamy @MockBean adnotacja do utworzenia MessageSender bean mock. Jest ona używana przez nasz kontroler, więc będziemy musieli później zastąpić jej metodę.
  • Definiujemy @State metoda z adnotacją. To nasze miejsce, aby zdefiniować nasze makiety i stuby dla wybranego stanu kontraktu.

Generowanie klasy testowej

Aby sprawdzić, czy test generuje klasę testową:

./gradlew generateContractTests

Oto wygenerowana klasa testowa!

generatedTest

Testy kontraktowe opierają się na typach odpowiedzi, a nie konkretnych wartościach (nie testują logiki). Dlatego wygenerowane testy sprawdzają typy wartości zamiast wartości.

Spróbuj uruchomić testy:

./gradlew test

Jak widać, test kończy się niepowodzeniem:

testFailed-contract-testing

Opis wyjaśnia ze szczegółami, że:

  1. Content-Type brakuje nagłówka
  2. status to 404 zamiast oczekiwanego 201
  3. typ odpowiedzi to test/plain zamiast oczekiwanego application/json

Wdrożenie

Musimy stworzyć MessageController:

package com.inspeerity.article.contract.messaging;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class MessageControler {

    private final MessageSender messageSender;

    @Autowired
    public MessageController(MessageSender messageSender) {
        this.messageSender = messageSender;
    }

    @PostMapping(
            consumes = MediaType.APPLICATION_JSON_UTF8_VALUE,
            produces = MediaType.APPLICATION_JSON_UTF8_VALUE
    )
    @ResponseStatus(HttpStatus.CREATED)
    public MessageDto postMessage(@RequestBody MessageCommand messageCommand) {
        return new MessageDto(messageSender.sendMessage(messageCommand.getMessage()));
    }
}

Oczywiście należy również utworzyć klasy:

  • typ danych wejściowych: MessageCommand z polem message z String typ
  • MessageDto z jednym polem id z UUID typ
  • MessageSender z adnotacją @Service z publiczną metodą sendMessage(String message)zwracający UUID

Nie trzeba tworzyć żadnej logiki w aplikacji MessageSender po prostu zwraca jakiś UUID. W rzeczywistości, dla celów samouczka, będziemy dbać tylko o jego skrót metody.

Nie zapomnij dodać metody usługi:

@State("provider accepts message")
public void postNewMessage() {
    when(messageSender.sendMessage("Sample message")) // we need to stub service method we used in controller
            .thenReturn(UUID.fromString("25e3ae11-d294-4a69-9421-2816df07b531")); // this data comes from pact file
}

Teraz, jeśli biegniesz:

./gradlew test

Test zostanie zaliczony:

testOk-contract-testing

Co zyskaliśmy?

Naszym problemem była niespójność API. Używając testów kontraktowych pozbywamy się kłopotliwych i czasochłonnych testów E2E. Ważną rzeczą jest to, że programiści backendu muszą być świadomi, że jeśli złamią kontrakt, np. zmieniając typ pola, test zakończy się niepowodzeniem. Z drugiej strony, jeśli programiści frontendu potrzebują nowego punktu końcowego lub modyfikują stary format danych, muszą przygotować nowy kontrakt.

Chcesz porozmawiać z naszymi ekspertami o niestandardowych rozwiązaniach programowych dla Twojej firmy?

P: Jakie trudności napotkał zespół frontendowy podczas pracy ze zdalnym zespołem backendowym klienta?

Zespół frontendowy napotkał trudności z powodu braku bezpośredniej komunikacji z zespołem backendowym i częstych zmian API. Doprowadziło to do spowolnienia rozwoju projektu.

P: Jak zespół poradził sobie z tymi trudnościami?

Zespół zdecydował się wdrożyć testowanie kontraktów, aby upewnić się, że zarówno usługi frontendowe, jak i backendowe komunikują się z tym samym "językiem" API. Wykorzystano również Spring Cloud Contract i PACT, aby wspomóc ten proces.

P: Czym są testy kontraktowe?

Testowanie kontraktów to sposób na zapewnienie, że usługi komunikują się ze sobą za pomocą tego samego "języka" API. Opiera się na umowie (przygotowanej przez producenta lub konsumenta), która stwierdza, że obie strony implementują ten sam interfejs API. Zarówno testy frontendowe, jak i backendowe opierają się na tym samym kontrakcie.

Oceń ten post