Testy API w C#

Gdy tworzymy jakieś API dobrze jest sprawdzić, czy ono działa. Po każdej zmianie albo dodaniu nowego punktu wejścia (endpoint) powinniśmy przetestować, czy wszystko jest ok. Możemy to zrobić uruchamiając aplikację i ręcznie wszystko sprawdzając, jednakże na dłuższą metę jest to bardzo czasochłonne. Dlatego warto utworzyć osobny projekt, w którym stworzymy testy sprawdzające czy nasze API działa poprawnie.

W kilku punktach postaram się opisać jak u mnie z reguły wygląda taki projekt z testami API.

Biblioteki

Po utworzeniu nowego projektu dla naszych testów (powinien być to projekt biblioteki – tzn. Class Library), musimy dołączyć do niego kilka paczek.

Najważniejszą z niż jest Microsoft.NET.Test.Sdk. Bez niej Visual Studio nie będzie wiedział, że dany projekt zawiera testy.

Kolejną biblioteką jest xUnit, przy pomocy której będziemy tworzyć nasze testy. Musimy pamiętać jeszcze o dodaniu bibiliteki xunit.runner.visualstudio, ponieważ bez niej Visual Studio może nie wykryć naszych testów.

Ja zawszę dodaję jeszcze bibliotekę do sprawdzania wyników (tzw. „assercji”). W moim przypadku jest to Shoudly, bo mam z nią najwięcej doświadczenia.

Oto lista wszystkich bibliotek:

Microsoft.NET.Test.Sdk
xunit
xunit.runner.visualstudio
Shoudly

WebApplicationFactory

Zanim przejdziemy do tworzenia testów, musimy stworzyć klasę, która pozwoli nam na wysłanie żądań (requests) do naszego API. Tutaj świetnie sprawdzi się wykorzystanie klasy WebApplicationFactory. Ta klasa umożliwia uruchomienie naszego API w pamięci i pozwala na wysyłanie do niego żądania. Oto przykład takiej klasy:

public class WebApiTesterFactory : WebApplicationFactory<TestStartup>
{
    protected override IWebHostBuilder CreateWebHostBuilder()
    {
        return new WebHostBuilder()
            .ConfigureAppConfiguration((hostingContext, config) =>
            {
                config.AddJsonFile("appsettings.json");
            })
            .UseEnvironment("Test")
            .UseStartup<TestStartup>();
    }
}

Nasza klasa WebApiTesterFactory dziedziczy po klasie WebApplicationFactory<>. Jako parametr generyczny przekazujemy TestStartup – jest to klasa Startup dla projektu testowego. Dziedziczy ona po klasie Startup z naszego projektu webowego. Celem tej klasy jest nadpisanie metod, które w testach mają się zachowywać inaczej.

public class TestStartup : Startup
{
    // ...
}

We wnętrzu klasy WebApiTesterFactory nadpisujemy metodę CreateWebHostBuilder.

CreateWebHostBuilder

W tej metodzie zwracamy obiekt WebHostBuilder uzupełniony o kilka dodatkowych rzeczy. Na początku ustawiamy, aby zaczytał on konfigurację z pliku appsettings.json (testy nie korzystają z pliku appsettings.json z projektu API, więc na potrzeby testów musimy utworzyć osobny plik konfiguracyjny w projekcie z testami). Robimy to poprzez wywołanie metody ConfigureAppConfiguration. W tej metodzie na obiekcie config (który jest typu IConfigurationBuilder) wołamy metodę AddJsonFile. Jako parametr przyjmuje ona nazwę pliku z konfiguracją. Następnie przy użyciu metody UseEnvironment ustawiamy, aby nasze API podczas testów miało ustawioną zmienną środowiskową na „Test”. Na końcu dodajemy, że ma on korzystać z klasy TestStartup.

Testy API

API do przetestowania

Teraz możemy przejść już do testów naszego API. Załóżmy, że mamy kontroler, który ma dwa punkty wejścia (endpoints) – jeden dla metody POST i jeden dla metody GET:

[HttpPost]
public async Task<IActionResult> InsertAsync([FromBody]Item request)
{
    // ...
    return new OkObjectResult(itemId);
}

[HttpGet("{itemId}")]
public async Task<IActionResult> RetrieveByIdAsync(Guid itemId)
{
    // ...
    return new OkObjectResult(item);
}

Metoda InsertAsync dodaje nowy element i zwraca jego Id. Metoda RetrieveByIdAsync na podstawie identyfikatora zwraca element o danym identyfikatorze.

Klasa Item niech posiada dwie właściwości – identyfikator oraz nazwę:

public class Item
{
    public Guid Id { get; set; }
    public string Name { get; set; }
}

Spróbujmy teraz napisać testy do tych metod.

Klasa testująca API

Najpierw do naszej klasy z testami musimy wstrzyknąć WebApiTesterFactory:

public class ItemsControllerTests : IClassFixture<WebApiTesterFactory>
{
    private readonly WebApiTesterFactory _factory;

    public ItemsControllerTests(WebApiTesterFactory factory)
    {
        _factory = factory;
    }
	
    // ...
}

Teraz możemy przejść do pisania testów.

Test dla POST

W tym teście chcielibyśmy przetestować request dla metody POST i sprawdzić, czy jako wynik otrzymamy identyfikator. Taki test może wyglądać następująco:

[Fact]
public async Task InsertAsync_ShouldAddNewItem()
{
    // Arrange
    var url = "api/items";
    var item = new Item { Name = Guid.NewGuid().ToString() };
    var request = new StringContent(JsonConvert.SerializeObject(item),
        Encoding.UTF8, "application/json");

    HttpClient client = _factory.CreateClient();

    // Act
    HttpResponseMessage response = await client.PostAsync(url, request);

    // Assert
    response.StatusCode.ShouldBe(HttpStatusCode.OK);
    var responseContent = await response.Content.ReadAsStringAsync();
    var result = JsonConvert.DeserializeObject<Guid>(responseContent);
    result.ShouldNotBe(Guid.Empty);
}

W sekcji Arrange tworzymy wszystkie elementy, które są nam niezbędne do wysłania żądania:
url, pod jaki na zostać wysłane żądanie;
item, który ma zostać wysłany;
request, zawierający item do wysłania;
client, który umożliwi nam wysłanie żądania;

W sekcji Act metodą PostAsync wysyłamy request pod wskazany url. W odpowiedzi otrzymujemy jakiś response typu HttpResponseMessage.

W sekcji Assert najpierw sprawdzamy, czy StatusCode odpowiedzi jest Ok. Następnie pobieramy zawartość odpowiedzi (Content), serializujemy ją do obiektu Guid i sprawdzamy, czy nie jest pusta.

Test dla GET

Test dla żądania GET będzie wyglądał podobnie, tylko w sekcji Arrange będziemy potrzebowali wysłać żądanie POST w celu dodania danego elementu. Następnie będziemy musieli pobrać identyfikator dodanego elementy i wykorzystać go w sekcji Act do wysłania żądania.

[Fact]
public async Task RetrieveByIdAsync_ShouldReturnAddedItemDetails()
{
    // arrange
    var url = "api/items";
    var item = new Item { Name = Guid.NewGuid().ToString() };
    var request = new StringContent(JsonConvert.SerializeObject(item),
        Encoding.UTF8, "application/json");

    var client = _factory.CreateClient();

    var addItemResponse = await client.PostAsync(url, request);
    var addItemResponseContent = await addItemResponse.Content.ReadAsStringAsync();
    var itemId = JsonConvert.DeserializeObject<Guid>(addItemResponseContent);

    // act
    var response = await client.GetAsync($"{url}/{itemId}");

    // assert
    response.StatusCode.ShouldBe(HttpStatusCode.OK);
    var responseContent = await response.Content.ReadAsStringAsync();
    var result = JsonConvert.DeserializeObject<Item>(responseContent);
    result.Id.ShouldBe(itemId);
    result.Name.ShouldBe(item.Name);
}

W sekcji Assert tutaj również na początku sprawdzamy, czy StatusCode odpowiedzi jest Ok. W tym przypadku jednak Content serializujemy do obiektu Item i sprawdzamy, czy ma on odpowiednie wartości.

Shoudly

Mogliście zauważyć, że w sekcji Assert nie mam standardowego sprawdzania wyników z wykorzystaniem klasy statycznej Assert. Metody ShouldNotBe i ShouldBe pochodzą z biblioteki Shoudly. Jak dla mnie taki kod:

result.Id.ShouldBe(itemId);

czyta się dużo lepiej niż taki kod:

Assert.Equal(itemId, result.Id);

Alternatywą dla biblioteki Shoudly może być Fluent Assertions.

Podsumowanie

Jeśli w swoich projektach nie pisaliście testów do API, to mam nadzieję, że ten post pokazał wam, że można je zacząć pisać w dość prosty sposób.

Niektórzy takie testy nazywają również testami integracyjnymi lub testami end-to-end (skrót: e2e), jednak to już jest szczegół uwarunkowany tym, co dokładnie sprawdzamy w naszych testach. Tak czy inaczej bazowa konfiguracja jest taka sama.

Dodaj komentarz