Z każdym kolejny mikroserwisem testowanie naszej aplikacji staje się coraz bardziej trudne (w przypadku gdy są one ze sobą powiązane). Oczywiście możemy zamokować zachowanie innych serwisów korzystając na przykład z biblioteki WireMock, jednakże po wdrożeniu naszej aplikacji na produkcję okazuje się, że aplikacja nie integruje się poprawnie. A no właśnie, okazało się, iż mój serwis odpytywał zły endpoint, ale jak to możliwe, skoro wszystkie testy były uzależniająco zielone?
Consumer Driven Contract
Testy przechodziły, ponieważ to ja sam, poprzez mockowanie określałem jak usługi, z którymi będę się komunikował będą się zachowywać (w moim przypadku zachowywały się źle w stosunku do produkcji). Rozwiązaniem tego problemu są kontrakty. Consumer Driven Contract to podejście, w którym do komunikacji między usługami wykorzystujemy współdzielone kontrakty. Działanie CDC pokażę na przykładzie dwóch aplikacji, producer oraz consumer.
Producer – serwis z innego teamu
Producer jest to aplikacja, która tworzona jest przez inny team. Moja usług consumer będzie korzystała z endpointu /
z usługi producer, która będzie zwracać użytkownika z bazy. Za nim zacznę pisać moją usługę, w uzgodnieniu z inną ekipą musimy przygotować kontrakt, który będziemy wykorzystywać do komunikacji. Zaczynamy od dodania zależności:
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-contract-verifier</artifactId> <scope>test</scope> </dependency>
Piszemy kontrakt
Przygotujmy teraz pierwszy kontrakt, dodajemy nowy plik shouldReturnUser.groovy
(ponieważ kontrakty piszemy w Groovym) w naszych źródłach testowych czyli src\test\resources\contracts
. Następnie dodajemy zawartość kontraktu. Dla zapytania GET
na adresie /
powinien być zwrócony JSON czyli contentType
oraz {"id":1, "name": "CodeCouple.pl" }
z statusem 200 OK
:
Contract.make { description "Should return user" request { method("GET") url("/") } response { status 200 body(["id":1, "name": "CodeCouple.pl"]) headers { contentType("application/json") } } }
Budujemy stuba
Napisaliśmy nasz pierwszy kontrakt, teraz musimy wygenerować stuba, który będzie zachowywał się jak w opisie kontraktu. Tak naprawdę nasz stub, to wygenerowany jar, który następnie możemy zdeployować na Nexusie lub w innym centralnym miejscu, do którego mają dostęp inne teamy. Domyślnie umieszczany jest on w lokalnym repozytorium m2. Jednakże, aby zbudować naszego stuba musimy dodać nowy plugin spring-cloud-contract-maven-plugin
do procesu budowania:
<plugin> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-contract-maven-plugin</artifactId> <version>1.1.4.RELEASE</version> <extensions>true</extensions> <configuration> <baseClassForTests>pl.codecouple.producer.BaseClass</baseClassForTests> </configuration> </plugin>
Testy?
Ważne jest ustawienie extensions
na wartość true
, co oznacza, że w naszym procesie budowania mogą być wykorzystane inne artefakty (więcej https://maven.apache.org/pom.html#Extensions). Należy także ustawić konfigurację dla naszych testów. Zaraz, zaraz, jakich testów? Otóż plugin ten, oprócz stworzenia stuba, tworzy także testy. Testy te, zweryfikują czy nasza aplikacja działa zgodnie z kontraktem. Jest to bardzo dobre podejście, ponieważ mamy pewność, że nie wypuśmy stuba, który z naszą aplikacją działałby niepoprawnie. Wiemy już, iż dostaniemy testy, teraz musimy wskazać klasę bazową (wygenerowane testy będą dziedziczyć po tej klasie) dla tych testów. W naszym przypadku będzie to klasa o nazwie BaseClass
. Nasza klasa bazowa wygląda nastepująco:
@RunWith(SpringRunner.class) @SpringBootTest(classes = ProducerApplication.class) public abstract class BaseClass { @Autowired UserController userController; @Before public void setUp() throws Exception { RestAssuredMockMvc.standaloneSetup(userController); } }
Tworzymy kontroler:
@RestController class UserController { @GetMapping("/") UserDTO getUser(){ return UserDTO.builder() .id(1) .name("CodeCouple.pl") .build(); } } @Builder @Data class UserDTO { private long id; private String name; }
Skonfigurowaliśmy proces budowania naszego stuba oraz przygotowaliśmy klasy, możemy więc odpalić mvn clean install
jeśli zdecydowaliśmy się na korzystanie z Mavena. W konsoli pojawi się wpis:
[INFO] Installing path-to-artifact\producer-service-0.0.1-SNAPSHOT-stubs.jar to path-to-m2\producer-service-0.0.1-SNAPSHOT-stubs.jar
W folderze \target\generated-test-sources\contracts
znajdziemy wygenerowane automatycznie testy, które weryfikują poprawność działa usługi na podstawie kontraktu:
public class ContractVerifierTest extends BaseClass { @Test public void validate_shouldReturnUser() throws Exception { // given: MockMvcRequestSpecification request = given(); // when: ResponseOptions response = given().spec(request) .get("/"); // then: assertThat(response.statusCode()).isEqualTo(200); assertThat(response.header("Content-Type")).matches("application/json.*"); // and: DocumentContext parsedJson = JsonPath.parse(response.getBody().asString()); assertThatJson(parsedJson).field("['id']").isEqualTo(1); assertThatJson(parsedJson).field("['name']").isEqualTo("CodeCouple.pl"); } }
Wygenerowaliśmy stuba, możemy teraz wrócić do pisania naszego serwisu czy consumera.
Druga strona – Consumer
Z drugiej strony, czyli już w naszym serwisie, który tworzymy, musi dodać zależności, które będą potrafiły uruchomić stuby z repozytorium:
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-contract-stub-runner</artifactId> <scope>test</scope> </dependency>
Po dodaniu zależności możemy zacząć pisanie naszej logiki, która odpytywać będzie Producer’a:
@RestController class ConsumerController { private final RestTemplate restTemplate; ConsumerController(RestTemplate restTemplate) { this.restTemplate = restTemplate; } @GetMapping("/") ResponseEntity<String> showCodeCouple(){ HttpHeaders headers = new HttpHeaders(); headers.set("Accept", MediaType.APPLICATION_JSON_VALUE); HttpEntity<?> entity = new HttpEntity<>(headers); return restTemplate.exchange( "http://producer-service/", HttpMethod.GET, entity, String.class); } }
Testy muszą być
Tym razem w testach nie będziemy już sami mockować zachowania innych serwisów, tylko wykorzystamy wygenerowane stuby. Wykorzystujemy do tego adnotację @AutoConfigureStubRunner
. Adnotacja ta przyjmuje kilka parametrów, workOffline
, który decyduje o tym czy stuby mają być pobierane z lokalnego repozytorium czy z repozytorium zdalnego. Kolejny parametr to ids
, gdzie wskazujemy nazwy przygotowanych stubów.
@RunWith(SpringRunner.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) @AutoConfigureMockMvc @AutoConfigureStubRunner(workOffline = true, ids = { "pl.codecouple:producer-service" }) public class ConsumerControllerIntegrationTest { @Autowired MockMvc mockMvc; @Test public void shouldReturnUser() throws Exception { // When ResultActions result = mockMvc.perform(get("/")); // Then result.andExpect(status().isOk()) .andExpect(content().json("{\"id\":1,\"name\":\"CodeCouple.pl\"}")); } }
Odpalamy testy, musi być kolor zielony. Teraz możemy wrócić do producera i zmienić coś w kontrakcie. Po zmianie, całą operację generowania należy powtórzyć. Gdy znów odpalimy testy będziemy mieli kolor czerwony, bardzo dobrze, to oznacza, że nasza aplikacja w sposób błędny integruje się z innym serwisem. Złapaliśmy to w teście! Dzięki temu unikniemy sytuacji, którą opisałem we wstępie.
GitHub
Całość kodu znajdziecie jak zawsze na GitHubie.
Więcej
Bardzo mocno polecam nagrania Marcina Grzejszczaka na temat CDC (należy wpisać na youtube Marcin Grzejszczak CDC). Fajne nagranie z Devoxx’a 2017, w którym Marcin i Josh Long pokazują moc kontraktów: