Ograniczenia frameworku Spring Cloud Contract

zupa

Spring Cloud Contract i Pact są dojrzałymi już frameworkami do testów kontraktowych i mają zbliżoną funkcjonalność. Dużą różnicę można jednak zauważyć w podejściu do kontraktu na zwracanie listy wyników.

Załóżmy, że mamy endpoint zwracający wyniki wyszukiwania po dacie. Chcemy wołać go następująco:

/supplies?day=2018-12-23

W dobrym teście powinniśmy upewnić się, że parametr day jest w ogóle interpretowany, tzn. że serwer zmienia zwracane wyniki w zależności od tego, o jaką datę go poprosimy. Kontrakt piszemy więc taki: (given) serwer ma przypisane 2 elementy tylko dla 23 grudnia (when) pytamy go o 23 grudnia (then) zwraca dokładnie 2 elementy.

Spring Cloud Contract pozwala zapisać to następująco:

request {
    method 'GET'
    url('/supplies') {
        queryParameters {
            parameter day: '2018-12-23'
        }
    }
}
response {
    status OK()
    body([
            [status: "CANCELED"],
            [count: 4, totalWeight: 20, status: "ACTIVE"],
    ])
    headers {
        contentType('application/json')
    }
}

Od razu widać pierwszy problem: mamy w kontrakcie części when i then, ale nie given. Kontrakt Pacta będzie zawierał wszystkie 3 części, specyfikacja umieszcza given jako stan providera.

Dalsze problemy pojawiają się przy wykonaniu. Załóżmy że mamy błąd w kodzie i nasz serwer duplikuje ostatni wynik. Zwracany JSON wygląda tak:

[
  {"count": 151, "totalWeight": 980, "status": "CANCELED"},
  {"count": 4, "totalWeight": 20, "status": "ACTIVE"},
  {"count": 4, "totalWeight": 20, "status": "ACTIVE"}
]

Pact zgłasza błąd naruszenia kontraktu:

Expected a List with 2 elements but received 3 elements

Spring Cloud Contract twierdzi, że kontrakt jest spełniony. Jeśli zwrócimy za mało wyników, pojawi się błąd, ale jeśli za dużo — wszystko w porządku.

Dokumentacja Spring Cloud Contract wspomina o „eksperymentalnej obsłudze sprawdzania rozmiaru tablic”, natomiast u mnie opisywana tam opcja assertJsonSize nie powoduje wygenerowania żadnych dodatkowych asercji na rozmiar tablic (może jest w bardzo wczesnej fazie eksperymentów).

Na upartego jesteśmy w stanie zmodyfikować kontrakt, by wyłapywać błędy w liczbie zwracanych wyników korzystając z matcherów:

body([
        [status: "CANCELED"],
        [count: 4, totalWeight: 20, status: "ACTIVE"],
])
bodyMatchers {
    jsonPath('$', byType {
        minOccurrence(2)
        maxOccurrence(2)
    })
}

Dla mnie jest to jednak bardzo mało zadowalające. Tworzymy duplikację w samym teście: 3 razy zapisujemy, że wynik ma mieć 2 elementy — 2 razy w bodyMatchers i raz określając samo body. Musimy pamiętać, żeby przy zmianach w kontrakcie utrzymywać te 3 miejsca spójne ze sobą.

Patrząc ogólniej na zachowanie tego frameworku: choć DSL kontraktu w Spring Cloud Contract sugeruje, że w body specyfikujemy wprost oczekiwaną odpowiedź, to prawda jest taka, że określamy jedynie luźno związane ze sobą stwierdzenia co do odpowiedzi. Możemy to lepiej zrozumieć patrząc, jak dopasowywane są pola.

Zapomnijmy o liczbie wyników i skupmy się na przypadku, gdy serwer źle przypisuje pola do pojedynczych wyników. Na przykład przestawia je jakby „na krzyż” (totalWeight 20 powędrowało z drugiego do pierwszego elementu):

[
  {"count": 151, "totalWeight": 20, "status": "CANCELED"},
  {"count": 4, "totalWeight": 980, "status": "ACTIVE"}
]

W przypadku Pacta nie ma zaskoczenia, framework zgłasza błąd na drugim z elementów:

$.1.totalWeight=[BodyMismatch(expected=20, actual=980, mismatch=Expected 20 but received 980, path=$.1.totalWeight, diff=null)]

Spring Cloud Contract akceptuje taką odpowiedź serwera. Dlaczego? Możemy łatwo znaleźć odpowiedź, ponieważ SCC generuje kod podczas buildu (w przeciwieństwie do Pacta, gdzie kontrakt podlega interpretacji w runtimie):

assertThatJson(parsedJson).array()
		.contains("['status']").isEqualTo("CANCELED");
assertThatJson(parsedJson).array()
		.contains("['count']").isEqualTo(4);
assertThatJson(parsedJson).array()
		.contains("['totalWeight']").isEqualTo(20);
assertThatJson(parsedJson).array()
		.contains("['status']").isEqualTo("ACTIVE");

Spring Cloud Contract nie operuje na poziomie elementów tablicy; dla tego frameworku tablica to „zupa” niepowiązanych ze sobą pól.

Da się wymusić poprawną weryfikację przez wpisanie wprost asercji do bodyMatchers, ale ponownie: zabija to deklaratywność kontraktu i zmusza do kodowania w miejscu, gdzie nie mamy podpowiadania składni ani nawet kompilatora. Domyślne zachowanie SCC jest nieintuicyjne i w praktyce stanowi pułapkę zastawioną na nowych użytkowników.

Reakcję Pacta i SCC na łamanie kontraktu możecie samodzielnie zaobserwować uruchamiając kod z mojego repozytorium github.com/pkubowicz/contract-testing-samples.