Testy integracyjne warstwy komunikacji z WebAPI ASP.NET Core

Programowanie

W tym artykule opiszę, w jaki sposób możesz łatwo i z minimalnym nakładem pracy dodać do swojego projektu testy integracyjne, które odpowiedzą Ci na jedno podstawowe pytanie: czy mój kod odpowiedzialny za komunikację z REST Api w ASP.NET działa prawidłowo.

Wstęp

Pierwszy raz testy integracyjne dla warstwy komunikacji z REST Api wprowadziłem do swojego projektu easyRenti.pl. Zauważyłem wtedy, iż mimo, że na pierwszy rzut oka implementacja warsty komunikacyjnej pomiędzy aplikacją kliencką (w naszym przykładzie był to Blazor WebAssembly), a WebAPI jest raczej prosta. To jednak ilość błędów w tym kodzie i tak była nadspodziewanie duża.

W tym artykule dodam testy warstwy komunikacji do projektu tworzonego w ramach serii Z pamiętnika SaaSa.

Kod źródłowy: https://github.com/robocik/NotesApp_V4_With_CommunicationTests

Warstwa komunikacyjna

Czym jest warstwa komunikacyjna z WebAPI? Po prostu jest to kod, w którym korzystamy z klasy HttpClient, podajemy jej adres URL do naszego API oraz klasę, która jako parametr zostanie zserializowana do JSON i przesłana do serwera. Na koniec musi ew. odebrać odpowiedź.

Dobrą praktyką w takich przypadkach jest wydzielenie kodu związanego z komunikacją do oddzielnej klasy (lub klas), które zajmują się całą obsługą HttpClienta. Pozostała część aplikacji klienckiej dzięki temu nie musi zajmować się takimi szczegółami – po prostu korzysta z naszej klasy.

W naszej testowej aplikacji przyjąłem zasadę, że każdy kontroller ma dedykowaną klasę na kliencie, która odpowiada za wywoływanie jego metod.

I tak w projekcie znalazły się:

KontrolerKlasa do komunikacji
NotesControllerNoteDataService
AccountControllerAccountDataService
FilesControllerFileDataService

Jak to wygląda w praktyce?

ASP.NET

[Route("api/[controller]")]
[ApiController]
[Authorize]
public class NotesController: Controller
{
    [HttpGet]
    public async Task<IActionResult> GetNotes([FromQuery] GetNotesParam param)
    {
        ...
        return Ok(retValue);
    }

    [HttpGet("{id}")]
    public async Task<IActionResult> GetNoteDetails(Guid id)
    {
        ...
        return Ok(retValue);
    }

    [HttpPost]
    public async Task<IActionResult> CreateNote([FromBody] CreateNoteParam? param)
    {
        ...
        return Created("note", null);
    }

    [HttpPut]
    public async Task<IActionResult> UpdateNote([FromBody] UpdateNoteParam? param)
    {
        ...
        return NoContent(); //success
    }

    [HttpDelete("{id}")]
    public async Task<IActionResult> DeleteNote(Guid? id)
    {
        ...
        return Ok();
    }
}

Kod do komunikacji

public class NoteDataService:DataServiceBase, INoteDataService
{
    public NoteDataService(HttpClient httpClient) : base(httpClient)
    {
    }

    public async Task<PagedResult<NoteDto>> GetNotes(GetNotesParam param)
    {
        var url = GetUrl("api/notes", param);
        
        return await Execute(async httpClient =>
        {
            var res= await  httpClient.GetFromJsonAsync<PagedResult<NoteDto>>(url, CreateOptions()).ConfigureAwait(false);
            return res!;
        }).ConfigureAwait(false);
    }

    public async Task<NoteDto> GetNoteDetails(Guid id)
    {
        return await Execute(async httpClient =>
        {
            var note=await httpClient.GetFromJsonAsync<NoteDto>($"api/notes/{id}", CreateOptions()).ConfigureAwait(false);
            return note!;
        }); 
    }

    public Task CreateNote(CreateNoteParam param)
    {
        return Execute(httpClient =>
        {
            return httpClient.PostAsJsonAsync("api/notes", param, CreateOptions());
        });
    }
    
    public Task UpdateNote(UpdateNoteParam param)
    {
        return Execute(httpClient =>
        {
            return httpClient.PutAsJsonAsync("api/notes", param, CreateOptions());
        });
    }

    public Task DeleteNote(Guid id)
    {
        return Execute(httpClient =>
        {
            return httpClient.DeleteAsync($"api/notes/{id}");
        });
    }
}

Implementacja metody Execute znajduje się w klasie bazowej i zajmuje się powtarzalnymi zadaniami, jak np obsługa wyjątków.

Natomiast GetUrl pozwala na łatwe „zserializowanie” parametru do Query String.

Błędy w warstwie komunikacyjnej

Patrząc na powyższy kod, możesz zapytać, jakie problemy (błędy) mogą wystąpić w tak prostym kodzie. Ja spotkałem się z następującymi:

Różne nazwy parametrów

Podając parametry w Query String (GET) musimy zapewnić te same nazwy w URL oraz po stronie ASP.NET. W przypadku literówki lub po prostu innej nazwy, wartość nie zostanie prawidłowo przesłana do metody kontrolera.

Kod klienta

public Task<string> GetPerson(Guid id)
{
   var url = GetUrl($"person?id={id}");

   return await Execute(async httpClient =>
   {
       var res = await httpClient.GetFromJsonAsync<string>(url, CreateOptions()).ConfigureAwait(false);
       return res!;
   }).ConfigureAwait(false);
}

ASP.NET

[HttpGet]
public PersonData GetPerson(Guid personId)
{
    return null;
}

W tym przykładzie klient wysyła identyfikator osoby jako id, a kontroler oczekuje personId. Oczywiście w tak prostym przykładzie pewnie bardzo szybko wyłapiemy taką pomyłkę, jednak w przypadku parametrów rzadziej używanych, błąd może być w programie dłuższy czas.

Brak atrybutu [FromBody]

W tym przypadku mimo, że obiekt jest przekazywany w treści requestu, po stronie ASP.NET nie zostanie on prawidłowo odczytany, gdyż brakuje [FromBody].

Kod klienta

public Task CreateNote(CreateNoteParam param)
{
    return Execute(httpClient =>
    {
        return httpClient.PostAsJsonAsync("api/notes", param, CreateOptions());
    });
}

ASP.NET

[HttpPost]
public async Task<IActionResult> CreateNote(CreateNoteParam? param)
{
    return Created("note", null);
}

Błedny adres URL

Każda metoda kontrolera ma adres, pod którym jest osiągalna. Na ten adres składa się adres aplikacji, nazwa kontrolera oraz czasem identyfikator metody. Bardzo łatwo pomylić się lub w przypadku copy-paste kodu, zapomnieć zmienić na nową wartość.

Przesyłana klasa jest błedna.

Serializator JSON ma określone wymagania, odnośnie przesyłanej klasy, np. musi być bezparametrowy konstruktor, własności nie powinny zwracać interfejsów, itp. W przypadku tego typu klas, prawdopodobnie zamiast wartości obiektu, otrzymamy null.

Przesyłamy strukturę, ale nie mamy model bindera

Ten problem miałem w przypadku wprowadzenia do easyRenti.pl obsługi wielu walut. W tym celu użyłem struktury Money z biblioteki NodaMoney. Wszystko działało dobrze, logika biznesowa, unit testy itp. Dopiero po paru dniach zdałem sobie sprawę, że wszystkie kwoty przesyłane z aplikacji Blazor do WebAPI były nullem. Po prostu aplikacja ASP.NET nie potrafiła poradzić sobie z typem Money i musiałem dodać model binder.

Nie zaimplementowaliśmy metody w kontrolerze

Może się to wydawać naciągane, jednak takie problemy też miałem. Więcej o tym poczytasz w części Migracja do .NET 6 poniżej.

Migracja z .NET Framework 4.8 -> .NET 6

Potrzeba przetestowania warstwy komunikacji pojawiła się również podczas jednego z projektów, przy którym pracowałem. Jest to aplikacja napisana w .NET Framework i rozwijana od kilkunastu lat. Pojawił się plan migracji do najnowszej wersji .NET 6. Było to duże wyzwanie, opisanie którego wymagałoby osobnego artykułu. Jednak jednym z etapów tej migracji było przeniesienie części serwerowej aplikacji napisanej w WCF (uruchamianej jako self host na Windows Service) na coś co jest dostępne w najnowszej wersji Frameworka.

Główny problem tutaj był taki, iż WCF nie został przeniesiony do .NET Core. Na GitHubie jest Open Source’owa implementacja CoreWCF, jednak po pierwszych testach okazało się, że nie spełnia naszych wymagań (jest to wczesna wersja Alpha, która po prostu nie działała).

W związku z tym podjeliśmy decyzję o przepisaniu aplikacji serwerowej na REST WebApi korzystając z ASP.NET Core. I tutaj dochodzimy do testów warstwy komunikacyjnej. W naszym projekcie mieliśmy ponad 850! metod, które trzeba było przenieść. W większości przypadków przeniesienie pojedyńczej metody było prostym zadaniem, jednak było tego tak wiele, że całe zadanie zajeło parę tygodni.

Początkowo przepisywanie wyglądało tak:

  1. Przenieś metodę z WCF->ASP.NET
  2. Uruchom program i sprawdź czy działa
  3. Jeśli działa to przejdź do następnej metody i pkt 1.

Takie podejście było mało wydajne. Często dłużej trwało uruchamianie systemu, aby sprawdzić, czy nowa metoda działa, niż jej napisanie. Dlatego następnym podejściem było przenoszenie metod „w ciemno”, czyli bez sprawdzania ich za każdym razem. Najpierw przepisałem np. 20 metod i wtedy sprawdzałem. Później coraz mniej testowałem, a gdy już uruchomiłem program to często okazywało się, że wiele metod jednak nie działa (głównie przez proste problemy, o których wspominałem powyżej).

Trzeba było lepszego podejścia…

Narodziny biblioteki RestVerifier

Pracując nad easyRenti, miałem stworzone testy warstwy komunikacji i wiedziałem, że mając je w migrowanym projekcie, znacząco by przyśpieszyły prace i zapewniły znacznie większą stabilność nowego kodu. Napisanie jednak teraz testów dla ponad 850 metod wydawało się zbyt dużym nakładem pracy. Nie po to zajmujemy się programowaniem, żeby robić coś ręcznie. Można próbować zautomatyzować cały proces.

W związku z tym stworzyłem mechanizm (bibliotekę), która jest odpowiedzialna za testowanie warstwy komunikacyjnej w projekcie. Po stworzeniu pierwszej wersji okazało się, że efekt działania takich testów jest nawet lepszy niż zakładałem początkowo. Po wdrożeniu testów w migrowanym projekcie, wykryliśmy mnóstwo błędów, których część prawdopodobnie nie zostałaby wyłapana w fazie dewelopmentu i trafiłaby na produkcję.

Biblioteka do testów bardzo się nam przydała, dlatego zdecydowałem się upublicznić kod i stworzyć paczkę NuGet RestVerifier, dzięki czemu w innych projektach również można takie automatyczne testy dodać.

NuGet: https://www.nuget.org/packages/RestVerifier/

GitHub: https://github.com/robocik/RestVerifier

Zasada działania RestVerifier

Idea jest prosta. Zakładam, że w projekcie masz WebAPI napisane w ASP.NET Core z pewną liczbą kontrolerów (w naszym przykładzie, np. NotesController) oraz klasę odpowiedzialną za komunikację po stronie klienta (NoteDataService). Obecnie zaimplementowałem tylko ASP.NET Core, jednak nic nie stoi na przeszkodzie, aby testy wspierały inne technologie serwerowe.

Testy warstwy komunikacyjnej są to standardowe testy integracyjne, czyli piszemy klasę, dodajemy jej atrybuty NUnit (czy innego frameworka do testów) i przekazujemy kontrolę do RestVerifier. Ten natomiast uruchamia w pamięci naszą aplikację ASP.NET oraz bierze klasę kliencką (NoteDataService), tworzy jej instancję, generuje testowe wartości dla parametrów każdej z metod w tej klasie i uruchamia je. Następnie sprawdza, czy parametry zostały przesłane do metody kontrolera prawidłowo i jeśli tak to, gdy nasz kontroler zwraca wartość, to jest ona automatycznie wygenerowana, zwracana i po stronie klienta znowu jest dokonana walidacja.

Ważne jest to, iż podczas testów NIE są uruchamiane faktyczne metody z kontrolerów. Oznacza, to iż nie musimy stawiać całego środowiska testowego, bazy danych itp. żeby uruchomić testy. RestVerifier przechwytuje zapytania jeszcze przed tym jak ASP.NET przekaże je do kontrolera. Dzięki temu nasza aplikacja nie jest faktycznie uruchomiona. A co za tym idzie, RestVerifier testuje tylko warstwę komunikacyjną, a nie logikę biznesową naszego programu.

Jak dodać testy do swojego projektu?

W tej części pokażę jak korzystać z biblioteki RestVerifier. Punktem wyjścia jest aplikacja, którą tworzę w ramach tego bloga i serii Z pamiętnika SaaSa. W ostatnim artykule opisałem, jak dodać tryb Live Demo. Teraz do tego kodu, dodamy testy warstwy komunikacyjnej.

Kod źródłowy: https://github.com/robocik/NotesApp_V4_With_CommunicationTests

Aby dodać testy warstwy komunikacyjnej należy:

  1. Do projektu z testami dodać pakiety RestVerifiera:
Install-Package RestVerifier
Install-Package RestVerifier.AspNetCore

i opcjonalnie (jeśli korzystasz z NUnit)

Install-Package RestVerifier.NUnit 
  1. Teraz musimy przystosować naszą aplikację ASP.NET do uruchamiania w ramach testów. Jest to bardzo proste. Najlepiej stworzyć nową klasę, która dziedziczy po RestVerifier.AspNetCore.CustomWebApplicationFactory. W tej klasie możemy zarejestrować dodatkowe serwisy lub coś zmockować.
public class TestWebApplicationFactory
    : RestVerifier.AspNetCore.CustomWebApplicationFactory<WebApiTestStartup>
{

    protected override IHost CreateHost(IHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            services.AddScoped<ISession>(s =>
            {
                return new Mock<ISession>().Object;
            });
        });
        return base.CreateHost(builder);
    }
    
}

Może zastanawiasz się po co miałbyś cokolwiek mockować, skoro wcześniej pisałem, że RestVerifier nie odpala faktycznego kodu z kontrolerów? Tak jest, za wyjątkiem wykonania kodu konstruktorów testowanych kontrolerów. Niestety, ASP.NET najpierw tworzy instancję kontrolera, a dopiero potem RestVerifier może przejąć kontrolę.

Więc, jeśli do kontrolerów przekazujesz np. połączenie z bazą danych lub cokolwiek innego, co byłoby problemem w testach, to własnie teraz możesz to nadpisać.

  1. Na koniec tworzymy nasz test. W najprostszym przypadku wygląda to mniej więcej tak.
public class NoteDataServiceTests_Easy : RestVerifier.NUnit.TestCommunicationBase<NoteDataService>
{
    protected override void ConfigureVerifier(IGlobalSetupStarter<NoteDataService> builder)
    {
        builder.CreateClient(v =>
        {
            var service = new TestWebApplicationFactory();
            service.SetCompareRequestValidator(v);
            service.SkipAuthentication = true;
            var httpClient = service.CreateClient();
            var client = new NoteDataService(httpClient);
            return Task.FromResult(client);
        });
        builder.CheckExceptionHandling<InvalidOperationException>();
    }
}

Najważniejszym elementem jest podziedziczenie po klasie RestVerifier.NUnit.TestCommunicationBase i wskazanie naszej klienckiej klasy odpowiedzialnej za komunikację z WebAPI (NoteDataService).

Dodatkowo, możesz ustawić SkipAuthentication=true, wtedy podczas testów twoje kontrolery nie będą wymagały autoryzacji. Później pokaże, w jaki sposób w swoich testach sprawdzić również, czy metody wymagają zalogowania.

Drugim krokiem jest wskazanie RestVerifier naszej aplikacji ASP.NET oraz stworzenie instancji klasy klienckiej.

  1. Teraz pozostaje nam uruchomić testy:
Wynik naszych testów warstwy komunikacji
Wynik naszych testów warstwy komunikacji

Nasza testowa metoda kliencka zawiera tylko 5 metod, jednak w projekcie migrowanym do .NET 6 było tego o wiele więcej:)

Co sprawdza RestVerifier?

Ok, testy się uruchamiają, jest zielono. Ale co to udowadnia? Co tak naprawdę zostało przetestowane? RestVerifier sprawdza wiele rzeczy:

  1. Przede wszystkim zakładamy, że każda uruchomiona metoda, powinna trafić do kontrolera w naszym WebAPI. W przypadku, gdy po odpaleniu metody, nasza aplikacja ASP.NET nie dostanie requestu, to jest zgłaszany błąd. Może on oznaczać, np. że mamy błędny adres URL lub po prostu metoda jest niezaimplementowana.
  2. Sprawdzane są parametry z metody klienckiej, czy wszystkie zostały przesłane do WebAPI i czy wartości są identyczne (czy serializacja nie miała problemów). Sam ten punkt tak naprawdę waliduje większość rzeczy, które mogą pójść źle: błędne nazwy parametrów, brakujące atrybuty (np [FromBody]), pominięte lub błędnie zserializowane parametry itp.
    Sprawdzanie wartości parametrów działa oczywiście dla typów złożonych, list itp. W tym celu korzystam z biblioteki FluentAssertions, jednak nic nie stoi na przeszkodzie, aby wykorzystać coś innego.
  3. Generowana jest testowa wartość, którą RestVerifier zwróci, w celu zbadania, czy nasza klasa prawidłowo deserializuje odpowiedzi serwera. Łącznie z sytuacją, gdzie serwer zwraca wartość, a klasa kliencka już nie (typ void). Wtedy też jest zgłoszony błąd
  4. W przypadku, gdyby podczas uruchamiania metody klienckiej wystąpiłby jakikolwiek błąd (wyjątek) po stronie ASP.NET, RestVerifier sprawdzi czy po stronie klienta też zostanie wygenerowany wyjątek. Jeśli nie, to jest zgłoszony błąd w tescie.
  5. Dodatkowo możemy także sami wymusić sprawdzenie, czy nasze metody prawidłowo obsługują sytuacje wyjątkowe.
    W tym celu konfigurując nasze testy trzeba wywołać:
builder.CheckExceptionHandling<InvalidOperationException>();

Ta linijka sprawi, że po standardowych testach, RestVerifier wygeneruje wyjątek podanego typu (u nas InvalidOperationException) i sprawdzi, czy po stronie klienta, też wyjątek zostanie rzucony (typ wyjątku nie jest sprawdzany).

Powyższe sprawdzenia w większości przypadków oznaczają błędy, jednak w sytuacji, gdy np. świadomie zaimplementowaliśmy metodę kliencką jako void, mimo że akcja kontrolera zwraca wartość (pkt 3 powyżej), to oczywiście, możemy poinstruować RestVerifier, że w tym przypadku jest to zamierzone.

Dostosowywanie testów

Powyższy przykład testu jest oczywiście bardzo optymistyczny (idealny przypadek). W realnych projektach, zwykle to nie wygląda tak różowo. Całe szczęście, RestVerifier ma spore możliwości konfiguracji, więc zdecydowaną wiekszość przypadków da się przetestować. Poniżej przedstawię najczęstsze problemy i sposoby ich rozwiązania.

Więcej przykładów i informacji znajdziesz w dokumentacji RestVerifier. Dodatkowo kod źródłowy RestVerifier zawiera wiele testów jednostkowych oraz testową aplikację ASP.NET z różnymi przypadkami użycia.

Różnice w parametrach

Domyślnie RestVerifier zakłada, że jeśli na kliencie przekazujemy parametr typu X (np Person), to po stronie kontrolera też powinien być typ X. Jednak czasem jest inaczej, np:

Metoda klienta:

Task<PersonDTO> GetPerson(PersonDTO person)

ASP.NET:

[HttpGet("GetPerson")]
public PersonDTO GetPerson(Guid id)

Jak widać, do WebApi tak naprawdę wysyłamy tylko identyfikator osoby, dlatego w tym przypadku musimy poinformować RestVerifier jak ma przekształcić jeden typ w drugi:

Konfiguracja:

_builder.ConfigureVerify(x =>
{
     x.Verify(b => b.GetPerson(Behavior.Transform<PersonDTO>(h => h.Id)));
});

Nie wszystkie parametry wysyłamy do WebAPI

Domyślnie RestVerifier oczekuje, że jeśli metoda kliencka ma 3 parametry, to metoda kontrollera WebAPI też przyjmuje 3 parametry. W przypadku, gdy jakiś parametr nie jest przesyłany (i nie powinien być sprawdzany) to trzeba poinformować o tym:

Metoda klienta:

Task<PersonDTO> GetPersonWithAdditionalParameter(Guid id,int retry)

ASP.NET:

[HttpGet("GetPerson")]
public PersonDTO GetPerson(Guid id)

Konfiguracja:

_builder.ConfigureVerify(x =>
{
     x.Verify(b => b.GetPersonWithAdditionalParameter(Behavior.Verify<Guid>(),Behavior.Ignore<int>()));
});

Złożone przekształcenia parametrów

Czasem mamy metodę kliencką, która przyjmuje wiele parametrów, a po stronie kontrolera mamy klasę, która te parametry opakowuje:

Metoda kliencka:

Task UploadAvatarFull(UploadFileParam fileParam, Stream fileContent)

ASP.NET:

public class UploadAvatarParameter
{
    public Stream? File { get; set; }
    public UploadFileParam? Meta { get; set; }
}
    
[HttpPost("uploadAvatarFull")]
public async Task<IActionResult> UploadAvatarFull(UploadAvatarParameter uploadParam)

Konfiguracja:

cons.Verify(g => g.UploadAvatarFull(Behavior.Verify<UploadFileParam>(), Behavior.Verify<Stream>()))
        .Transform<UploadFileParam,Stream>((p1, p2) =>
              {
                var param = new UploadAvatarParameter()
                {
                   Meta = p1,
                   File = p2
              };
              return new[] { param };
         });

Kontroler zwraca inną wartość niż metoda kliencka

Jest to sytuacja często występująca w przypadku metod zwracających plik z serwera. Po stronie ASP.NET zwracamy FileStreamResult, a już na kliencie mamy Stream:

Metoda kliencka:

Task<Stream> GetFileContent(string name)

ASP.NET:

[HttpGet("GetFileContent")]
public FileStreamResult GetFileContent(string name)

W tym przypadku RestVerifier wygeneruje testowy strumień (na podstawie wartości zwracanej metody klienckiej), a naszym zadaniem jest zdefiniować, w jaki sposób zamienić go na obiekt klasy FileStreamResult:

Konfiguracja:

_builder.ConfigureVerify(x =>
{
    x.Verify(b => b.GetFileContent(Behavior.Verify<string>())).Returns<Stream>(stream =>
    {
        var memory = (MemoryStream)stream;
        var newMemory = new MemoryStream();
        memory.CopyTo(newMemory);
        newMemory.Position = 0;
        memory.Position = 0;
        return new FileStreamResult(newMemory, "text/json");
    });
});

W przypadku typu Stream, potrzebujemy jeszcze poinformować RestVerifier o tym jak stworzyć testowy strumień oraz w jaki sposób dokonać weryfikacji. Info o tym znajdziesz poniżej.

Konfigurowanie tworzenia testowych danych

Domyślnie RestVerifier korzysta z biblioteki AutoFixture do tworzenia testowych danych, która w większości przypadków wie, w jaki sposób wygenerować dane. Jednak w pewnych przypadkach (gdy np. parametrem jest interface lub klasa abstrakcyjna – u nas Stream), trzeba poinformować AutoFixture, w jaki sposób stworzyć testowe instancje.

Konfiguracja:

var creator = new AutoFixtureObjectCreator();
creator.Fixture.Register<byte[], Stream>((byte[] data) => new MemoryStream(data));
_builder.UseObjectCreator(creator);

Najprościej stworzyć instancję obiektu AutoFixtureObjectCreator, który daje dostęp do biblioteki AutoFixture (własność Fixture). W powyższym przykładzie gdy AutoFixture będzie musiał stworzyć obiekt typu Stream, to wykorzysta w tym celu klasę MemoryStream.

Szczegółowe informacje na temat korzystania z AutoFixture znajdziesz w dokumentacji.

Istnieje też możliwość skorzystania z innej biblioteki do tworzenia testowych danych. W tym celu wystarczy zaimplementować interface ITestObjectCreator.

Dostosowanie porównywania obiektów

RestVerifier korzysta z biblioteki FluentAssertions do porównywania wszystkich wartości, która zwykle dobrze radzi sobie z tym zadaniem. Czasem jednak trzeba jej troszke pomóc (np. gdy porównujemy strumienie). Najprościej stworzyć nową klasę, dziedziczącą FluentAssertionComparer i przeciążyć metodę Compare:

public class TestComparer: FluentAssertionComparer
{
    public override void Compare(object? obj1, object? obj2)
    {
        if (obj1 is Stream stream1 && obj2 is Stream stream2)
        {
            stream1.Should().HaveLength(stream2.Length);
            return;
        }
        base.Compare(obj1, obj2);
    }
}

Następnie wskazujemy tą klasę w RestVerifierze:

_builder.UseComparer<TestComparer>();

W powyższym przykładzie, w przypadku porównywania obiektów Stream, zostaną sprawdzone tylko ich długości (własność Length).

Można też skorzystać z innej biblioteki do porównywania obiektów. Aby to zrobić implementujemy interface IObjectsComparer.

Testy autentykacji

Do tej pory podczas wykonywania naszych testów integracyjnych, autentykacja była wyłączona, gdyż tak naprawdę dla sprawdzenia warstwy komunikacji, nie ma potrzeby zajmować się bezpieczeństwem.

Czasem jednak chcelibyśmy, aby nasze testy również sprawdziły, czy WebAPI wymaga autoryzacji. Dzięki temu zwiększamy bezpieczeństwo naszej aplikacji – jeśli ktoś przez pomyłkę wyłączy autoryzację dla metody kontrolera, to nasze testy to wyłapią.

W naszym testowym projekcie korzystam z IdentityServera. Dodałem testy z nazwą _Advanced (np: NoteDataServiceTests_Advanced), które zawierają dwa sprawdzenia:

  1. Czy metoda odpalona bez zalogowania do API wygeneruje wyjątek UnauthorizedException
  2. Czy metoda odpalona po zalogowaniu zwróci wartość

W tym miejscu nie będę pokazywał kodu, który jest już bardziej złożony. Jeśli interesuje Cię, jak w testach można zasymulować logowanie do IdentityServera, przejżyj kod klasy TestRestCommunication.

Podsumowanie

Testy jednostkowe oraz integracyjne warto wprowadzić do swojego projektu, gdyż w znaczący sposób poprawiają jakość oprogramowania i sprawiają, że o wiele bezpieczniej będzie nam refaktoryzować kod. Jednym z obszarów, które warto testować automatycznie to nasza warstwa komunikacji. Korzystając z biblioteki RestVerifier, zadanie to jest proste i nie zajmuje wiele czasu. Co więcej, każda nowa wersja biblioteki będzie sprawdzała coraz więcej rzeczy, a korzyści z takich testów szybko się pojawią.

Zachęcam również do zapoznania się z dokumentacją RestVerifier oraz zgłaszania ewentualnych bugów oraz pomysłów na nowe ficzery.

Share