Stabilizacja testów z wykorzystaniem rozbudowanego WireMocka

24.04.2019 | Mateusz Beczek

Wstęp

Kiedy mówimy o testowaniu naszych aplikacji, każdy z Was kojarzy pewnie problem związany z dostępnością wszystkich systemów, z którymi nasza aplikacja się komunikuje. Nie zawsze mamy możliwość powołania w ramach testów całego ekosystemu (np. z wykorzystaniem Dockera i Kubernetesa) lub, co gorsza, pracujemy nad legacy code, w którym ciężko o prawidłową strukturę testów. Jedynymi testami, które są wykonywane, są żmudne testy E2E (end-to-end). Możemy dorzucić niestabilne środowisko i … klęska gotowa. Żadna automatyzacja nie poprawi sytuacji, jeśli wyniki testów są niedeterministyczne. Nikomu nie chce się robić testów, które migoczą jak choinka.

Pewnie pierwsze co zaproponowałaby większość z Was to testy jednostkowe. O ile w przypadku (mikro)serwisów wydaje się to oczywiste, tak w już zaawansowanym projekcie z długiem technologicznym nie poprawi to sytuacji od razu. Nie oszukujmy się: wprowadzenie kompletnych testów jednostkowych (może funkcjonalnych?) to często potężna liczba roboczogodzin, której najzwyczajniej w świecie nie posiadamy. Teoria swoje, praktyka swoje*.

W aplikacjach, z którymi miałem do czynienia, bywało, że jedynymi, kompletnym testami były testy automatyczne E2E z wykorzystaniem np. Selenium. W ramach jednego scenariusza biznesowego aplikacja wołała kilkanaście różnych API. Jakakolwiek niedostępność rozsypywała testy, lokalnie czy na środowisku. Padło pytanie: czy jesteśmy w stanie ustabilizować testy poprzez odcięcie się od niedziałających API?

* jestem zwolennikiem TDD, kontraktów i wszelkich dobrych praktyk, jednak czysto purystyczne podejście czasem jest… niewykonalne :)

Zarys sytuacji

Załóżmy taką architekturę: wszystkie aplikacje w naszym ekosystemie komunikują się z wykorzystaniem REST API. Dorzucimy do tego Consula jako nasze Service Discovery oraz Keycloak jako bazę wiedzy, kto z kim może się komunikować. Status aplikacji jest rejestrowany w Consulu poprzez health check, dzięki czemu wiemy, czy i który host jest w stanie obsłużyć nasze żądanie.

Architektura wygląda dość prosto:

  1. Klient aktualizuje listę endpointów przypisaną do wybranego przez nas dostawcy.
  2. Klient pyta Keycloaka o token w celu autoryzacji.
  3. Klient wysyła kompletne żądanie do dostawcy.

Niestety, w takiej sytuacji możemy napotkać na kilka problemów:

  • Co się stanie, jeśli provider nie zarejestrował się w Consulu?
  • Co się stanie, jeśli provider będzie zarejestrowany w Consulu, ale żądania kończą się timeoutem?

WireMock for the win!

Pomysł z rozszyciem komunikacji i wstawieniem w środek WireMocka w tym wypadku załatwił sprawę. WireMock to świetna, open-sourcowa biblioteka do zaślepiania komunikacji HTTP. Na podstawie przychodzących do serwera żądań możemy zwrócić konkretną odpowiedź, szytą na miarę. Może być uruchomiona zarówno jako osobna usługa jak i wpleciona w testy. Stuby (lub mappingi), czyli predefiniowane odpowiedzi można wysyłać poprzez API, wrzucać na dysk lub definiować z kodu, np. w JUnit. Najlepsze jednak jest to, że w bardzo prosty sposób da się rozszerzać – i to wykorzystaliśmy.

WireMock ma bardzo wdzięczną funkcjonalność, pozwalającą na przekazywanie ruchu dalej, niekoniecznie tam, gdzie było to wstępnie przewidziane. Dzięki temu do testów nasz poprzedni schemat nieco ulega zmianie:

Teraz wygląda to tak:

  1. Tak jak poprzednio, klient pobiera token z Keycloaka.
  2. Klient wysyła żądanie do WireMocka.
  3. WireMock czyni magię :)

Na czym polega ta magia? W WireMocku tak naprawdę jest podejmowana decyzja, co zrobić z naszym żądaniem. To, czy należy przesłać dalej czy zwrócić predefiniowaną odpowiedź w oparciu o listę przełączników, pingowanie dostawcy – wszystko zależy od danego przypadku. My zdecydujemy się na sterowanie tym z zewnątrz i przechowywanie informacji o przełącznikach w WireMocku. Na cele artykułu będzie to baaaaaardzo prosta i naiwna implementacja :)

Jak się do tego dobrać? WireMock pozwala na rozszerzanie klas:

  • transformujących definicje odpowiedzi, tj. ResponseDefinitionTransformer,
  • transformujących gotowe odpowiedzi, tj. ResponseTransformer,
  • transformujących dodawane stuby, tj. StubMappingTransformer,
  • weryfikujących trafność dopasowania żądania do nagrania, tj. RequestMatcherExtension,
  • nasłuchujących żądań, tj. RequestListener.

Specyfikacja

W tym przykładzie wykorzystam:

  • Java 8,
  • Wiremock 2.18.0,
  • com.orbitz.consul:consul-client (klient do Consula).

Z premedytacją pomijam dokładniejszą konfigurację Consula i Keycloaka, zakładając, że działają gdzieś w naszym ekosystemie, a my znamy do nich URL. Jedynie wymagamy instalację agenta Consula na lokalnej stacji pod konkretnym portem.

We must go deeper

Aby rozwiązać wspomniany wcześniej problem, wykorzystamy ResponseDefinitionTransformer, w oparciu o taki szkielet klasy:

public class ConsulTransformer extends ResponseDefinitionTransformer { 
	public static final String TRANSFORMER_NAME = "ConsulTransformer";
	SwitchService switchService;

	public ConsulTransformer(SwitchService switchService){
	    this.switchService=switchService;
	}

	@Override
	public boolean applyGlobally(){
	    return true;
	}

	@Override
	public String getName() {
	    return TRANSFORMER_NAME;
	}

	@Override
	public ResponseDefinition transform(Request request, ResponseDefinition responseDefinition, FileSource files, Parameters parameters) {
	// magic stuff!
	}
}

Co tu się stało? Po kolei:

  • rozszerzamy klasę ResponseDefinitionTransformer,
  • przyjmujemy, że będziemy sterować zachowaniem transformera na podstawie mapy w pamięci, która będzie przechowywać informacje: API URL (String) oraz flagę informującą o tym, czy zaślepiamy odpowiedź (true dla zaślepiania, false dla proxy); mapę dostarczy klasa pod interfejsem SwitchService,
  • metoda applyGlobally zwraca informację, czy wszystkie żądania trafiające do WireMocka mają być przetworzone przez ten transformer (true) czy tez nie (false),
  • metoda getName zwraca nazwę transformera – możemy ją wpisać w naszym stubie, żeby wprost zaznaczyć, że chcemy tego użyć. Jeśli applyGlobally będzie zwracać false, musimy to wpisywać w stubie,
  • metoda transform – najważniejsza metoda klasy, gdzie dzieją się czary.

Do realizacji zadania potrzebujemy na tą chwilę wyłącznie dostępu do żądania i definicji odpowiedzi. Na początek metody transform wrzucimy taki fragment:

@Override
public ResponseDefinition transform(Request request, ResponseDefinition responseDefinition, FileSource files, Parameters parameters) {
	boolean switchOn = checkIfMocked(request.getUrl());
	...
}

Pod metodą checkIfMocked kryje się, wspomniana przeze mnie, naiwna implementacja:

private boolean checkIfMocked(String url) { 
    for(Map.Entry<String,Boolean> entry : switchService.getSwitchMap().entrySet()){
        if(url.startsWith(entry.getKey())){
            return entry.getValue();
        }
    }
    return false;
}

Iterujemy po naszej mapie (zasilonej z zewnątrz: z pliku lub innego API) z klasy SwitchService i sprawdzamy, czy trafiliśmy z nazwą API. Zwracamy flagę, jeśli nie znajdziemy naszego API domyślnie uznajemy, że trzeba żądanie przepchnąć dalej. Przykładowy stan naszej mapy może wyglądac tak:

  • /myapp = true
  • /mysecondapp =false

W tym momencie wysłanie żądania pod adres /myapp/getPerson spowoduje zwrócenie odpowiedzi nagranej, a /mysecondapp/getState zwróci odpowiedź rzeczywistą.

Lecimy dalej:

if (switchOn) {
    return ResponseDefinitionBuilder.like(responseDefinition).withHeader("mock","true").build();
} else {
    String url;
    try {
        String apiname = request.getHeaders().getHeader("Host").firstValue();
        url = getServiceEndpoint(apiname);
        return ResponseDefinitionBuilder.responseDefinition()
                .proxiedFrom(url)
                .build();
    } catch (ConsulServiceUnavailableException e) {
		// LOGGER.error(e);
        return new ResponseDefinitionBuilder().withHeader("Content-Type", "application/json").withBody("{\"error\":\"ConsulServiceUnavailable: "+e.getMessage()+"\"}").withStatus(500).build();
    }
}

Jeśli API jest ustawione na tryb mock, to WireMock dorzuca nam do istniejącego stuba nagłówek “mock” – później będziemy w stanie rozróżnić nasze odpowiedzi. Jeśli ustawiony jest tryb proxy, to wyciągamy informację o adresach usług. W naszym przypadku wyciągamy tą informację z nagłówka Host. Następnie przekazujemy do metody getServiceEndpoint, która prezentuje się następująco:

public String getServiceEndpoint(String serviceName) throws ConsulServiceUnavailableException {
      Consul consul;
      try {
         consul = Consul.builder().withHostAndPort(HostAndPort.fromParts("localhost", 48500)).build();
      } catch (ConsulException e) {
         throw new ConsulServiceUnavailableException(e.getMessage(), e);
      }
      HealthClient healthClient = consul.healthClient();
      List<ServiceHealth> nodes = healthClient.getHealthyServiceInstances(serviceName).getResponse();
      if(nodes==null || nodes.size()==0){
          throw new ConsulServiceUnavailableException("No nodes found for API: "+serviceName);
      }
      ServiceHealth node = nodes.stream().findAny().get();
      String protocol = node.getService().getTags().contains("proto-https") ? "https" : "http";
      String endpoint = protocol + "://" + node.getService().getAddress() + ":" + node.getService().getPort();
      return endpoint;
  }

Jeśli lokalny agent Consula nie działa lub wybrana przez nas usługa nie odpowiada, zwracamy odpowiedni wyjątek. W przeciwnym razie z metody zwracamy interesujący nas adres (zarówno protokół, nazwę hosta jak i port, wszystko z Consula).

Wykorzystanie w praktyce

Jeśli chcemy wykorzystać nasze rozwiązanie w ramach testów JUnita (np. w ramach pipelines czy w Jenkinsie), powołujemy do życia naszego WireMocka w ten sposób:

@Before
public void setup(){
    wireMockServer = new WireMockServer(
            options().port(8041).extensions(new ConsulTransformer(switchService)));
    wireMockServer.start();
}

Zaślepianie naszych API może być wykonane na dwa sposoby. WireMock DSL jest elastyczną metodą tworzenia mappingów z filtrowaniem, tworzeniem nagłówków, ciała odpowiedzi itp. prosto z kodu:

wireMockServer.stubFor(
        get(urlMatching("/nonexisting/nonexistingapi"))
                .willReturn(
                        aResponse().withBody("{\"myfield\":\"myvalue\"}")
                )
);

Inną opcją jest wrzucenie pliku JSON z naszym mappingiem na dysk lub wysłać poprzez REST API:

{
  "name" : "myapp_getPerson",
  "request" : {
    "url" : "/myapp/getPerson",
    "method" : "GET",
    "bodyPatterns" : [ ]
  },
  "response" : {
    "status" : 200,
    "body" : "{\"name\":\"John\",\"surname\":\"Smith\",\"birthdate\":\"1990-01-01\"}",
    "headers" : {
      "Content-Type" : "application/json"
    }
  }
}

Zamiast tworzyć instancje w kodzie testów możemy wrzucić ten kod do SpringBoota i odpalić to gdziekolwiek, np. na stacji roboczej lub w sieci. Przydaje się to, gdy wiele osób korzysta z jednej instancji WireMocka lub wpinamy działającą aplikację pod testy E2E.

Przetestujmy się

Pora przetestować nasze rozwiązanie. Mamy działającą aplikację, która korzysta z kilku API. Jedno z nich, /myapp/getPerson przestało odpowiadać. Możemy w tym momencie przełączyć naszą flagę i zasilić WireMocka.

Strzał z wykorzystaniem Postmana pod adres naszego WireMocka daje taki efekt:

W zakładce Headers możemy podejrzeć, że ta odpowiedź faktycznie pochodzi z nagranej odpowiedzi:

Podsumowanie

WireMock jest wygodnym narzędziem do zaślepiania naszych API. Oprócz swoich funkcji, takich jak nagrywanie, filtrowanie czy generowanie odpowiedzi umożliwia na tworzenie swoich własnych rozwiązań, w zależności od potrzeb. Podobną, opakowaną przez nas wersję używamy do testów funkcjonalnych w izolacji.

Główną zaletą tego rozwiązania jest przezroczystość komunikacji REST dla developerów. Kiedy nasze środowisko działa, nasz rozbudowany WireMock przerzuci cały ruch tam, gdzie trzeba. Kiedy tylko coś przestanie odpowiadać możemy przełączyć flagę i wymusić na WireMocku zwracanie gotowych odpowiedzi. Z drugiej strony możemy pracować zawsze w izolacji, odcinając się od ruchu lub korzystać z jeszcze nieutworzonego API, zaślepiając jego odpowiedzi nim jeszcze gdzieś zostało wrzucone. To dobry krok w stronę kontraktów, ale o tym innym razem… :)

Między innymi o tej formie rozszerzania WireMocka jak i o innych, równie przydatnych, będę opowiadał na Quality Excites 2019, na które serdecznie zapraszam :)