Testy jednostkowe w Entity Framework Core 5

Testy są nieodłączną częścią tworzenia oprogramowania. Są to oddzielne programy, które pozwalają sprawdzić, czy napisany przez nas kawałek program robi dokładnie to, co powinien. Testy jednostkowe są małymi fragmentami kodu, które testują pojedyncze elementy programu a w Entity Framework Core 5 pisze się je zaskakująco łatwo.

W pamięci, czy nie

Microsoft zaleca, żeby przy pisaniu testów używających EF Core, używać prawdziwej bazy danych, jeżeli to tylko możliwe. W zasadzie najlepiej używać bazy danych dokładnie w tej samej konfiguracji i na tym samym serwerze, na którym ma działać nasza aplikacja finalnie. Takie podejście może nie mieć sensu ze względu na koszty, co przyznaje również Microsoft. Testy wydajnościowe z pewnością powinny sprawdzać nasze rozwiązania na środowisku możliwie zbliżonym do produkcyjnego. Natomiast przy pisaniu testów jednostkowych wystarczy nam trzymanie bazy danych w pamięci. Entity Framework Core pozwala na działanie na wirtualnej bazie danych, tworzonej jedynie w pamięci. Możemy tez wykorzystać bazę SQLite, ponieważ działa szybko i nie potrzebuje serwera. Ma także tryb, w którym może działać w pamięci. W tym rozdziale nie będziemy się wgłębiali w użycie SQLite do testów, jednak mogę Cię zapewnić, że nie wymaga to wiele zachodu.

Pisanie testów jednostkowych

Entity Framework Core 5 bardzo łatwo skonfigurować do działania w pamięci i w projekcie z testami, wystarczy doinstalować pakiet NuGet o nazwie Microsoft.EntityFrameworkCore.InMemory. Przydadzą się też inne pakiety, a oto ich cała lista:

  • Microsoft.EntityFrameworkCore.InMemory – to run EF Core 5 in memory
  • NUnit – a framework to write and run unit tests
  • NUnit3TestAdapter – an adapter to run NUnit tests in Visual Studio
  • FluentAssertions – easy library to write nice and readable assertions

Do testów użyję klasy ReservationController, którą po części pokazywałem już wcześniej. Oto jej pełna treść:

[ApiController]
[Route("[controller]")]
public class ReservationsController : ControllerBase
{
    private readonly PrimeDbContext primeDbContext;

    public ReservationsController(PrimeDbContext _primeDbContext)
    {
        primeDbContext = _primeDbContext;
    }

    [HttpGet]
    public async Task<IEnumerable<Reservation>> Get()
    {
        return await primeDbContext.Reservations.Include(r => r.Room).AsNoTracking().ToListAsync();
    }

    [HttpGet("{id}")]
    public async Task<IActionResult> GetById(int id)
    {
        var reservation = await primeDbContext.Reservations.FindAsync(id);
        if (reservation == null)
        {
            return NotFound();
        }

        await primeDbContext.Entry(reservation).Collection(r => r.Profiles).LoadAsync();
        await primeDbContext.Entry(reservation).Reference(r => r.Room).LoadAsync();

        return Ok(reservation);
    }

    [HttpPost]
    public async Task<IActionResult> Post([FromBody] NewReservation newReservation)
    {
        var room = await primeDbContext.Rooms.FirstOrDefaultAsync(r => r.Id == newReservation.RoomId);
        var guests = await primeDbContext.Profiles.Where(p => newReservation.GuestIds.Contains(p.Id)).ToListAsync();

        if (room == null || guests.Count != newReservation.GuestIds.Count)
        {
            return NotFound();
        }

        var reservation = new Reservation
        {
            Created = DateTime.UtcNow,
            From = newReservation.From.Value,
            To = newReservation.To.Value,
            Room = room,
            Profiles = guests
        };

        var createdReservation = await primeDbContext.Reservations.AddAsync(reservation);
        await primeDbContext.SaveChangesAsync();

        return Ok(createdReservation.Entity.Id);
    }
}

Klasę testową nazwałem ReservationControllerTests, czyli jest to nazwa klasy i końcówka Tests na końcu. W tych testach skupię się na sprawdzeniu jak podmienić dane w Entity Framework Core, a nie aby przetestować wszystkie możliwe przypadki.

Podstawą tutaj jest odpowiednie przygotowanie PrimeDbContext do testowania. Sama podstawa klasy z testami wygląda następująco:

public class ReservationsControllerTests
{
    private DbContextOptions<PrimeDbContext> dbContextOptions = new DbContextOptionsBuilder<PrimeDbContext>()
        .UseInMemoryDatabase(databaseName: "PrimeDb")
        .Options;
    private ReservationsController controller;

    [OneTimeSetUp]
    public void Setup()
    {
        SeedDb();

        controller = new ReservationsController(new PrimeDbContext(dbContextOptions));
    }

    private void SeedDb()
    {
        using var context = new PrimeDbContext(dbContextOptions);
        var rooms = new List<Room>
        {
            new Room { Id = 1, Description = "Room nr 1", Number = 1, Level = 1, RoomType = RoomType.Standard },
            new Room { Id = 2, Description = "Room nr 2", Number = 2, Level = 1, RoomType = RoomType.Standard },
            new Room { Id = 3, Description = "Room nr 3", Number = 3, Level = 2, RoomType = RoomType.Suite }
        };

        var profiles = new List<Profile>
        {
            new Profile { Id = 1, Ref = "Profile 1", Forename = "Michał", Surname = "Białecki" },
            new Profile { Id = 2, Ref = "Profile 2", Forename = "John", Surname = "Show" },
            new Profile { Id = 3, Ref = "Profile 3", Forename = "Daenerys", Surname = "Targaryen" }
        };

        context.AddRange(rooms);
        context.AddRange(profiles);

        context.AddRange(new List<Reservation>
        {
            new Reservation
            { 
                Id = 1,
                Room = rooms[0],
                Profiles = new List<Profile>{ profiles[0] },
                From = DateTime.Today,
                To = DateTime.Today.AddDays(2)
            },
            new Reservation
            {
                Id = 2,
                Room = rooms[2],
                Profiles = new List<Profile>{ profiles[1], profiles[2] },
                From = DateTime.Today.AddDays(1),
                To = DateTime.Today.AddDays(3)
            }
        });

        context.SaveChanges();
    }
}

Pierwsza rzecz, która od razu zwaraca naszą uwagę, to metoda SeedDb, która służy do dodania danych testowych do kontektsu EF Core. Dla tych testów, dane zostaną wprowadzone tylko raz, na samym początku dzięki użyciu atrybutu [OneTimeSetUp]. Stan bazy danych zostanie zachowany dopóki działa proces, który te testy wykonuje. Jednak ważniejszy fragment znajduje się na samej górze, czyli utworzenie dbContextOptions. Zauważ, że to właśnie tam używamy opcji UseInMemoryDatabase, a następnie przy pomocy tego obiektu tworzymy klasę PrimeDbContext. Przy tworzeniu podajemy nazwę bazy danych i używamy zawsze tej samej.

Kolejna bardzo istotna linia to:

using var context = new PrimeDbContext(dbContextOptions);

Na początku używamy słowa kluczowego using, ponieważ nie chcemy aby Garbage Collector usunął nam zmienną context z pamięci w trakcie wykonywania testu.

Skoro mamy już skonfigurowaną bazę oraz dane, to czas na testy:

[Test]
public async Task Get_FetchesReservationsWithoutRoomsAndGuests()
{
    using var context = new PrimeDbContext(dbContextOptions);
    var reservations = (await controller.Get()).ToList();

    reservations.Count.Should().Be(2);
    reservations.All(r => r.Room == null).Should().BeFalse();
    reservations.All(r => r.Profiles == null).Should().BeTrue();
}

W pierwszym teście pobieramy wszystkie rezerwacje i sprawdzamy, czy ich zależności są załadowane. W tym przypadku tak nie będzie, ponieważ metoda Get w kontrolerze, nie wymusza ładowania zależności. Przetestujmy kolejną metodę.

[Test]
public async Task GetById_WhenIdIsProvided_FetchesReservationWithRoomsAndGuests()
{
    using var context = new PrimeDbContext(dbContextOptions);
    var result = await controller.GetById(2);
    var okResult = result.As<OkObjectResult>();
    var reservation = okResult.Value.As<Reservation>();

    reservation.Should().NotBeNull();
    reservation.Profiles.Should().NotBeNull();
    reservation.Room.Should().NotBeNull();
}

W drugim teście pobieramy pojedynczą rezerwację i tutaj sprawdzamy, że zarówno pokój, jak i profile są załadowane. Dzieje się tak, ponieważ w metodzie GetById używamy metod Collection i Reference, aby te zależności załadować. Przetestujmy teraz metodę Post.

[Test]
public async Task Post_WithRoomAndProfiles_AddsReservation()
{
    var newReservation = new NewReservation
    {
        From = DateTime.Today.AddDays(3),
        To = DateTime.Today.AddDays(7),
        RoomId = 3,
        GuestIds = new List<int> { 2 }
    };

    using var context = new PrimeDbContext(dbContextOptions);
    var result = await controller.Post(newReservation);

    var okResult = result.As<OkObjectResult>();
    var reservationId = okResult.Value.As<int>();
    var addedReservation = await context.Reservations
        .Include(p => p.Profiles)
        .Include(r => r.Room)
        .FirstOrDefaultAsync(r => r.Id == reservationId);

    addedReservation.Should().NotBeNull();
    addedReservation.Profiles.Should().NotBeNull();
    addedReservation.Profiles.Count.Should().Be(1);
    addedReservation.Profiles[0].Id.Should().Be(2);
    addedReservation.Room.Should().NotBeNull();
    addedReservation.Room.Id.Should().Be(3);
}

Ostatni test sprawdza, czy dodana rezerwacja, została dodana poprawnie. Sprawdzamy czy pokój i profil gościa, został odpowiednio przypisany do nowej rezerwacji.

Podsumowanie

Testy jednostkowe w Entity Framework Core piszę się naprawdę prosto i zrozumiale. Zaledwie kilka linii konfiguracji pozwala nam używać klasy dbContext do przygotowania żądanego stanu bazy danych. Nie musimy oddzielnie podmieniać poszczególnych kolekcji w PrimeDbContext jak to było w przypadku testów Entity Framework. Pod tym względem Entity Framework Core jest dopracowany, a testy jednostkowe z jego użyciem nie odstają znacząco od jakichkolwiek innych testów jednostkowych. Praca z nimi jest łatwa i przyjemna, czyli dokładnie tak, jak powinno być.

Cały zamieszczony tutaj kod dostępny jest ma moim GitHub, zerkaj do woli. Jest to projekt demonstracyjny co potrafi EF Core 5, więc zachęcam do eksploracji na własnę rękę!

Dzięki za przeczytanie i do zobaczenia 😊

 

5 thoughts on “Testy jednostkowe w Entity Framework Core 5

  1. Kajetan

    Kiedyś widziałem dyskusję na temat testowania bazy za pomocą in-memory. W sumie doszło do wniosków, że nie można nazwać tego testami jednostkowymi przez jednak zależność, która jeszcze na dodatek nie odzwierciedla rzeczywistości. Czyli testy jednostkowe w sumie wychodzą bardziej jak integracyjne.

    Reply
  2. Adamus

    Super artykuł, na co dzień nie pracuje z .net core ale nie znałem fluent assertions a wygląda naprawdę spoko, zapisuje bloga do ulubionych 🙂
    Jedna uwaga- biorąc pod uwagę implementacje to test powinien nazywać się Get_FetchesReservationsWithRoomsAndWithoutGuests() – include mamy tylko na pokojach.

    Pozdrawiam!

    Reply
  3. Paweł

    Hej. W jednym z projektów wykorzystuję widoki. Jak przetestować ich integralność? Miałem ostatnio taki przypadek, że zmienił się schemat bazy danych i pomimo wykonania metody pobierającej dane z widoku w InMemory test przeszedł a na realnej bazie już nie. Jakiś pomysł jak można takie błędy wyłapać?

    Reply
    1. Michał Białecki Post author

      Hej Paweł,
      To dość ciekawe pytanie, czyli skąd bierze się schemat bazy danych, kiedy baza jest tworzona w InMemory. Przeczytałem szybko dokumentację i wychodzi na to, że schemat jest tworzony na podstawie encji oraz metody `OnModelCreating`. Czy mógłbyś mi powiedzieć jak w Twoim przypadku zmienił się schemat bazy danych? czy został zmieniony poza modelami EF Core?

      Reply

Leave a Reply

Your email address will not be published. Required fields are marked *