Java Developer? Przejdź na wyższy poziom wiedzy 🔥💪  Sprawdź

Team Leader? Podnieś efektywność swojego zespołu 👌 Sprawdź

Consumer-Driven Contract (CDC) – Java Microservices

utworzone przez 6 sierpnia 2020Java, Spring Framework, Testowanie

Zintegruj swoje mikroserwisy za pomocą Consumer-Driven Contract (CDC). Koniec z niedziałającymi testami integracyjnymi i środowiskiem testowym.

Aby uzyskać relatywnie dużą niezależność (niski coupling) i przy okazji wysoką kohezję naturalne jest dzielenie systemów na mniejsze części. Idealną sytuacją jest niezależność systemów i zespołów, które są ich właścicielami – release jednego systemu nie pociąga za sobą konieczności wdrożenia innego na zasadzie: razem, albo w ogóle. Jeżeli jest odwrotnie – istnieje wysoka zależność (coupling).

Gdy już podzielimy systemy, należy je ze sobą zintegrować, np. za pomocą REST, Message-Driven – nazwijmy z grubsza, że system wystawia jakieś API. Gdy dochodzi do integracji, każdy system po swojej stronie chce mieć pewność, że komunikacja jest zapewniona.

Dlatego zgodnie z piramidą testów pisane są testy na różnych poziomach: unitowe/komponentowe działające w izolacji i integracyjne generujące realny ruch i sprawdzające połączenie między systemami.

Z tego artykułu dowiesz się:

  • Czym jest Consumer-Driven Contract (CDC) i jakie problemy rozwiązuje
  • Czym jest Contract, Consumer i Provider
  • Jak zaimplementować CDC za pomocą Spring Cloud Contract

Piguła wiedzy o najlepszych praktykach testowania w Java

Pobierz za darmo książkę 100 stron o technikach testowania w Java

Consumer i Provider API

Wprowadźmy dwa pojęcia:

  • Consumer – konsument, system, który używa API.
  • Provider – dostawca, system, który udostępnia swoje funkcjonalności poprzez API.

Patrząc z najpierw z perspektywy konsumenta – czyli zespołu, który używa API innego systemu – sytuacja może wyglądać mniej więcej w ten sposób:

Różnica pomiędzy Consumer-Driven Contract, a Testowaniem integracyjnym - Component Tests, Integration Tests, End to End tests.

W teście komponentowym utrzymujemy wysoką izolację – nie dochodzi do fizycznej integracji. Odpowiedź zastępujemy oczekiwaną odpowiedzią (stub → stałą, mock → sterowaną zachowaniem). Samodzielnie przechwytujemy jakąś przykładową odpowiedź (np. odpowiedź w formacje JSON/XML w pliku tekstowym dołączonym do zestawu testów) i wstawiamy ją do swojego systemu jako przykładową.

W teście integracyjnym istnieje niska izolacja – łączymy się po fizycznym protokole i testujemy scenariusze integracji. Potrzebny jest działający system Providera, na którym możemy sprawdzić nasz unit integracyjny.

Z kolei w teście end-to-end używamy aplikacji i sprawdzamy, czy działa ona całościowo, nie zwracając uwagi na szczegóły integracji. Proces ma ogólnie działać.

Rodzi się szereg problemów:

  • Testy komponentowe są wyizolowane i niekoniecznie odzwierciedlają rzeczywisty stan odpowiedzi Providera ze względu na to, że używana jest pobrana próbka odpowiedzi. W międzyczasie kontrakt mógłby się zmienić bez zmiany przykładowej odpowiedzi w zestawie testów, które są zielone. Dlatego używane są testy integracyjne.
  • Testy integracyjne są wolne i zależą od działającego Providera. W tym miejscu możemy zidentyfikować potencjalne problemy z kontraktem. Powoduje to, że żeby organizacja mogła przeprowadzać testy integracyjne, potrzebne jest działające, stabilne środowisko testowe przynajmniej dla każdego Providera.
  • Gdy Provider zmieni swoje API naruszając czyjeś integracje – nie dowiaduje się o tym automatycznie.

Odnośnie stabilnego środowiska testowego: spójrzmy prawdzie w oczy. X zespołów robi „drobne zmiany” w swoich aplikacjach, które testują na środowisku testowym. Prawdopodobieństwo, że całe środowisko jest stabilne, wynosi iloczyn prawdopodobieństw: P(1) × P(2) × … × P(X). Prawdopodobieństwo maleje znacznie.

Jako Producer ustalmy kontrakt z Consumerem

Contract – kontrakt, czyli specyfikacja, na podstawie której zarówno Provider jak i Consumer opierają swoje implementacje.

Wcześniej wspomniane problemy zaadresować próbuje Consumer-Driven Contract. Idea jest prosta: wyspecyfikujmy API w formie dokumentu, wymieńmy się nim i każdy po swojej stronie uruchomi testy:

  • Consumer po swojej strony uruchomi realnego mocka, który będzie odzwierciedlał zachowanie. Złamanie kontraktu po stronie integracji spowoduje przerwanie procesu budowania.
  • Provider uruchomi zestaw testów kontraktowych i sprawdzi, czy je spełnia w sposób w pełni automatyczny jako część swoich testów. Złamanie kontraktu przez Providera API spowoduje przerwanie procesu budowania.
Różnica pomiędzy Consumer-Driven Contract, a Testowaniem integracyjnym - Component Tests, Integration Tests, End to End tests.

Zauważ, że metodologia nazywa się Consumer-Driven, czyli API wystawiane przez Providera jest skoncentrowane na aplikacjach, które będą z niego korzystać. Provider może mieć wiele kontraktów z Consumerami, którzy definiują swoje oczekiwania od usługi. Zmiana tego wektoru jest kluczowa, od teraz każda zmiana Providera jest automatycznie testowana przed wydaniem oprogramowania.

Wspomnę szereg zalet:

  • Wymieniamy się zawsze najbardziej aktualnym kontraktem. To bardzo ważne, żeby żadna ze stron nie operowała na nieaktualnym kontrakcie.
  • Każda ze stron wykonuje testy po swojej stronie w izolacji, ale na podstawie kontraktu. Nie jest wymagane działające w pełni zintegrowane środowisko.
  • Consumer wykonuje szybkie wyizolowane testy po swojej stornie uruchamiając mocka sterowanego kontraktem.
  • Provider w ramach swojego procesu budowania w sposób automatyczny dowie się, czy został złamany kontrakt z systemem.

Psst… Interesujący artykuł?

Jeżeli podoba Ci się ten artykuł i chcesz takich więcej – dołącz do newslettera. Nie ominą Cię materiały tego typu.

.

Consumer-Driven Contract w praktyce

Przejdźmy do programowania, koncept jest już Ci znany. W Java na ten moment mamy co najmniej dwa bardziej popularne rozwiązania:

Moja osobista preferencja to Spring Cloud Contract ze względu na stos technologiczny, którego używam. Aby zaprezentować działanie na przykładzie, posłużę się domeną koszyka sklepowego.

Zdefiniujemy dwa REST endpointy:

  1. POST /basket
    Tworzy koszyk i zwraca kod 201 Created z body: { basketId:"uuid-koszyka" }
  2. POST /basket/uuid-koszyka/summary
    Na podstawie podanych danych adresowych zwraca kod 200 i odpowiedź z ceną koszyka: { totalPrice: 123.45 }

Zaprezentuję poniżej fragmenty kodu. Stworzyłem przykładowy projekt, którego pełne źródła znajdziesz pod adresem:

👉 https://github.com/softwareskill/cdc-example

Kod jest podzielony na dwa katalogi:

  • provider (dostawca REST API) ,
  • consumer (klient REST API).

Producer

Producer definiuje kontrakty, wobec tego potrzebujemy dwóch zależności w pom.xml:

<dependencies>
    ...
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-contract-verifier</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

<build>
    <plugins>
        ...
        <plugin>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-contract-maven-plugin</artifactId>
            <version>2.2.3.RELEASE</version>
            <extensions>true</extensions>
            <configuration>
                <testFramework>JUNIT5</testFramework>
                <baseClassForTests>pl.softwareskill.example.cdc.basket.rest.BaseContractTest</baseClassForTests>
            </configuration>
        </plugin>
    </plugins>
</build>

Deklarujemy spring-cloud-starter-contract-verifier oraz spring-cloud-contract-maven-plugin – te zależności wygenerują zestaw testów po stornie producenta, które zostaną uruchomione w ramach scenariuszy testowych aplikacji, aby sprawdzić, czy spełnia ona wymagania kontraktu. BaseClassForTests to klasa testu, która jest bazowa dla testów (w niej skonfigurujemy kontekst Spring`a oraz ewentualnie przygotujemy dane testowe pod kontrakty – o ile występują).

Następnie w katalogu /resources/contracts definiujemy dwa kontrakty. Definiowanie kontraktów odbywa się poprzez użycie DSL (Domain-Specific Language) zdefiniowanego przez Spring Cloud Contract. Mogą być napisane w Groovy lub YAML.

Listing create-basket.groovy

package contracts

org.springframework.cloud.contract.spec.Contract.make {
    request {
        method 'POST'
        url '/basket'
        //body()
        headers {
            contentType('application/json')
        }
    }
    response {
        status CREATED()
        body([
            "basketId": $(anyUuid())
        ])
        headers {
            contentType('application/json')
        }
    }
}

Listing basket-summary.groovy

package contracts

org.springframework.cloud.contract.spec.Contract.make {
    request {
        method 'POST'
        url '/basket/063b360b-854a-4f5a-9081-fd0157135d9c/summary'
        body([
            "shippingAddress": [
                "country": "Polska",
                "city": "Warszawa",
                "street": "Podwale",
                "number": "12",
            ]
        ])
        headers {
            contentType('application/json')
        }
    }
    response {
        status OK()
        body([
            "totalPrice": $(anyNumber())
        ])
        headers {
            contentType('application/json')
        }
    }
}

W kontraktach możesz zauważyć miejsca typu $(anyNumber()) lub $(anyUuid()). Są to matchery (porównanie do wzorca), które będą wykorzystywane przy sprawdzeniu odpowiedzi. Pełną ich listę znajdziesz w dokumentacji.

Uruchamiamy testy i są czerwone. To dobrze, nasza aplikacja na razie nie spełnia wymagań.

Zerknijmy jednak w automatycznie wygenerowane testy przez Contract Verifier:

Wynik działania Contract Verifier u Providera w Consumer-Driven Contract - automatycznie wygenerowane testy

Powyższy screen prezentuje kod Java automatycznie wygenerowanych testów na podstawie DSL. Wykonywany jest request do aplikacji, a następnie sprawdzana jest odpowiedź (kod, content-type, zdefiniowane pola) – zgodnie z kontraktem.

Czas na implementację, tworzymy bazową klasę testów, która poprawnie skonfiguruje kontekst Spring’a i zarejestruje kontroler w bibliotece RestAssured używanej w Spring Cloud Contract:

Listing BaseContractTest.java

package pl.softwareskill.example.cdc.basket.rest;

@SpringBootTest
@AutoConfigureMockMvc
public class BaseContractTest {

    @Autowired
    private MockMvc mockMvc;

    @BeforeEach
    public void setup() {
        RestAssuredMockMvc.mockMvc(mockMvc);
    }
}

Czas na implementację kontrolerów:

RestConfig.java

package pl.softwareskill.example.cdc.basket.rest;

import org.springframework.context.annotation.ComponentScan;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;

@EnableWebMvc
@ComponentScan(basePackages = "pl.softwareskill.example.cdc.basket.rest.controller")
public class RestConfig {
}

BasketController.java

package pl.softwareskill.example.cdc.basket.rest.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import pl.softwareskill.example.cdc.basket.rest.dto.BasketSummaryDto;
import pl.softwareskill.example.cdc.basket.rest.dto.BasketSummaryView;
import pl.softwareskill.example.cdc.basket.rest.dto.BasketView;

import java.math.BigDecimal;
import java.net.URI;
import java.util.UUID;

@RestController
@RequestMapping(path = "/basket")
public class BasketController {

    @PostMapping
    public ResponseEntity<BasketView> createBasket() {
        // Basket basket = basketService.createBasket();
        UUID basketId = UUID.randomUUID();

        return ResponseEntity.created(URI.create("/" + basketId + "/summary"))
                .body(BasketView.builder()
                        .basketId(basketId)
                        .build());
    }

    @PostMapping("/{basketId}/summary")
    public ResponseEntity<BasketSummaryView> summary(@PathVariable UUID basketId,
                                                     @RequestBody BasketSummaryDto basketSummaryView) {
        BigDecimal summaryPrice = BigDecimal.TEN;
        return ResponseEntity.ok(BasketSummaryView.builder()
                .totalPrice(summaryPrice)
                .build());
    }
}

Uruchamiamy testy i tym razem są już zielone 👏. Spójrzmy na log ich wykonania. Testy preparują żądania do uruchomionej in-memory aplikacji i weryfikują, czy producer spełnia wymagania kontraktu.

Wynik działania Contract Verifier u Providera w Consumer-Driven Contract - wynik z testów

Consumer

Pora skorzystać z REST API używając kontraktu. W projekcie konsumenta definiujemy zależność stub-runner, która uruchomi realnie działający proces symulujący Providera na podstawie wymienionego kontraktu.

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-contract-stub-runner</artifactId>
    <scope>test</scope>
</dependency>

Aby skomunikować się z REST API użyjemy przykładowo webflux, ale może to być dowolna biblioteka do wykonywania żądań HTTP.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

Następnie piszemy kod kliencki, klasę BasketService, która skorzysta z REST API i stworzy koszyk zakupowy:

package pl.softwareskill.example.cdc.consumer;

import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.client.WebClient;

import java.util.UUID;

@RequiredArgsConstructor
public class BasketService {

    private final String basketServiceUrl;

    public UUID createBasket() {
        return WebClient.create(basketServiceUrl)
                .post()
                .uri("/basket")
                .contentType(MediaType.APPLICATION_JSON)
                .retrieve()
                .bodyToMono(BasketView.class)
                .block()
                .getBasketId();
    }
}

Świetnie, teraz należy pokryć użycie testem. Test zrobi następujące rzeczy:

  • Za pomocą Stub Runnera uruchomi osobny proces WireMock (na losowym porcie), który będzie odpowiadał zgodnie ze zdefiniowanym kontraktem.
  • Pobierze najświeższą wersję kontraktu.
package pl.softwareskill.example.cdc.consumer;

@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment= WebEnvironment.NONE, classes = BasketServiceTest.TestConfig.class)
@AutoConfigureStubRunner(ids = {"pl.softwareskill.example.cdc:provider"},
        stubsMode = StubRunnerProperties.StubsMode.LOCAL)
class BasketServiceTest {

    @Autowired
    BasketService basketService;

    @Test
    void createsNewBasket() {
        UUID newBasketId = basketService.createBasket();

        assertThat(newBasketId)
                .isNotNull();
    }

    @Configuration
    static class TestConfig {

        @StubRunnerPort("pl.softwareskill.example.cdc:provider")
        int basketServicePort;

        @Bean
        BasketService basketService() {
            return new BasketService("http://localhost:" + basketServicePort);
        }
    }
}

Do powyższego kodu kilka komentarzy:

  • @AutoConfigureStubRunner
    posiada parametr id, który określa koordynaty artefaktu, w którym znajdują się kontrakty, które zostaną automatycznie pobrane
  • @StubRunnerPort("pl.softwareskill.example.cdc:provider")
    zwraca port, dla którego został uruchomiony WireMock, na jego podstawie budowany jest URL do symulowanego REST API.

Chciałbym tutaj zaznaczyć, że losowanie wolnego portu jest bardzo dobrą praktyką, ponieważ:

  • Buildy Continuous Integration są bardziej stabilne, wybrany port nie jest zajęty.
  • Możemy uruchamiać kilka buildów jednocześnie – port nie jest zajmowany pomiędzy jobami Continuous Integration.
Wynik działania Stub Runner u Consumera w Consumer-Driven Contract

Zaglądając w logi testów, widzimy, że nasz kod wygenerował request do WireMocka, który odpowiedział zgodnie z kontraktem. Dzięki temu możemy sprawdzić kod Consumera, czy właściwie korzysta z API.

Wpis który czytasz to zaledwie fragment wiedzy zawartej w Programie szkoleniowym Java Developera od SoftwareSkill. Mamy do przekazania sporo usystematyzowanej wiedzy z zakresu kluczowych kompetencji i umiejętności Java Developera. Program składa się z kilku modułów w cotygodniowych dawkach wiedzy w formie video.

Więcej o Spring Cloud Contract

Garść informacji o Spring Cloud Contract:

  • Projekt jest stale rozwijany, warto obserwować kolejne wydania
  • Wspomaga CDC nie tylko dla REST API, ale również Messaging’u (np. JMS, EMS, Kafka, RabbitMQ)
  • Kontrakty można definiować w m.in. YAML, Groovy, Pact oraz w Java

Linki:

Piguła wiedzy o najlepszych praktykach testowania w Java

Pobierz za darmo książkę 100 stron o technikach testowania w Java

Podoba Ci się ten artykuł? Weź więcej.

Jeżeli uważasz ten materiał za wartościowy i chcesz więcej treści tego typu – nie przegap ich i otrzymuj je prosto na swoją skrzynkę. Nawiążmy kontakt.

.

Gdybyś potrzebował jeszcze więcej:

Jesteś Java Developerem?

Przejdź na wyższy poziom wiedzy 
„Droga do Seniora” 🔥💪

Jesteś Team Leaderem? Masz zespół?

Podnieś efektywność i wiedzę swojego zespołu 👌

Podsumowanie

Czym jest Consumer-Driven Contract

Consumer-Driven Contract to technika umożliwiająca wymienienie się kontraktem pomiędzy Consumerem i Producerem, zebranie wymagań każdego z nich i uruchamianie testów po obu stronach. Spring Cloud Contract to implementacja CDC, która pozwala na uruchomienie testów w izolacji. Pozwala to na spójne, zautomatyzowane przetestowanie założeń po obu stronach bez konieczności fizycznej integracji (posiadania uruchomionego środowiska testowego).

Czym jest Consumer, a czym Provider

Provider to dostawca API, a Consumer to klient korzystający API.

Jakie są dostępne implementacje Consumer-Driven Contract w Java

Obecnie na rynku są dostępne co najmniej dwa popularne rozwiązania: Spring Cloud Contract i Pact.

Obraz: Tło plik wektorowy utworzone przez iconicbestiary – pl.freepik.com

Dyskusja