MemoryCzęść NR.4 Jak testować warstwę dostępu do danych. Czy już na tym etapie jest na potrzebna baza danych? Oczywiście, że nie? Czy musimy tworzyć jakieś statyczne listy, które taką bazę mają symulować? Nie

Wystarcz skorzystać z frameworka Entity Framework, który pozwala na utworzenie bazy testowej w twojej pamięci RAM. Gdy będziesz na produkcji wtedy podłączysz się do prawdziwej bazy z prawidzywmi danymi. Zrobimy to w projekcie ASP.NET CORE.

Jakie mamy wymagania w warstwie dostępu do danych:

  • Chcemy zwracać wszystkie gry dostępne w bazie
  • Chcemy mieć metodę "IsGameAvailable" do sprawdzania czy dana gra jest dostępna do zakupu
  • Chcemy sprawdzić czy zapisujemy zakup/zamówienie do naszego źródła danych, gdy zrobimy metodę Save
  • Chcemy mieć możliwość wyświetlenia wszystkich zamówień/zakupów zaczynając od najstarszych zamówień.

Zanim przejdziemy do następnego przykładu zróbmy porządek w naszym projekcie.

Stworzenie folderu Domain w Visual Studio

Umieściłem prawie wszystkie pliki, które definiują abstrakcyjne obiekty określające Grę, Zapytanie, Odpowiedź do folderu Domain.

Dodajemy nowy projekt do folderu DataAccess

Przechodzimy dziś do folderu DataAccess. Pora stworzyć nasz nowy projekt testowy do warstwy danych.

Nowy projekt xUnit

Wybieram znowu projekt testowy xUnit i nazywam go  "GameShop.DataAccess.Test".

Zmieniam też domyślną przestrzeń nazw projektu testowego. Przyda nam się to później, gdy będziemy przenosić kod do prawdziwego projektu nietestowego.

Zmiana przestrzeni nazw

Już w warstwie logiki biznesowej wiemy, że mamy przynajmniej dwa repozytoria. Tworzymy więc do nich testy w osobnych klasach. Tak pamiętam w warstwie logiki biznesowej tworzyliśmy MOCK-i do tych repozytoriów. Teraz pora przetestować na tym poziomie aplikacji działania ich implementacji.

Projekt testowy w DataAccess

Nie chcemy jednak by nasze repozytoria korzystały z prawdziwej bazy. Na szczęście Entity Framework pozwala nam utworzenie bazy danej w pamięci RAM.

Musimy do projektu zainstalować odpowiednie paczki NuGet by to zrobić.

Manage NuGet Packages

Szukamy paczki Microsoft.EntityFrameworkCore.InMemory

Paczka NuGet Microsoft.EntityFrameworkCore.InMemory

Instalujemy też paczkę Moq potrzebną do tworzenia fałszywych obiektów.

Paczka NuGet Moq

Teraz możemy pisać testy. Stawiamy sobie dwa wymagania na początek:

  • Chcemy zwracać wszystkie gry dostępne w bazie
  • Chcemy mieć metodę "IsGameAvailable" do sprawdzania czy dana gra jest dostępna do zakupu

Te wymagania dotyczą repozytorium GameRepository więc zaczynamy pisać pierwszą metodę testową w klasie GameRepositoryTests. Stwórzmy test, który sprawdzi czy zwracamy wszystkie gry.

[Fact]
public void ShouldGetAllGames()
{

}

Bez kontekstu źródła danych nie ruszymy się dalej z tym kodem.  Tworzymy więc taki kontekst musi on dziedziczyć po klasie DbContext z Entity Framework.

public class GameContext : DbContext
{
}

Do kontekstu konstruktora dodajemy możliwości przekazywania opcji naszego proksy do źródła danych. W projekcie testowym tutaj powiemy, że chcemy mieć bazę w pamięci RAM, a w prawdziwym projekcie przekażemy prawdziwe połączenie do bazy danej SQL Server.

public class GameContext : DbContext
{
    public GameContext(DbContextOptions<GameContext> options) :
        base(options)
    {
    }
}

Teraz dodamy do kontekstu referencję do kolekcji gier. Ta kolekcja jest mapowana na tabelkę w bazie danych SQL. Jak widzisz dodajemy referencję do projektu GameShop.Core by mieć dostęp do klasy Game.

Dodanie referencji GameShop.Core

Do samej klasy gry dodam też właściwość określający jej nazwę oraz jej dostępność. Na razie mieliśmy tylko ID.

namespace GameShop.Core
{
    public class Game
    {
        public int Id { get; set; }

        public string Name { get; set; }

    }
}

Teraz możemy napisać trochę kodu testowego. Aby testy nie miały wpływy na siebie będziemy tworzyć bazę w pamięci RAM w każdej metodzie oddzielnie.

Korzystając z metody "UseInMemoryDatabase" tworzymy bazę danych.

[Fact]
public void ShouldGetAllGames()
{
    // Arrange
    var options = new DbContextOptionsBuilder<GameContext>()
      .UseInMemoryDatabase(databaseName: "ShouldGetAllGames")
      .Options;

    var storedList = new List<Game>
    {
                new Game() { Name="M" },
                new Game() { Name="N" },
                new Game() { Name="O" }
    };

    using (var context = new GameContext(options))
    {
        foreach (var game in storedList)
        {
            context.Add(game);
            context.SaveChanges();
        }
    }

    // Act
    // Assert
}

Naszym celem jest dodanie 3 gier do bazy w pamięci RAM później chcemy sprawdzić czy nasze repozytorium wyświetli wszystkie gry. Tylko zaraz my nie mamy repozytorium. Na chwilę obecną nawet interfejs IGameRepository nie ma metody do zwracania wszystkich gier.

Wracamy więc na chwilę do projektu GameShop.Core i poprawiamy interfejs.

public interface IGameRepository
{
    bool IsGameAvailable(Game game);

    IEnumerable<Game> GetAll();
}

Teraz w projekcie GameShop.DataAccess.Test tworzymy nowe repozytorium. Repozytorium przyjmie do siebie kontekst źródła danych.

public class GameRepository : IGameRepository
{
    private GameContext _context;

    public GameRepository(GameContext context)
    {
        _context = context;
    }

    public IEnumerable<Game> GetAll()
    {
        throw new NotImplementedException();
    }

    public bool IsGameAvailable(Game game)
    {
        throw new NotImplementedException();
    }
}

Mogę teraz skończyć swój test i przejść do fazy green.

[Fact]
public void ShouldGetAllGames()
{
    // Arrange
    var options = new DbContextOptionsBuilder<GameContext>()
      .UseInMemoryDatabase(databaseName: "ShouldGetAllGames")
      .Options;

    var storedList = new List<Game>
    {
        new Game() { Name="M", IsAvailable=false },
        new Game() { Name="N", IsAvailable=true  },
        new Game() { Name="O", IsAvailable=true  }
    };

    using (var context = new GameContext(options))
    {
        foreach (var game in storedList)
        {
            context.Add(game);
            context.SaveChanges();
        }
    }

    // Act
    List<Game> actualList;
    using (var context = new GameContext(options))
    {
        var repository = new GameRepository(context);
        actualList = repository.GetAll().ToList();
    }

    // Assert
    Assert.Equal(storedList.Count(), 
        actualList.Count());
}

Test kończy się na czerwono pora więc na implementację naszych metod. Napisałem też metodę IsGameAvailable. Myślę, że na tym etapie rozumiesz o co chodzi z cyklem TDD RED-GREEN-REFACTOR.

public IEnumerable<Game> GetAll()
{
    return _context.Games.ToList();
}


public bool IsGameAvailable(Game game)
{
    throw new NotImplementedException();
}

Teraz test zostanie zakończony sukcesem.

Wszystkie testy GREEN w XUnit

Przechodzimy do następnego wymagania. Będziemy teraz pisać test, który pokaże nam jak działa sprawdzenie dostępności gry. W sumie to ciągle operujemy na MOCK-ach w warstwie logiki biznesowej, że w ogóle nie wiemy jak to ma wyglądać w bazie danych.

Dodanie nowej klasy do Domain

Tworzymy nową klasę, która będzie przechowywać tą informację. Ta klasa będzie w projekcie GameCore w folderze Domain

public class GameShopWarehouseStatus
{
    public int Id { get; set; }

    public int GameId { get; set; }

    public int Amount { get; set; }
}

Ta klasa będzie reprezentowała tabelkę w bazie danych. Zawiera ona ID, klucz obcy do gry oraz informację o ilości produktu w magazynie. W metodzie IsGameAvaliable będziemy sprawdzać czy dana gra o danym ID istnieje jeszcze w magazynie.

Do kontekstu dodajemy nową referencję do tej tabelki

public class GameContext : DbContext
{
    public DbSet<Game> Games { get; set; }
    
    public DbSet<GameShopWarehouseStatus> ShopWarehouseStatus { get; set; }

Nasz testowa metoda wygląda następująco :

[Fact]
public void ShouldCallIsGameAvaliableWithFalse()
{
    // Arrange
    var date = new DateTime(2020, 1, 25);

    var options = new DbContextOptionsBuilder<GameContext>()
      .UseInMemoryDatabase(databaseName: "ShouldCallIsGameAvaliableWithFalse")
      .Options;

    Game game1 = new Game { Id = 1, Name = "M" };
    Game game2 = new Game { Id = 2, Name = "K" };
    Game game3 = new Game { Id = 3, Name = "I" };
    using (var context = new GameContext(options))
    {
        context.Games.Add(game1);
        context.Games.Add(game2);
        context.Games.Add(game3);

        context.ShopWarehouseStatus
            .Add(new GameShopWarehouseStatus { Id = 1, GameId = 1, Amount = 0 });
        context.ShopWarehouseStatus
            .Add(new GameShopWarehouseStatus { Id = 2, GameId = 2, Amount = 8 });
        context.ShopWarehouseStatus
            .Add(new GameShopWarehouseStatus { Id = 3, GameId = 3, Amount = 1 });

        context.SaveChanges();
    }

    using (var context = new GameContext(options))
    {
        var repository = new GameRepository(context);

        // Act
        var status1 = repository.IsGameAvailable(game1);
        var status2 = repository.IsGameAvailable(game2);
        var status3 = repository.IsGameAvailable(game3);

        // Assert
        Assert.Equal(false, status1);
        Assert.Equal(true, status2);
        Assert.Equal(true, status3);
    }
}

Tworzymy bazę danych w pamięci RAM. Dodajemy do niej 3 gry oraz 3 rekordy określające ile tej gry jest w magazynie. Później 3 razy wywołuje metodę IsGameAvailable i sprawdzam czy te 3 rezultaty są poprawne. Test oczywiście wychodzi na czerwono, ponieważ nie mamy logiki, która obsługuję tą metodę. W repozytorium piszemy następujący kod:

public bool IsGameAvailable(Game game)
{
    var shopWarehouseStatus =
        _context.ShopWarehouseStatus.
        FirstOrDefault(x => x.GameId == game.Id);

    if (shopWarehouseStatus == null)
        return false;

    return shopWarehouseStatus.Amount > 0;
}

Testy z tym repozytorium są już skończone. Mamy zielone lampki. Pora na repozytorium zakupów.

Wszystkie testy są na zielono

Chcemy spełnić dwa wymagania. Chcemy mieć możliwość zapisu zakupionej gry. W swoją drogą powinniśmy na to mówić zamówienie. Chcemy także otrzymywać listę zamówień zakupionych gier zaczynając od najstarszego zamówienia.

Oto dwie metody testowe, które sprawdzą te przypadki.

public class GameBuyingRepositoryTest
{
    [Fact]
    public void ShouldSaveTheGameBoughtOrder()
    {

    }

    [Fact]
    public void ShouldGetAllGameBoughtOrdersByDate()
    {

    }
}

Pora też na mały refactoring. Zmieniamy nazwę klasy GameBought na GameBoughtOrder. Pamiętaj, że możesz zmienić nazwę każdej metody, klasy w wielu miejscach w prosty sposób. Musisz tylko skorzystać z opcji meni Rename.

Zmiana nazwy przy pomocy Rename Visual Studio

Mamy więc klasę GameBoughtOrder

public class GameBoughtOrder : GameBuyingBase
{
    public int GameId { get; set; }
}

Potrzebuje też nowego kontekstu

Dodanie nowej klasy

Oto nasz nowy kontekst

public class GameBoughtOrderContext : DbContext
{
    public DbSet<GameBoughtOrder> Orders { get; set; }


    public GameBoughtOrderContext(DbContextOptions
        <GameBoughtOrderContext> options) :
        base(options)
    {
    }
}

Zmieniłem też nazwę klasy testowej na GameBoughtOrdersRepositoryTest by się nie gubić i jasno określić styl nazewniczy.

Zmiana nazwy przy pomocy Rename

Pora napisać nasz test. Tworzymy bazę danych w pamięci RAM i używając metody Save w repozytorium chcemy się upewnić, że nasze zamówienie zostało zapisane w bazie.

[Fact]
public void ShouldSaveTheGameBoughtOrder()
{
    // Arrange
    var options = new DbContextOptionsBuilder
        <GameBoughtOrderContext>()
      .UseInMemoryDatabase(databaseName: "ShouldSaveTheGameBoughtOrder")
      .Options;

    var gameOrder = new GameBoughtOrder
    {
        FirstName = "Cezary",
        LastName = "Walenciuk",
        Date = new DateTime(2020, 6, 25),
        Email = "walenciukC@gmail.com",
        GameId = 1
    };

    // Act
    using (var context = new GameBoughtOrderContext(options))
    {
        var repository = new GameBoughtOrderRepository(context);
        repository.Save(gameOrder);
    }

    // Assert
    using (var context = new GameBoughtOrderContext(options))
    {
        var orders = context.Orders.ToList();

        Assert.Equal(1, orders.Count);
        var storedGameOrder = orders.Single();

        Assert.Equal(gameOrder.FirstName, storedGameOrder.FirstName);
        Assert.Equal(gameOrder.LastName, storedGameOrder.LastName);
        Assert.Equal(gameOrder.Email, storedGameOrder.Email);
        Assert.Equal(gameOrder.GameId, storedGameOrder.GameId);
        Assert.Equal(gameOrder.Date, storedGameOrder.Date);
    }
}

Sprawdzam czy rekord istnieje. Sprawdzam też czy zostały zapisane poprawne dane.

Nie mamy jeszcze klasy "GameBoughtOrderRepository".

public class GameBoughtOrderRepository : IGameBuyingRepository
{
    private GameBoughtOrderContext _context;

    public GameBoughtOrderRepository
        (GameBoughtOrderContext context)
    {
        _context = context;
    }

    public int Save(GameBoughtOrder gameBought)
    {
        throw new NotImplementedException();
    }
}

Nasz interfejs nazywa się źle więc to poprawiamy używając opcji Rename.

public class GameBoughtOrderRepository : IGameBoughtOrderRepository
{
    private GameBoughtOrderContext _context;

    public GameBoughtOrderRepository
        (GameBoughtOrderContext context)
    {
        _context = context;
    }

    public int Save(GameBoughtOrder gameBought)
    {
        throw new NotImplementedException();
    }
}

Skończyliśmy fazę RED mamy już kod testu, pora na kod, który sprawi, że ten test przejdzie na zielono.

public int Save(GameBoughtOrder gameBought)
{
    _context.Orders.Add(gameBought);

    return gameBought.GameId;
}

Teraz pisząc kod obsługujący metodę Save zdałem sobie sprawę, że nie mamy ID zakupu w klasie GameBoughtOrder.

GameId to nie ID zakupu czyż nie. Wcześniej nie było tego problemu, ponieważ operowałem na MOCK-ach fałszywych obiektach i mogłem tam zwracać jakiekolwiek magiczne cyfry.

Poza tym, jeśli ta klasa reprezentuje tabelkę w bazie danych to potrzebny nam jest klucz główny. Dodajemy więc ID do klasy 

public class GameBoughtOrder : GameBuyingBase
{
    public int Id { get; set; }

    public int GameId { get; set; }

}

Zmieniamy teraz odpowiednio metodę Save. Pamiętaj o zapisie zmian by wysłać je do źródła danych.

public int Save(GameBoughtOrder gameBought)
{
    _context.Orders.Add(gameBought);
    _context.SaveChanges();
    return gameBought.Id;
}

Teraz wszystkie nasze test będą na zielono. W tym wpisie został nam już tylko ostatni przypadek.

Chcemy teraz testować czy zwrócimy kolekcję wszystkich zamówień zaczynając od najstarszego zamówienia.

Tworzymy więc kolejną bazę w pamięci RAM i umieszczamy w niej odpowiednie rekordy zamówień.

// Arrange
var options = new DbContextOptionsBuilder<GameBoughtOrderContext>()
  .UseInMemoryDatabase(databaseName: "ShouldGetAllGameBoughtOrdersByDate")
  .Options;

var storedList = new List<GameBoughtOrder>
{
    CreateGameOrder(1,new DateTime(2020, 6, 27)),
    CreateGameOrder(2,new DateTime(2020, 6, 25)),
    CreateGameOrder(3,new DateTime(2020, 6, 29))
};

var expectedList = storedList.OrderBy(x => x.Date).ToList();

using (var context = new GameBoughtOrderContext(options))
{
    foreach (var order in storedList)
    {
        context.Add(order);
        context.SaveChanges();
    }
}

Ułatwiłem sobie tworzenie zamówień przy pomocy oddzielnej metody

private GameBoughtOrder CreateGameOrder(int id, DateTime dateTime)
{
    return new GameBoughtOrder
    {
        Id = id,
        FirstName = "Cezary",
        LastName = "Walenciuk",
        Date = dateTime,
        Email = "walenciuk@walenciuk.com",
        GameId = 1
    };
}

Później porównamy działanie metody GetAll() z repozytorium (która obecnie nie istnieje) z kolekcją wyciągniętą według kolejności, którą chcemy mieć.

// Act
List<GameBoughtOrder> actualList;
using (var context = new GameBoughtOrderContext(options))
{
    var repository = new GameBoughtOrderRepository(context);
    actualList = repository.GetAll().ToList();
}

//Assert
//CollectionAssert for NUnit
var test = expectedList.SequenceEqual(actualList,
    new GameBoughtEqualityComparer());
Assert.True(test);

Do porównania obu kolekcji korzystam z metody LINQ SequenceEqual. Potrzebuje ona tylko informacji o definicji równości obiektów zamówień.

Jeżeli zamówienia mają inne ID to są one innymi obiektami.

private class GameBoughtEqualityComparer : IEqualityComparer<GameBoughtOrder>
{
    public bool Equals([AllowNull] GameBoughtOrder x,
        [AllowNull] GameBoughtOrder y)
    {

       if (x == null || y == null)
          return false;

        return x.Id == y.Id;
    }

    public int GetHashCode([DisallowNull] GameBoughtOrder obj)
    {
        return obj.Id.GetHashCode();
    }
}

Dodajemy metodę GetAll do repozytorium

public class GameBoughtOrderRepository : IGameBoughtOrderRepository
{
    public IEnumerable<GameBoughtOrder> GetAll()
    {
        throw new NotImplementedException();
    }

Mamy już wszystko by uruchomić test. Jest on oczywiście na czerwono. Dodajemy więc kod obsługujący metodę GetAll()  i...

public class GameBoughtOrderRepository : IGameBoughtOrderRepository
{
    public IEnumerable<GameBoughtOrder> GetAll()
    {
        return
            _context.Orders.OrderBy(k => k.Date);
    }

Wszystkie nasze testy teraz są na zielono.

Wszystkie testy na zielono

Debugując test możesz sprawdzić czy test i implementacja przypadku zostały napisane prawidłowo.

Porównanie kolekcji w xUnit w Debug Visual Studio

Na koniec pozostał nam refactoring. Trzeba przenieść wszystkie klasy nietestowe do osobnego projektu GameShop.DataAccess.

Tworzymy więc kolejny projekt Class Library (.NET Standard)

Stworzenie nowego projektu

Przenosimy odpowiednie pliki do projektu i nasza praca w tej warstwie została skończona.

Przeniesienie plików w Visual Studio

W następnym wpisie zobaczymy jak testować aplikację ASP.NET CORE.

[Fact]
public void ShouldGetAllGames()
{
    // Arrange
    var options = new DbContextOptionsBuilder<GameContext>()
      .UseInMemoryDatabase(databaseName: "ShouldGetAllGames")
      .Options;

    var storedList = new List<Game>
    {
                new Game() { Name="M" },
                new Game() { Name="N" },
                new Game() { Name="O" }
    };

    using (var context = new GameContext(options))
    {
        foreach (var game in storedList)
        {
            context.Add(game);
            context.SaveChanges();
        }
    }

    // Act
    // Assert
}