Cześć! Po dłuższej przerwie zapraszam Was na kolejny wpis w Cesartswie-Dev. Wrócimy dzisiaj do jednego z moich ulubionych zagadnień – testów. Dokładniej mówiąc, poruszymy bardzo interesujący temat, jakim są testy integracyjne przykładowego API. W końcu nie samymi testami jednostkowymi programista żyje! Chcesz się dowiedzieć czym są testy integracyjne, oraz jak je zaimplementować w .NET? A może po prostu chcesz odświeżyć wiedzę? Niezależnie, trafiłeś w dobre miejsce, zapraszam! Pierwsze rozdziały skupią się na teorii związanej z testami integracyjnymi, więc zrozumiem jeśli je pominiesz i przejdziesz do głównej części programu.

Piramida testów

The Test Pyramid. Thoughts on the test pyramid… | by Jessie Leung | Better  Programming | Medium

Pewnie spotkałeś się już z pojęciem piramidy testów. Przedstawia ona model, według którego powinniśmy pisać testy do naszego projektu. Na samym dole, w najszerszej części, mamy testy jednostkowe – tanie i szybkie. Możemy je wywołać kilkukrotnie w ciągu nawet jednej minuty kodowania. Testują one w odizolowaniu funkcjonalności poszczególnych jednostek. Na górze piramidy są testy e2e – end to end. Są one długotrwałe, gdyż sprawdzają pełną funkcjonalność systemu krokowo przechodząc kolejne etapy pewnego procesu biznesowego. Najczęściej wykonuje się je z poziomu UI, jednak mogą również skupić się wyłącznie na API. Na środku widzimy głównego prowodyra tego wpisu – testy integracyjne. Trwają one dłużej niż testy jednostkowe, ponieważ nie testujemy w nich komponentu w pełnej izolacji. Zamiast tego sprawdzamy, czy poprawnie zachodzi komunikacja pomiędzy różnymi elementami naszego projektu (wewnętrznymi oraz zewnętrznymi, takimi jak bazy danych).

Warto tutaj wspomnieć, że powyższy model nie jest idealny w każdym projekcie. Istnieją takie, gdzie będzie istniało najwięcej testów integracyjnych, jak i takie gdzie ta piramida będzie zupełnie odwrotna. Wszystko zależy od kontekstu oraz czasu.

Testy integracyjne

Jak wspomniałem wcześniej, testy integracyjne sprawdzają nasz system na szerszym poziomie niż testy jednostkowe. Dzięki testom integracyjnym testujemy nie tylko nasz kod, ale także infrastrukturę. Do tej ostatniej często wliczamy bazy danych, systemy plików czy zewnętrzne serwisy. W testach integracyjnych rzadziej stosujemy mocki czy stuby, chociaż wciąż jest to możliwe. Czasami chcemy napisać test korzystający wyłącznie z jednej 'prawdziwej' zależności.

Testy integracyjne kontrolerów zapewniają nas, że dobrze zarejestrowaliśmy wszystkie zależności, że aplikacja jest w stanie obsłużyć żądania, jak i pozwalają nam sprawdzić integracje z innymi systemami. Testy kontrolerów zawsze pokrywają duży obszar funkcjonalności, gdyż często są to jedyne punkty wejściowe naszej aplikacji. Każde żądanie do nich zawiera pełny proces (bądź podproces), który musi się wykonać. Testy integracyjne można jednak pisać dla znacznie węższych zagadnień (co zaleca między innymi Martin Fowler) – np. testy integracyjne repozytorium do bazy danych. Każdy test integracyjny powinien potrafić uruchomić wszystkie swoje zależności lokalnie, tak więc odpalić instancję MySQL, bądź wystartować inny serwis.

Testy integracyjne – implementacja

Let’s code! Zacznijmy od stworzenia projektów:

mkdir Movies
cd Movies
dotnet new webapi -n Movies.Api
dotnet new xunit -n Movies.Tests
dotnet new sln
dotnet sln add Movies.Tests
dotnet sln add Movies.Api

Doinstalujmy kilka paczek do naszego projektu z API:

<PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="5.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="5.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="5.0.1">

Następnie przygotujmy kod odpowiedzialny za obsługę bazy danych. W kolejnym kroku zarejestrujemy ją przy użyciu bazy danych w pamięci (będzie to wystarczające dla naszego eksperymentu).

 namespace Movies.Api.Database.Entities
 {
     public class Movie
     {
         public int Id { get; set; }
         public string Title { get; set; }
         public decimal Rating { get; set; }
     }
 }
 using Microsoft.EntityFrameworkCore;
 using Movies.Api.Database.Entities;
 namespace Movies.Api.Database
 {
     public class MoviesContext : DbContext
     {
         public MoviesContext(DbContextOptions options) : base(options) { }
         public DbSet<Movie> Movies { get; set; }
     }
 }
 public void ConfigureServices(IServiceCollection services)
 {
     services.AddDbContext(builder =>
         builder.UseInMemoryDatabase("Movies"));
     services.AddControllers();
     services.AddSwaggerGen(c =>
     {
         c.SwaggerDoc("v1", new OpenApiInfo { Title = "Movies.Api", Version = "v1" });
     });
 }

Następnie stwórzmy kontroler, który będzie obsługiwać podstawowe akcje. W tym celu kliknij prawym przyciskiem myszy na folder Controllers, następnie wybierz Add Controller. Na kolejnym oknie wybierz API oraz API Controller with actions, using Entity Framework.

W ten sposób zyskaliśmy kontroler, który możemy przetestować! Możesz śmiało się teraz zapoznać z jego kodem. Zanim przejdziemy do projektu testowego, dodajmy metodę, która zainicjuje naszą bazę przykładowymi danymi (gdyż korzystamy z bazy danych w pamięci). Następnie wywołajmy ją w metodzie ConfigureServices.

public static IServiceCollection InitData(this IServiceCollection services)
 {
     var provider = services.BuildServiceProvider();
     using var context = provider.GetRequiredService();
     context.Movies.AddRange(
         new Movie
         {
             Title = "Joker",
             Rating = 8.7m
         }, 
         new Movie
         {
             Title = "Batman",
             Rating = 9.2m
         });
     context.SaveChanges();
     return services;
 }

Czas przejść do mięska! Zobaczmy implementację podstawowego testu integracyjnego.

public class MoviesControllerTests : IClassFixture<WebApplicationFactory<Startup>>
{
 private readonly HttpClient _client;
 public MoviesControllerTests(WebApplicationFactory<Startup> fixture)
 {
     _client = fixture.CreateClient();
 }

 [Fact]
 public async void Get_Should_ReturnListOfMovies()
 {
     var response = await _client.GetAsync("api/movies");
     var movies = await response.Content.ReadFromJsonAsync<Movie[]>();

     Assert.Equal(200, (int)response.StatusCode);
     Assert.NotNull(movies);
     Assert.All(movies, Assert.NotNull);
 }
}

Korzystamy tutaj z klasy WebApplicationFactory, która zawiera kod potrzebny do uruchomienia aplikacji internetowej. Aby z niej skorzystać, należy doinstalować paczkę Microsoft.AspNetCore.Mvc.Testing.

Scenariusz zaawansowany

Powyższy kod sprawdzi się wyłącznie dla prostych aplikacji. Nasza aplikacja korzysta z bazy danych w pamięci, podczas gdy prawdziwe systemy muszą łączyć się z prawdziwymi bazami. Dla testów kontrolerów zależności te musimy zastąpić sztucznymi. Zobaczmy, jak sobie z tym można poradzić! Stwórzmy własną implementację klasy WebApplicationFactory.

    public class MoviesApplicationFactory : WebApplicationFactory<Startup>
    {
        protected override void ConfigureWebHost(IWebHostBuilder builder)
        {
            // replace database   
            builder.ConfigureServices(services =>
            {
                var descriptor = services.SingleOrDefault(
                    d => d.ServiceType ==
                         typeof(DbContextOptions<MoviesContext>));

                services.Remove(descriptor);

                services.AddDbContext<MoviesContext>(options =>
                {
                    options.UseInMemoryDatabase("InMemory");
                });
            });

            // Replacing services with test services
            builder.ConfigureTestServices(services =>
                services.AddScoped<ISample, Sample>());

            // Replace configuration
            builder.ConfigureAppConfiguration(config =>
            {
                config.AddConfiguration(
                    new ConfigurationBuilder()
                        .AddJsonFile("appsettings.integrationtests.json")
                        .Build());
            });
        }
    }

W ten sposób możemy w łatwy sposób konfigurować zachowanie naszych testów. Czyż nie jest to proste i przyjemne? W ten sposób możemy podmienić między innymi bazę danych, jednak warto pamiętać, że testy integracyjne z bazą danych również powinny być zaimplementowane. Nie są jednak częścią testów kontrolera. W celu użycia powyższej fabryki w naszym teście musimy zmienić kod testów, jak i dodać plik konfiguracyjny zdefiniowany w fabryce (bądź usunąć ten fragment kodu – i tak nie jest wykorzystywany).

public class MoviesControllerTests : IClassFixture<MoviesApplicationFactory>
{
 private readonly HttpClient _client;
 public MoviesControllerTests(MoviesApplicationFactoryfixture)
 {
     _client = fixture.CreateClient();
 }
// ...
}

Dodawanie encji

A co z testem dodawania filmu? Możemy sprawdzić status i odpowiedź w taki sam sposób jak przy GET. Jeśli jednak chcemy sprawdzić, czy film rzeczywiście został dodany do bazy, to możemy zastosować taką technikę:

   public class MoviesControllerTests
        : IClassFixture<MoviesApplicationFactory>
    {
        private readonly HttpClient _client;
        private readonly IServiceProvider _serviceProvider;

        public MoviesControllerTests(MoviesApplicationFactory fixture)
        {
            _client = fixture.CreateClient();
            _serviceProvider = fixture.Services;
        }

        [Fact]
        public async void Post_Should_CreateNewMovie()
        {
            using var scope = _serviceProvider.CreateScope();
            var context = scope.ServiceProvider.GetRequiredService<MoviesContext>();

            var response = await _client.PostAsync("api/movies",
                new StringContent(@"  
				{
					""id"": 0,
					""title"": ""Harry Potter"",
					""rating"": 7
				}", Encoding.UTF8, "application/json"));

            var created = await response.Content.ReadFromJsonAsync<Movie>();
            Assert.Equal(201, (int)response.StatusCode);
            Assert.True(await context.Movies.AnyAsync(m =>
                m.Id == created.Id &&
                m.Title == created.Title &&
                m.Rating == created.Rating));
        }


        [Fact]
        public async void Get_Should_ReturnListOfMovies()
        {
            var response = await _client.GetAsync("api/movies");
            var movies = await response.Content.ReadFromJsonAsync<Movie[]>();

            Assert.Equal(200, (int)response.StatusCode);
            Assert.NotNull(movies);
            Assert.All(movies, Assert.NotNull);
        }
    }

Jak widać, poprzez skorzystanie z dostawcy serwisów możemy utworzyć scope oraz pobrać z niego interesujące nas zależności. Dzięki temu nasz kod już jest pełny! W ramach ćwiczenia możesz napisać testy dla pozostałych metod tego kontrolera.

Bonus – zabezpieczone endpointy

Nasze systemy są często zabezpieczone, co mogłoby utrudnić nam implementacje testów integracyjnych. Nie chcemy musieć w każdym teście pisać odpowiedniego kodu do pobierania i używania tokenu, o ile to jest po prostu możliwe. Na szczęście jest na to prosta sztuczka:

    public class MoviesApplicationFactory : WebApplicationFactory<Startup>
    {
        protected override void ConfigureWebHost(IWebHostBuilder builder)
        {
            builder.ConfigureServices(services =>
            {
                services.AddScoped<IAuthorizationEvaluator, MockAuthorizationEvaluator>();
            });
        }

        private class MockAuthorizationEvaluator : IAuthorizationEvaluator
        {
            public AuthorizationResult Evaluate(AuthorizationHandlerContext context)
                => AuthorizationResult.Success();
        }
    }

Dzięki takiej konfiguracji w fabryce naszej aplikacji możemy obejść zabezpieczenia. Szybko i prosto. Tak jak lubimy!

Podsumowanie

Testy integracyjne stanowią ważną część naszego systemu. Pozwalają zweryfikować naszą aplikację w szerokim kontekście, włączając w to infrastrukturę. Testy kontrolerów zapewniają nam poprawne działanie warstwy wejściowej do naszej aplikacji. Warto pamiętać, by w testach kontrolerów starać się nie wykorzystywać prawdziwych zależności zewnętrznych. One powinny mieć swoje testy integracyjne.

To już koniec kolejnego wpisu w Cesarstwie-Dev. Jeśli temat testów Cię nie znudził, to możesz śmiało przeskoczyć do podobnego wpisu, klikając tutaj. Ja bardzo Ci dziękuję za Twoją obecność na tej stronie i gorąco zachęcam do przejrzenia innych wpisów, jak i zapisania się do listy subskrybentów z prawej strony. Dzięki temu nie ominie Cię żaden wpis!

Kategorie: Techniczne

3 Komentarze

Leszek · 2021-02-23 o 22:32

Swietny artykul!

Ps. czy baza InMemory nie musi miec indywidualnej nazwy (a wiec nie byc dzielona) per test?

    Cesarz · 2021-02-24 o 07:06

    Cześć, rzeczywiście zwykle nadaję nazwę bazy za pomocą Guid.NewGuid().ToString(), dzięki czemu nie jest współdzielona. Musiałem to przeoczyć.
    Dziękuje za miły komentarz! 🙂

dotnetomaniak.pl · 2020-12-13 o 23:07

Testy integracyjne kontrolerów – Cesarstwo Dev

Dziękujemy za dodanie artykułu – Trackback z dotnetomaniak.pl

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Wymagane pola są oznaczone *