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:
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
zamiastPactRunner
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 umowamibackend-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żywaszSpringRestPactRunner
i wszystkie testowane kontrolery muszą być ustawione, w przeciwnym razie nie zostaną one sprawdzone i test zakończy się niepowodzeniem z wynikiemNot found
błąd. - Używamy
@MockBean
adnotacja do utworzeniaMessageSender
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!
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:
Opis wyjaśnia ze szczegółami, że:
Content-Type
brakuje nagłówka- status to
404
zamiast oczekiwanego201
- typ odpowiedzi to
test/plain
zamiast oczekiwanegoapplication/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 polemmessage
zString
typ MessageDto
z jednym polemid
zUUID
typMessageSender
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:
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.