Wstęp
W maju zeszłego roku opisywałem, jak odcinam się od cudzego API WireMockiem w kontenerze. Ten wpis kończył się akapitem, który dziś czytam jak notatkę do samego siebie: napisałem tam, że stub to jest moje wyobrażenie o tym, jak partner odpowiada, i że jeżeli partner zmieni pole price na grossPrice, to moje testy będą zielone, a produkcja nie. Zapowiedziałem wtedy, że contract testing w stylu Pacta zostawiam poza tamtym wpisem.
Wracam do tego dzisiaj, bo problem przestał być teoretyczny. Scenariusz, który przerabiam co kilka miesięcy, wygląda zawsze tak samo. Zestaw testów integracyjnych jest zielony, bo uderza w stuby. Deploy na środowisko wspólne pada, bo dostawca zmienił kształt odpowiedzi. Idę do logów, potem do repozytorium dostawcy, potem na czacie ktoś mówi, że to była “drobna zmiana, kompatybilna wstecz”. Kompatybilna wstecz z jego perspektywy, bo on nie wie, których pól używam. Nie wie, bo nigdzie tego nie zapisałem w formie, którą jego pipeline mógłby przeczytać.
Tego brakuje między stubem a integracją. Stub izoluje mnie od niestabilności dostawcy, ale nie mówi dostawcy niczego o moich oczekiwaniach. Consumer-driven contracts to jest dokładnie ta brakująca połowa: ja jako konsument zapisuję, czego potrzebuję, a dostawca odtwarza to u siebie na CI i dowiaduje się, że złamał kontrakt, zanim ja się o tym dowiem z produkcji.
Wersje, których trzymam się w całym wpisie: .NET 6, PactNet 4.1.0, xUnit 2.4.1. Demo jest syntetyczne, dwa serwisy na localhost: konsument Order API i dostawca Inventory API. Żadnych prawdziwych payloadów i żadnych nazw z projektów.
Consumer-driven contracts nie są nowym frameworkiem
Zanim wejdę w kod, jedna rzecz, którą warto powiedzieć wprost, bo w rozmowach ciągle wraca w błędnej wersji. Contract testing nie jest wynalazkiem tego roku i Pact nie jest nowością 2022.
Sam pomysł opisał Ian Robinson w artykule Consumer-Driven Contracts w czerwcu 2006 roku, czyli szesnaście lat temu. Pact jako implementacja tego pomysłu powstał w 2013 roku w realestate.com.au przy udziale DiUS i od tego czasu obrósł w implementacje dla kilkunastu języków. Jeżeli ktoś w 2019 roku powiedział, że próbował Pacta na .NET i było to nieprzyjemne, to prawdopodobnie ma rację i nie zmyśla.
Nowe jest coś innego i tylko o to mi chodzi w tym wpisie: warstwa .NET-owa. Linia PactNet 4.x to przepisanie biblioteki na rdzeń w Ruście zamiast starego rdzenia w Rubym. 4.0.0 wyszło 6 czerwca, a 4.1.0 dwa dni temu, 13 lipca. To jest ta zmiana, przez którą po roku wracam do tematu, który wcześniej odłożyłem.
Warto też wiedzieć, że są dwie szkoły i one rozwiązują trochę inny problem. Szkoła interaction-first, czyli Pact: kontrakt powstaje z testów konsumenta, więc opisuje wyłącznie te pola i te ścieżki, których konsument faktycznie używa. Szkoła spec-first, czyli na przykład Specmatic z OpenAPI: kontraktem jest specyfikacja, a narzędzie robi z niej test i stub. Pierwsza mówi “sprawdź, czy nadal dowozisz to, na czym mi zależy”. Druga mówi “sprawdź, czy jesteś zgodny ze specyfikacją, którą oboje uzgodniliśmy”. Na .NET idę Pactem, bo mam konsumenta i mam jego testy, a nie mam wynegocjowanego OpenAPI, którego obie strony faktycznie pilnują.
WireMock zostaje, kontrakt dochodzi
To nie jest wybór “albo stub, albo kontrakt”. Te dwie rzeczy odpowiadają na różne pytania i u siebie trzymam obie.
WireMock odpowiada na pytanie “jak przetestować mój kod, kiedy dostawcy nie ma pod ręką albo nie umiem u niego wywołać 503”. Sterowanie odpowiedzią jest w moich rękach, łącznie z opóźnieniem, pustym body i błędem. To jest jego siła i jednocześnie jego ograniczenie, bo skoro odpowiedź układam ja, to zgodność z rzeczywistością jest kwestią mojej pamięci.
Kontrakt odpowiada na pytanie “skąd dostawca ma wiedzieć, że to, czego oczekuję, nadal działa”. Te same oczekiwania, które w stubie zostawały w moim repozytorium, lecą tutaj do pipeline’u dostawcy i tam są odtwarzane przeciwko prawdziwej implementacji.
Praktyczny podział, który u mnie działa, jest taki. Ścieżki błędów, timeouty i dziwne stany zostają w WireMocku, bo dostawca nie ma obowiązku umieć ich odtworzyć na żądanie i kontrakt na “timeout po trzech sekundach” jest bez sensu. Kształt szczęśliwej ścieżki i tych kilku wariantów, które naprawdę obsługuję w kodzie, idzie do kontraktu. Kontrakt nie zastępuje też prawdziwej bazy podnoszonej z testu. Kontener z bazą jest o tym, czy mój własny kod poprawnie zapisuje dane. Kontrakt jest o tym, czy dwa serwisy nadal się rozumieją. To są dwie różne warstwy i mieszanie ich kończy się testem, który jest jednocześnie wolny i nieprecyzyjny.
PactNet 4 na .NET 6
Zaczynam od projektu testowego po stronie konsumenta. Zestaw pakietów jest krótszy niż rok temu i to jest pierwsza dobra wiadomość:
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
<PackageReference Include="PactNet" Version="4.1.0" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" />
</ItemGroup>Jeden pakiet PactNet, bez wariantów per system operacyjny. W linii 3.x, na której ostatnia wersja to 3.0.2 z października zeszłego roku, trzeba było doinstalować PactNet.Windows albo odpowiednik dla swojego systemu. Teraz natywna biblioteka jedzie w jednym NuGecie, dlatego ten pakiet waży ponad dwadzieścia megabajtów. To jest cena, którą płacę raz przy restore.
Co jeszcze zmieniło się w 4.0.0 i jest odczuwalne w codziennej pracy:
- Mock server działa w procesie testu, więc nie ma już zostawionych procesów
ruby.exepo przerwanym przebiegu. Kto to przerabiał, ten wie, ile czasu potrafi zjeść szukanie, dlaczego port jest zajęty. - Port przydziela się sam. Nie muszę go rezerwować ani pilnować, żeby dwa projekty testowe na jednym agencie nie weszły sobie w drogę.
- Specyfikacja to Pact v3, a nie v2. Praktycznie oznacza to między innymi listę stanów dostawcy zamiast pojedynczego stringa.
- API jest fluentowe i buduje się je per test, a nie raz na klasę.
W 4.1.0 doszły dwie rzeczy, obie wąskie, ale warto o nich wiedzieć: obsługa body innych niż JSON w request i response oraz możliwość publikowania wyników weryfikacji dla źródeł typu URI. Pierwsza przydaje się, kiedy dostawca oddaje na przykład zwykły tekst albo CSV.
Jest jedno ograniczenie, o którym trzeba wiedzieć przed pierwszym uruchomieniem na CI, bo inaczej odkryję je w najgorszym momencie. Skoro rdzeń jest natywną biblioteką, to lista wspieranych platform jest skończona: Windows x64, Linux x64 z glibc oraz macOS x64. Linux na musl, czyli obrazy oparte o Alpine, nie zadziała. Mac na ARM w tej chwili też nie. Jeżeli więc buduję w kontenerze alpine, to albo zmieniam obraz agenta na oparty o Debiana, albo nie uruchamiam tam testów kontraktowych.
Teraz test konsumenta. Order API pyta Inventory API o stan magazynowy dla konkretnego SKU:
public class InventoryApiConsumerTests
{
private readonly IPactBuilderV3 _pact;
public InventoryApiConsumerTests(ITestOutputHelper output)
{
var config = new PactConfig
{
PactDir = Path.Combine("..", "..", "..", "..", "pacts"),
LogLevel = PactLogLevel.Information,
Outputters = new[] { new XUnitOutput(output) }
};
IPactV3 pact = Pact.V3("Order API", "Inventory API", config);
_pact = pact.UsingNativeBackend();
}
[Fact]
public async Task PobranieStanuMagazynowego_GdySkuIstnieje_ZwracaDostepnaIlosc()
{
_pact
.UponReceiving("zapytanie o stan magazynowy istniejacego SKU")
.Given("pozycja magazynowa o sku ABC-1 istnieje")
.WithRequest(HttpMethod.Get, "/api/inventory/ABC-1")
.WithHeader("Accept", "application/json")
.WillRespond()
.WithStatus(HttpStatusCode.OK)
.WithHeader("Content-Type", "application/json; charset=utf-8")
.WithJsonBody(new
{
sku = Match.Type("ABC-1"),
quantity = Match.Integer(12),
warehouse = Match.Regex("WA-1", "WA-\\d+")
});
await _pact.VerifyAsync(async ctx =>
{
var client = new InventoryClient(ctx.MockServerUri);
InventoryItem item = await client.GetAsync("ABC-1");
Assert.Equal(12, item.Quantity);
});
}
}Kilka rzeczy w tym kodzie jest ważniejszych, niż wygląda.
Konstruktor buduje IPactBuilderV3 dla każdego testu osobno i tak ma być. W linii 3.x mock server trzeba było postawić raz przez IClassFixture i czyścić interakcje między testami. Teraz serwer startuje w momencie wywołania VerifyAsync i gaśnie, kiedy VerifyAsync wraca. Cała rozmowa z API musi się więc odbyć wewnątrz tej lambdy, z adresem wziętym z ctx.MockServerUri. Jeżeli klient złapie adres i spróbuje strzelić po wyjściu z lambdy, dostanie odmowę połączenia. To jest zresztą przyjemna konsekwencja: domyślnej izolacji instancji w xUnit nie trzeba tu niczym nadwyrężać, bo po stronie konsumenta nie ma już żadnego współdzielonego zasobu, który trzeba by wsadzić do kolekcji.
Matchery to jest sedno, a nie ozdobnik. Match.Type("ABC-1") znaczy “oczekuję stringa w tym miejscu”, a nie “oczekuję dokładnie ABC-1”. Gdybym wpisał surową wartość, kontrakt wymagałby od dostawcy zwrócenia dokładnie tej wartości, a to jest asercja o danych, nie o kontrakcie. Match.Integer(12) jest ostrzejsze niż Match.Number, bo odrzuci 12.0. To bywa dokładnie tym, czego chcę, kiedy po drugiej stronie mam deserializację do int. Match.Regex używam oszczędnie, tylko tam, gdzie format jest naprawdę częścią umowy, na przykład przy identyfikatorach magazynu.
Kontrakt zawiera tylko to, o co poprosiłem. Jeżeli dostawca zwraca dwadzieścia pól, a ja opisałem trzy, to w kontrakcie są trzy i dostawca może swobodnie dokładać kolejne. Na tym polega cała wartość podejścia consumer-driven: nie blokuję rozwoju dostawcy, blokuję wyłącznie usunięcie albo zmianę tego, z czego korzystam.
XUnitOutput trzeba dopisać samemu, to jest kilkanaście linijek, bo xUnit 2 nie przechwytuje wyjścia z konsoli, a domyślnym outputterem PactNeta jest właśnie konsola:
public class XUnitOutput : IOutput
{
private readonly ITestOutputHelper _output;
public XUnitOutput(ITestOutputHelper output) => _output = output;
public void WriteLine(string line) => _output.WriteLine(line);
}Bez tego pierwsza nieudana weryfikacja da mi w raporcie samo “test failed” i będę zgadywał, które pole się nie zgodziło.
I jedna pułapka, która na lokalnej maszynie nie boli, a na CI boli bardzo. Pliki pact są zapisywane w trybie scalania. Jeżeli uruchomię pojedynczy test, wynik zostanie domergowany do istniejącego pliku, a nie go nadpisze. Na CI oznacza to, że przed przebiegiem testów konsumenta trzeba skasować katalog pacts/, inaczej opublikuję kontrakt zawierający interakcje z testów, których od miesięcy już nie ma.
Minimalny flow: konsument, artefakt, dostawca
Kiedy test przechodzi, w katalogu pacts/ ląduje plik nazwany według schematu konsument-dostawca, czyli u mnie Order API-Inventory API.json. To jest cały kontrakt i warto go raz przeczytać, żeby przestał być magią. Poniżej jego skrócona wersja, z uciętym blokiem metadata.pactRust:
{
"consumer": { "name": "Order API" },
"provider": { "name": "Inventory API" },
"interactions": [
{
"description": "zapytanie o stan magazynowy istniejacego SKU",
"providerStates": [
{ "name": "pozycja magazynowa o sku ABC-1 istnieje" }
],
"request": {
"method": "GET",
"path": "/api/inventory/ABC-1",
"headers": { "Accept": "application/json" }
},
"response": {
"status": 200,
"headers": { "Content-Type": "application/json; charset=utf-8" },
"body": { "sku": "ABC-1", "quantity": 12, "warehouse": "WA-1" },
"matchingRules": {
"body": {
"$.sku": { "combine": "AND", "matchers": [{ "match": "type" }] },
"$.quantity": { "combine": "AND", "matchers": [{ "match": "integer" }] },
"$.warehouse": {
"combine": "AND",
"matchers": [{ "match": "regex", "regex": "WA-\\d+" }]
}
}
}
}
}
],
"metadata": { "pactSpecification": { "version": "3.0.0" } }
}Widać tu dokładnie to, co napisałem w teście, plus sekcję matchingRules, która zamienia przykładowe wartości w reguły. Pole providerStates jest listą, bo to specyfikacja v3.
Drugi krok to przeniesienie tego pliku do pipeline’u dostawcy. Na pierwsze wdrożenie nie uczę zespołu Pact Brokera od zera. Plik JSON opublikowany jako artefakt builda konsumenta i pobrany przez build dostawcy wystarczy, żeby zobaczyć, czy cały mechanizm ma sens w danym projekcie. Pact Broker w wersji open source jest naturalnym kolejnym krokiem, kiedy konsumentów robi się więcej niż jeden i zaczyna mieć znaczenie, która wersja kontraktu została zweryfikowana przeciwko której wersji dostawcy. Hostowany broker to osobna usługa i osobna decyzja, tutaj zostaję przy pliku.
Trzeci krok to weryfikacja u dostawcy. Tu jest jedna rzecz, na którą trzeba uważać na .NET i którą łatwo przegapić, bo idzie pod prąd przyzwyczajeniom.
Rdzeń Pacta jest natywny, więc odtwarza requesty przez prawdziwe gniazdo TCP. Oznacza to, że nie mogę wystawić API pod weryfikację przez TestServer ani przez WebApplicationFactory z Microsoft.AspNetCore.Mvc.Testing. Ten host żyje w pamięci procesu testowego i kod spoza .NET nie ma jak się do niego dobić. API musi wstać na porcie:
public class InventoryApiFixture : IDisposable
{
private readonly IHost _server;
public Uri ServerUri { get; }
public InventoryApiFixture()
{
ServerUri = new Uri("http://localhost:9223");
_server = Host.CreateDefaultBuilder()
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseUrls(ServerUri.ToString());
webBuilder.UseStartup<TestStartup>();
})
.Build();
_server.Start();
}
public void Dispose() => _server.Dispose();
}To jest klasyczny przypadek na fixture współdzielony przez klasę, bo start hosta jest kosztowny, a weryfikacja nie modyfikuje go w sposób, który przeszkadzałby kolejnym testom. Sam test weryfikujący wygląda tak:
public class InventoryApiContractTests : IClassFixture<InventoryApiFixture>
{
private readonly InventoryApiFixture _fixture;
private readonly ITestOutputHelper _output;
public InventoryApiContractTests(InventoryApiFixture fixture, ITestOutputHelper output)
{
_fixture = fixture;
_output = output;
}
[Fact]
public void InventoryApiSpelniaKontraktOrderApi()
{
var config = new PactVerifierConfig
{
LogLevel = PactLogLevel.Information,
Outputters = new List<IOutput> { new XUnitOutput(_output) }
};
string pactPath = Path.Combine("..", "..", "..", "..", "pacts", "Order API-Inventory API.json");
IPactVerifier verifier = new PactVerifier(config);
verifier
.ServiceProvider("Inventory API", _fixture.ServerUri)
.WithFileSource(new FileInfo(pactPath))
.WithProviderStateUrl(new Uri(_fixture.ServerUri, "/provider-states"))
.Verify();
}
}Verify() jest synchroniczne i rzuca wyjątkiem, kiedy któraś interakcja się nie zgadza. Nie piszę tu żadnej własnej asercji i to jest w porządku, bo asercje siedzą w kontrakcie.
Zostaje WithProviderStateUrl i to jest najważniejsza część całej układanki, a jednocześnie ta, którą ludzie najczęściej rozumieją opacznie. Provider state to nie jest mock. To jest umówiony haczyk, pod który weryfikator strzela POST przed każdą interakcją, podając nazwę stanu z kontraktu. Zadaniem dostawcy jest doprowadzić swoje dane do tego stanu:
public class ProviderStateMiddleware
{
private readonly IDictionary<string, Action> _providerStates;
private readonly RequestDelegate _next;
public ProviderStateMiddleware(RequestDelegate next)
{
_next = next;
_providerStates = new Dictionary<string, Action>
{
["pozycja magazynowa o sku ABC-1 istnieje"] = () => InventorySeed.Upsert("ABC-1", quantity: 12, warehouse: "WA-1")
};
}
public async Task InvokeAsync(HttpContext context)
{
if (!context.Request.Path.StartsWithSegments("/provider-states"))
{
await _next.Invoke(context);
return;
}
context.Response.StatusCode = (int)HttpStatusCode.OK;
using var reader = new StreamReader(context.Request.Body, Encoding.UTF8);
string body = await reader.ReadToEndAsync();
var state = JsonConvert.DeserializeObject<ProviderState>(body);
if (!string.IsNullOrEmpty(state?.State))
{
_providerStates[state.State].Invoke();
}
await context.Response.WriteAsync(string.Empty);
}
}
public class ProviderState
{
public string State { get; set; }
}Zwracam uwagę na dwie decyzje. Po pierwsze, słownik indeksowany stringiem oznacza, że literówka w nazwie stanu po stronie konsumenta wywali się dopiero w trakcie weryfikacji, jako KeyNotFoundException. Warto trzymać nazwy stanów w stałych współdzielonych przez oba repozytoria, jeżeli tylko jest taka możliwość. Po drugie, ten kod jest ustawianiem danych, a nie podstawianiem odpowiedzi. Jeżeli w tym miejscu zacznę mockować repozytorium, to zweryfikuję kontrakt między konsumentem a swoim mockiem, czyli nic. Ta granica jest łatwa do przekroczenia pod presją terminu i dlatego trzymam się zasady, że provider state wolno tylko wstawiać wiersze albo ustawiać flagi. Wraca tu wszystko, co pisałem o strategiach zarządzania danymi testowymi, bo kontrakty tylko podnoszą stawkę: stan musi być idempotentny, skoro weryfikator wywoła go przy każdej interakcji z osobna.
Na CI weryfikacja kontraktu jest u mnie osobnym jobem, a nie kolejnym krokiem w jobie testów jednostkowych. Powód jest ten sam, co przy równoległych testach na CI: chcę widzieć w raporcie, że pipeline jest czerwony przez kontrakt, a nie przez cokolwiek innego, i chcę móc ten job powtórzyć bez powtarzania reszty. Job dostawcy pobiera artefakt z buildu konsumenta i uruchamia jeden projekt testowy przez dotnet test --filter "Category=Contract".
Inne narzędzia, bez tutoriala
Dwa nazwiska, które padają w tej rozmowie regularnie i które warto znać, nawet jeśli nie jest to moja ścieżka.
Spring Cloud Contract, w tej chwili w wersji 3.1.3 z końca maja, to odpowiednik dla świata JVM. Kontrakt opisuje się tam w DSL-u w Groovym albo w YAML-u, a narzędzie generuje z niego testy dla dostawcy i stub runner dla konsumenta. Kierunek jest odwrotny niż w Pakcie, bo kontrakt zwykle mieszka w repozytorium dostawcy. Jeżeli zespół siedzi w Springu, to jest naturalny wybór i nie ma sensu przekonywać go do czegokolwiek innego. Dla projektu w C# to nie jest ścieżka.
Specmatic, artefakt in.specmatic:specmatic-core w wersji 0.46.0 z 2 lipca, reprezentuje szkołę spec-first. Kontraktem jest tam plik OpenAPI, a narzędzie potrafi z niego zrobić zarówno test dla dostawcy, jak i stub dla konsumenta. Projekt wcześniej nazywał się Qontract. To dobre podejście w miejscach, gdzie OpenAPI jest realnie utrzymywane i traktowane jako źródło prawdy. W moich projektach zwykle nie jest, dlatego wybieram kontrakt wyprowadzony z testów konsumenta, a nie z dokumentu, który potrafi się rozjechać z implementacją.
To nie jest przegląd rynku i celowo nie robię z tego wpisu recenzji wszystkich narzędzi. Chodzi mi tylko o to, żeby było jasne, że wybór PactNeta jest wyborem stosu, a nie jedyną możliwą odpowiedzią.
Czego świadomie nie mieszam
Cztery rzeczy, które przy kontraktach zlewają się ludziom w jedno, a nie powinny.
Kontrakt to nie jest test E2E. Nie sprawdza, czy zamówienie da się złożyć przez interfejs. Sprawdza, czy Inventory API nadal oddaje quantity jako liczbę całkowitą. To pierwsze wymaga uruchomienia obu systemów naraz, to drugie działa w kilka sekund na dwóch niezależnych pipeline’ach i to jest cała jego przewaga.
Kontrakt to nie jest ponawianie. Polityki Polly opisywałem rok temu i nadal ich używam, ale one leczą chwilową usterkę. Weryfikacja kontraktu obłożona retry to jest weryfikacja, która przestała cokolwiek znaczyć, bo albo dostawca spełnia kontrakt, albo nie, i druga próba tego nie zmieni.
Kontrakt to nie jest walidacja logiki biznesowej. Kształt odpowiedzi może być idealnie zgodny, a wyliczona ilość błędna. Testy dostawcy nadal muszą istnieć i nadal muszą sprawdzać, co system liczy.
I na koniec to, od czego zacząłem: kontrakt nie zastępuje ani WireMocka, ani kontenera z bazą. Zamyka lukę, której żadne z nich nie zamykało.
Podsumowanie
Po roku pracy ze stubami dokładam kontrakty dokładnie tam, gdzie stub ukrywał dryf cudzego API, i nigdzie indziej.
- PactNet
4.1.0na .NET 6 jako narzędzie. Jeden NuGet, mock server w procesie, specyfikacja v3. To jest ta zmiana, przez którą temat wrócił, bo sam pomysł jest z 2006 roku, a Pact z 2013. - Kontrakt obejmuje szczęśliwą ścieżkę i te warianty, które faktycznie obsługuję w kodzie. Reszta, czyli 503, timeout i puste body, zostaje w stubach WireMocka.
- Provider states ustawiają dane, nigdy nie podstawiają odpowiedzi. Ta jedna zasada decyduje o tym, czy weryfikacja cokolwiek znaczy.
- Na start wystarczy plik JSON w artefaktach CI. Broker jest odpowiedzią na problem wielu konsumentów, a nie warunkiem wejścia.
W podsumowaniu zeszłego roku napisałem, że motywem 2021 była izolacja. Kontrakty są następnym krokiem po izolacji i trochę jej przeciwwagą: najpierw odciąłem się od cudzych środowisk, żeby móc w ogóle testować, a teraz muszę odbudować kanał informacji zwrotnej do tych środowisk, żeby to odcięcie nie zamieniło się w ciszę.

