Spring Cloud Contract – testowanie kontraktu jako sposób na utrzymanie stabilności systemu

Damian Jeleń

Architektura mikroserwisowa jest obecnie jednym z najpopularniejszych sposobów tworzenia systemów. Każdy programista w swojej codziennej pracy spotyka się częściej niż kiedykolwiek przedtem z jednym z najprostszych scenariuszy w świecie IT, czyli relacją producenta i konsumenta. Testowanie kontraktu jest dobrym sposobem na utrzymanie między nimi kompatybilności.

Wstęp

Producentem nazywamy serwis udostępniający API, a konsumentem serwis korzystający z tego API. Jak napisać test dla tej relacji? Jednym z podejść mogłoby być wdrożenie obu aplikacji i uruchomienie testów end-to-end. Rozwiązanie to jest jednak problematyczne, czasem nawet nierealne do przeprowadzenia, a na pewno dość czasochłonne, ponieważ trzeba czekać na uruchomienie obu aplikacji. Inną możliwością jest użycie biblioteki, np. Wiremock, i skonfigurowanie stuba – sztucznego serwera skonfigurowanego do wysyłania takiej samej odpowiedzi jak prawdziwy. To całkiem dobry pomysł i właśnie tak zazwyczaj robimy.

Problem

Nawet jeśli zaimplementowaliśmy tego rodzaju testy, nadal coś może pójść nie tak. Wyobraź sobie, że jesteś programistą po stronie konsumenta. Otrzymałeś właśnie wiadomość od developerów zajmujących się serwisem producenta, że wdrożyli oni zupełnie nowy endpoint /newEndpoint. Wszystkie wymagania są spełnione? Pora na implementowanie brakującego kodu i testy integracyjne ze spreparowanymi odpowiedziami.

Wszystko jest „zielone”, więc pora na wdrożenie Twojego serwisu. Wtedy pojawia się awaria. Być może powodem jest literówka w adresie url i zobaczyłeś „404”. Przyczyną może być też błąd w nazwie obiektu lub to, że ktoś zapomniał Ci powiedzieć o jeszcze jednym wymaganym polu.

Problem polega na tym, że stuby zostały zbudowane po stronie konsumenta i nie mają nic wspólnego z prawdziwym API. Z tego powodu nie możesz mieć pewności, że są one poprawne. Ponadto nie da się ich ponownie wykorzystać, więc każdy konsument musi powtórzyć tę samą pracę.

Rozwiązanie

Z pomocą przychodzi testowanie kontraktu, czyli pewnego rodzaju umowy między producentem a konsumentem dotyczącej tego, jak będzie wyglądać API / wiadomość. Kontrakt znajduje się po stronie producenta, a konsument pobiera go za każdym razem, gdy uruchamiane są testy. Z tego powodu, jeśli producent wprowadzi jakieś zmiany w istniejącym API, następna kompilacja po stronie konsumenta zakończy się niepowodzeniem i poinformuje programistę o wystąpieniu zmian oraz koniecznej ingerencji.

Przykład

Zobaczmy, jak to działa na przykładzie. Zbudowałem dwa proste serwisy – producenta, który udostępnia endpoint zwracający film oraz konsumenta, który korzysta z tego endpointu.

Producent

Zaczynając od strony producenta, potrzebujemy dwóch rzeczy w PoM – zależności dla Spring Cloud Contract oraz pluginu, który wygeneruje dla nas testy. To drugie umożliwia kilka frameworków, np. JUnit czy Spock.

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

<plugin>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-contract-maven-plugin</artifactId>
    <version>3.1.5</version>
    <extensions>true</extensions>
    <configuration>
        <testFramework>JUNIT5</testFramework>
        <baseClassForTests>com.example.contract.BaseTestClass</baseClassForTests>
    </configuration>
 </plugin>

Jak widać w konfiguracji wtyczki, musisz określić baseClassForTests. Opowiem o tym nieco później.

Potrzebujesz klasy modelowej Movie. Dodałem tutaj adnotacje lombok, które utworzą gettery, settery i konstruktor.

Data
@AllArgsConstructor
public class Movie {
    private Long id;
    private String title;
}

Następną potrzebną klasą jest MovieService.

@Service
public class MovieService {
 private final Map<Long, Movie> movieMap;
    public MovieService() {
        movieMap = new HashMap<>();
        movieMap.put(1L, new Movie(1L, "Kiler"));
        movieMap.put(2L, new Movie(2L, "Into the wild"));
    }
    Movie findMovieById(Long id) {
        return movieMap.get(id);
    }
}

Ponieważ naszym celem jest tylko stworzenie samouczka, magazynem będzie wyłącznie kolekcja HashMap. Następnie zastosujemy prostą metodę, która zwraca Movie według podanego id. Wstawimy też endpoint, z którego będzie korzystał konsument.

@RestController
public class MovieController {
    private final MovieService movieService;

    public MovieController(MovieService MovieService) {
        this.movieService = MovieService;
    }

    @GetMapping("movie/{id}")
    public Movie findMovieById(@PathVariable("id") Long id) {
        Movie movie = movieService.findMovieById(id);
        if (movie == null) {
            throw new ResponseStatusException(
                    HttpStatus.NOT_FOUND, "Not Found"
            );
        }
        return movie;
    }
}

Pora na prosty kontroler wywołujący MovieService i zwracający Movie, jeśli zostanie on znaleziony, albo błąd „404”, jeśli się to nie uda.

Definiowanie kontraktu

Teraz najważniejsza część – musisz zdefiniować kontrakty. W tym celu możesz użyć kilku różnych języków. Ja wybrałem Groovy, ponieważ uważam go za najbardziej intuicyjny i czytelny. Pliki należy umieścić w katalogu src/test/resources/contracts. Stworzyłem dwa kontrakty – jeden dla prawidłowej ścieżki, a drugi dla błędnej.

  • Kontrakt dla prawidłowej ścieżki
import org.springframework.cloud.contract.spec.Contract

Contract.make {
    description "should return movie with id=1"

    request {
        method 'GET'
        url '/movie/1'
    }

    response {
        status OK()
        headers {
            contentType applicationJson()
        }
        body (
                id: 1,
                title: "Movie1"
        )
    }
}

W kontrakcie musisz zdefiniować, jak powinno wyglądać żądanie oraz odpowiedź na nie. Jak to zrobić? Możesz napisać, że jeśli nadejdzie żądanie z metodą GET na url /movie/1, odpowiedzią powinno być OK (czyli kod HTTP 200) posiadające w nagłówku określony content-type applicationJson. Jednocześnie ciało powinno się składać z dwóch zmiennych z określonymi wartościami.

  • Kontrakt dla błędnej ścieżki
Contract.make {
    description "should return 404 for movie with id=3"

    request {
        url "/movie/3"
        method GET()
    }

    response {
        status 404
    }

}

Drugi kontrakt dotyczy scenariusza kończącego się niepowodzeniem. W sytuacji, gdy żądanie będzie dotyczyć filmu z id = 3, zwrócony zostanie status „404 NOT FOUND”.

Generowanie stubów

Wróćmy teraz do właściwości baseClassForTests w konfiguracji wtyczki. Jest to klasa bazowa, która załaduje kontekst Springa. Będą z niej dziedziczyć testy generowane przez Spring Cloud Contract.

@SpringBootTest(classes = ProducerApplication.class)
public abstract class BaseTestClass {

    @Autowired
    MovieController movieController;

    @MockBean
    MovieService movieService;

    @BeforeEach
    public void setup() {
        RestAssuredMockMvc.standaloneSetup(movieController);
        Mockito.when(movieService.findMovieById(1L))
                .thenReturn(new Movie(1L, "Movie1"));
        Mockito.when(movieService.findMovieById(2L))
                .thenReturn(new Movie(2L, "Movie2"));
    }
}

W naszej klasie bazowej wstrzykniemy MovieController oraz zamockujemy odpowiedzi z MovieService.

Teraz jesteś gotowy do wygenerowania stubów, z których będzie korzystał konsument. Aby to zrobić, musisz zbudować projekt za pomocą polecenia mvn clean install. Po przeanalizowaniu wpisów w konsoli możesz dostrzec, że utworzone zostały: klasa testowa ContractVerifierTest, stuby w formacie JSON, a także plik JAR z tymi stubami, który wypchnięty został do lokalnego repozytorium Maven. Zobacz:

[INFO] Installing C:\Users\...\producer\target\producer-0.0.1-SNAPSHOT-stubs.jar to C:\Users\...\.m2\repository\com\example\producer\0.0.1-SNAPSHOT\producer-0.0.1-SNAPSHOT-stubs.jar

Teraz plik JAR ze stubami jest gotowy do wypchnięcia na zdalne repozytorium. Dzięki temu będą mogły z niego korzystać zewnętrzne usługi. W tym tutorialu użyję jednak lokalnego repozytorium Maven.

public class ContractVerifierTest extends BaseTestClass {

 @Test
 public void validate_find_movie_by_id1() throws Exception {
  // given:
   MockMvcRequestSpecification request = given();

  // when:
   ResponseOptions response = given().spec(request)
     .get("/movie/1");
  // 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("['title']").isEqualTo("Movie1");
 }

 @Test
 public void validate_find_movie_by_id3() throws Exception {
  // given:
   MockMvcRequestSpecification request = given();

  // when:
   ResponseOptions response = given().spec(request)
     .get("/movie/3");

  // then:
   assertThat(response.statusCode()).isEqualTo(404);
 }
}

Tak wygląda wygenerowana klasa testowa. Od tej pory każdy build będzie uruchamiał testy sprawdzające, czy kontrakt został spełniony.

Konsument

Po stronie konsumenta potrzebujemy tej samej zależności Spring Cloud Contract, ale wtyczka nie jest już potrzebna. Kolejną przydatną rzeczą jest ta sama klasa modelu Movie i klasa kontrolera z endpointem, który wywoła usługę producenta, a następnie zwróci film do klienta.

@RestController
public class MovieConsumerController {

  @Value( "${producer.port}" )
     private Integer producerPort;

     private final RestTemplate restTemplate;

     MovieConsumerController(RestTemplateBuilder restTemplateBuilder) {
         this.restTemplate = restTemplateBuilder.build();
     }

     @RequestMapping("/borrowMovie/{id}")
     String getMessage(@PathVariable("id") Long movieId) {

         HttpHeaders headers = new HttpHeaders();
         HttpEntity<Void> requestEntity = new HttpEntity<>(headers);

         ResponseEntity<Movie> response = 
         restTemplate.exchange("http://localhost:"+producerPort+
         "/movie/{id}",HttpMethod.GET, requestEntity, Movie.class, movieId);
         Movie movie = response.getBody();

         return "Here is your movie with title: " + movie.getTitle();
     }
}

Jak widać, jest tam endpoint borrowMovie. Za pomocą RestTemplate wywołuje on usługę producenta, aby pobrać żądany film. Teraz jesteś gotowy do napisania testu, który użyje kontraktu producenta zawartego w wygenerowanym pliku JAR.

Pisanie testu

@SpringBootTest
@AutoConfigureStubRunner(ids = {"com.example:producer:+:stubs:9000"}, stubsMode = StubsMode.LOCAL)
public class ContractIntegrationTest {

 @Test
    public void get_hat1() {
        RestTemplate restTemplate = new RestTemplate();
        ResponseEntity<Movie> responseEntity = 
            restTemplate.getForEntity("http://localhost:9000/movie/1", Movie.class);
        assertThat(responseEntity.getStatusCodeValue()).isEqualTo(200);
        Movie movie = responseEntity.getBody();
        assertThat(movie.getId()).isEqualTo(1);
        assertThat(movie.getTitle()).isEqualTo("Movie1");
    }

 @Test
    public void get_hat3() {
        try {
            RestTemplate restTemplate = new RestTemplate();
            ResponseEntity<Movie> responseEntity = 
                restTemplate.getForEntity("http://localhost:9000/movie/3", Movie.class);
        }
        catch (HttpClientErrorException ex) {
            assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
        }
    }

}

Najważniejsza jest tutaj adnotacja AutoConfigureStubRunner. Najpierw definiujesz projekt, którego chcesz użyć: com.example:producer. Następnie za pomocą znaku „+” określasz, że powinna zostać pobrana najnowsza wersja. Kolejno definiujesz, że powinien zostać użyty JAR ze stubami, a zadaniem StubRunnera jest uruchomienie serwera Wiremock na porcie 9000. Na samym końcu określasz, że stuby powinny być pobierane z lokalnego repozytorium. To wszystko – jesteś gotowy do uruchomienia testów.

Uruchomienie testów

Analizując wynik konsolowy uruchomionego testu, widzisz, że Spring pobrał plik JAR ze stubami producenta. Uruchomił też serwer na porcie 9000 z mapowaniami pobranymi z zaczytanego pliku.

INFO 2568 --- [main] o.s.c.c.stubrunner.AetherStubDownloader  : Resolved artifact [com.example:producer:jar:stubs:0.0.1-SNAPSHOT] to C:\Users\...\.m2\repository\com\example\producer\0.0.1-SNAPSHOT\producer-0.0.1-SNAPSHOT-stubs.jar

INFO 2568 --- [main] o.s.c.contract.stubrunner.StubServer     : Started stub server for project [com.example:producer:0.0.1-SNAPSHOT:stubs] on port 9000 with [2] mappings
INFO 2568 --- [main] o.s.c.c.stubrunner.StubRunnerExecutor    : All stubs are now running RunningStubs [namesAndPorts={com.example:producer:0.0.1-SNAPSHOT:stubs=9000}]

Wszystko działa tak, jak powinno. Teraz zobacz, co się stanie, gdy jakiś programista wpadnie na pomysł, żeby zmienić pole title na movieTitle.

@Data
@AllArgsConstructor
public class Movie {
 private Long id;
    private String movieTitle;
}

Po zmianie klasy Movie projekt jest gotowy do kompilacji.

Kompilacja

[ERROR] validate_find_movie_by_id1  Time elapsed: 0.754 s  <<< ERROR!
java.lang.IllegalStateException: Parsed JSON [{"id":1,"movieTitle":"Movie1"}] doesn't match the JSON path [$[?(@.['title'] == 'Movie1')]]
 at com.example.contract.ContractVerifierTest.validate_find_movie_by_id1(ContractVerifierTest.java:36)

Kompilacja zakończyła się niepowodzeniem. Jak pisałem wcześniej, wygenerowane testy pilnują aplikacji, aby wypełniała zaimplementowany kontrakt. Aby kompilacja mogła zostać pomyślnie zakończona, musisz poprawić definicję kontraktu. Kiedy to zrobisz, zaktualizowany JAR zostanie wypchnięty do repozytorium. Gdy natomiast programista po stronie konsumenta zbuduje projekt, zostanie pobrany nowy stub i testy zakończą się sukcesem. Dzięki takiemu mechanizmowi programista po stronie konsumenta zawsze będzie świadomy, że zaszły pewne zmiany w kontrakcie. Będzie to sygnał, że jego kod musi zostać zaktualizowany zgodnie z nimi.

Oto dane wyjściowe konsoli z testu po stronie konsumenta, gdy został użyty nowy stub ze zmianą movieTitle.

org.opentest4j.AssertionFailedError: 
expected: "Movie1"
 but was: null
Expected :"Movie1"
Actual   :null

Wartość to null. Dlaczego? Test sprawdza wartość zmiennej title, a ona już nie istnieje. Kod i testy wymagają więc poprawy.

Podsumowanie

Na tym prostym przykładzie pokazałem, jak testowanie kontraktu może pomóc w utrzymaniu kompatybilności między producentem a konsumentem. To także sposób, aby uchronić nas przed awarią w środowisku produkcyjnym lub testowym.

Poznaj mageek of j‑labs i daj się zadziwić, jak może wyglądać praca z j‑People!

Skontaktuj się z nami