Jak testować kod używający bazy danych? (EF Core)

0

Zacząłem niedawno pisać sobie CRUDa. Wykorzystuję w nim CQRS i Mediatr. Jako bazę planuję użyć SQLite. Napisałem query, mające za zadanie pobrać wiersz z bazy i go zwrócić. No i nie za bardzo wiem jak ugryźć testowanie takiego kodu.

W klasie testowej chciałbym sobie wsadzić jakieś testowe dane do bazy. I teraz tak:

  1. Jeśli dobrze rozumiem musiałbym napisać query w taki sposób, żeby context przekazywać z zewnątrz jeśli chciałbym testować w pamięci. Ale to wygląda jak głupie rozwiązanie, chyba lepiej wstrzykiwać context bezpośrednio do handlera.

  2. Jeśli chcę testować na fizycznej bazie to czy jest możliwe, żeby aplikacja automatycznie korzystała z jednego connection stringa podczas zwykłej kompilacji i innego podczas puszczania testów? Czy trzeba to zmieniać ręcznie?

  3. W jaki sposób używa się mocka DbContextu? Czy chęć jego użycia nie wymaga pisania aplikacji w sposób z pkt 1? Wiem, że mock to nie to czego potrzebuję, ale gdybym chciał go użyć to jak to zrobić, skoro context jest automatycznie wstrzykiwany przez framework?

Dzięki za pomoc :)

2
  1. Jeśli wstrzykujesz przez konstruktor do handlera to już przekazujesz z zewnątrz, więc trochę nie rozumiem tego punktu ;)
  2. Do testów pewnie masz oddzielny projekt, więc nie widzę problemu. Connection string możesz dostarczyć np. przez zmienną środowiskową (tak samo jak i do apliakcji).
  3. W EF Core do testowania bardziej używa się providera InMemory (https://docs.microsoft.com/pl-pl/ef/core/providers/in-memory/?tabs=dotnet-core-cli), nie wiem czy w ogóle da się sensownie zamockować DbContext.

Wiem, że mock to nie to czego potrzebuję, ale gdybym chciał go użyć to jak to zrobić, skoro context jest automatycznie wstrzykiwany przez framework?

No jest automatycznie wstrzykiwany przez framework, ale w aplikacji, a nie w testach.
W testach robisz sobie przecież:

var options = new DbContextOptionsBuilder<MyDbContext>()
   .UseInMemoryDatabase(databaseName: Guid.NewGuid())
   .Options;

var dbContext = new MyDbContext(options);

var handler = new MyQueryHandler(dbContext);

Czyli tworzysz instancję handlera z DbContextem wykorzystującym provider InMemory.

0

Ja to robię w taki sposób.

  1. Interfejs IDbAccess - interfejs jest implementowany przez klasy, które obsługują konkretne połączenie z bazą danych (np. klasa używająca EfCore lub nHibernate, lub nawet czyste zapytania do bazy), np. tak może wyglądać:
public interface IDbAccess
{
    event EventHandler ItemLoaded;

    Task<T> GetAsync<T>(object id) where T : class;

	Task<IList<T>> GetAsync<T>(QueryFilter<T> filters) where T : class;

    Task<DbError> UpdateAsync<T>(T obj, object id) where T : class;

    Task<DbError> SaveAsync<T>(T obj) where T : class;

    Task<DbError> SaveOrUpdateAsync<T>(T obj) where T : class;

    Task<DbError> DeleteAsync<T>(T obj) where T : class;

    //IQueryable<T> Query<T>();

    void BeginTransaction(IsolationLevel level = IsolationLevel.Unspecified);

    DbError CommitTransaction();

    void RollbackTransaction();

	void SwitchToDb(string dbName);
    Task<DbError> ReattachObject(object obj);
    void CloseReattachedObject(object obj);

    DbError GetLastDbError();

	Task<long> GetCountAsync(string sql, object parameters);
	Task<IEnumerable<T>> QueryAsync<T>(string sql, object parameters);
	Task<IEnumerable<TReturn>> QueryAsync<TFirst, TSecond, TReturn>(string sql, Func<TFirst, TSecond, TReturn> map, object parameters);
}

Nie zwracaj uwagi na szczegóły. On jest robiony pod moje potrzeby, dlatego też możesz zobaczyć kilka dziwnych rzeczy, ale ogólnie tak może to wyglądać.
Następnie do serwisów jest wstrzykiwany obiekt implementujący ten interfejs:

class MyService
{
    IDbAccess db;

    public MyService(IDbAccess db)
    {
      this.db = db;
    }
}

I później jest używany np. tak (to oczywiście uproszczone, bo dochodzi obsługa błędów itd):

public async Task<Client> GetClient(int id)
{
    return await db.GetAsync<Client>(id);
}

A teraz jak wyglądają testy? Wystarczy moq:

Mock<IDbAccess> dbAccessMoq = new Mock<IDbAccess>();
dbAccessMoq.Setup(x => x.GetAsync(It.IsAny<int>())
    .ReturnsAsync(null);

W tym momencie zawsze będzie zwracany null. Zamiast tego możesz zwrócić po prostu jakiegoś utworzonego klienta:

Mock<IDbAccess> dbAccessMoq = new Mock<IDbAccess>();
Client c = new Client();
dbAccessMoq.Setup(x => x.GetAsync(It.IsAny<int>())
    .ReturnsAsync(c);

I tyle. Pamiętaj - nie testujesz bazy danych, tylko swój kod, który ma działać z bazą danych. Więc musisz się upewnić, co się stanie, gdy:

  • IDbAccess wywali wyjątek
  • zwróci nulla
  • zwróci poprawne (oczekiwane) dane
  • zwróci nieoczekiwane dane
0

Można też stawiać w testach aplikacje z innym db providerem, settingsami i testować ją jak faktyczną aplikacje, że całe API, a nie tylko handlery.

2

Najlepiej to separować kod korzystający z bazy od kodu robiącego coś z danymi i wtedy nie ma w ogóle problemu z testowaniem kodu korzystającego z bazy danych.

0
some_ONE napisał(a):
  1. Jeśli wstrzykujesz przez konstruktor do handlera to już przekazujesz z zewnątrz, więc trochę nie rozumiem tego punktu ;)
  2. Do testów pewnie masz oddzielny projekt, więc nie widzę problemu. Connection string możesz dostarczyć np. przez zmienną środowiskową (tak samo jak i do apliakcji).
  3. W EF Core do testowania bardziej używa się providera InMemory (https://docs.microsoft.com/pl-pl/ef/core/providers/in-memory/?tabs=dotnet-core-cli), nie wiem czy w ogóle da się sensownie zamockować DbContext.

Wiem, że mock to nie to czego potrzebuję, ale gdybym chciał go użyć to jak to zrobić, skoro context jest automatycznie wstrzykiwany przez framework?

No jest automatycznie wstrzykiwany przez framework, ale w aplikacji, a nie w testach.
W testach robisz sobie przecież:

var options = new DbContextOptionsBuilder<MyDbContext>()
   .UseInMemoryDatabase(databaseName: Guid.NewGuid())
   .Options;

var dbContext = new MyDbContext(options);

var handler = new MyQueryHandler(dbContext);

Czyli tworzysz instancję handlera z DbContextem wykorzystującym provider InMemory.

O, to wygląda sensownie. To co ja próbowałem robić to testować pełną ścieżkę korzystając z publicznego api modułu. Czyli próbowałem wysłać zapytanie przez mediator i wtedy nie ma możliwości dostarczenia ręcznie swojego contextu (chyba). I wtedy jest zawsze wstrzykiwany context z konfiguracją z głównego projektu i nie jestem w stanie go skonfigurować z poziomu projektu testowego? Ale chyba faktycznie założenie, że mediatr zawsze działa uprości mi znacznie testy xd

Co do korzystania z in-memory providera to czytałem, też w ms docs, że powinno się tego unikać. https://docs.microsoft.com/en-us/ef/core/miscellaneous/testing/

0

Co do korzystania z in-memory providera to czytałem, też w ms docs, że powinno się tego unikać. https://docs.microsoft.com/en-us/ef/core/miscellaneous/testing/

EFCore InMemory różni się zachowaniem od prawdziwej bazy i można się zdziwić przy debugowaniu

Przy InMemory możesz mieć niepoprawny model bazy i w testach będzie Ci wykazywał wszystko na zielono, a przy prawdziwej bazie EF Core od razu wywali wyjątek, że ten model zawiera błędy.

Sam MS piszę:

The EF in-memory database often behaves differently than relational databases. Only use the EF in-memory database after fully understanding the issues and trade-offs involved, as discussed in Testing code that uses EF Core.

SQLite is a relational provider and can also use in-memory databases. Consider using this for testing to more closely match common relational database behaviors. This is covered in Using SQLite to test an EF Core application.

0

To chyba oczywiste, że się różni ;)

Jak chcesz przetestować logikę aplikacji w odizolowaniu od bazy to używaj InMemory, jak chcesz przetestować aplikację end-to-end/integracyjnie/funkcjonalnie/jak-zwał-tak-zwał to użyj tej samej bazy, której będziesz używał docelowo i testuj jak black boxa.

Sam MS pisze:

Consider testing a piece of business logic that might need to use some data from a database, but is not inherently testing the database interactions. One option is to use a test double such as a mock or fake.

We use test doubles for internal testing of EF Core. However, we never try to mock DbContext or IQueryable. Doing so is difficult, cumbersome, and fragile. Don't do it.

Instead we use the EF in-memory database when unit testing something that uses DbContext. In this case using the EF in-memory database is appropriate because the test is not dependent on database behavior. Just don't do this to test actual database queries or updates.

1
some_ONE napisał(a):

Jak chcesz przetestować logikę aplikacji w odizolowaniu od bazy to używaj InMemory

Chyba się powtarzam, ale jak się chce przetestować logikę w odizolowaniu od bazy, to trzeba tę logikę po prostu napisać w sposób odizolowany od bazy. To wystarczy.
Takie haki jak InMemory się przydaje w monolitach tworzonych w myśl zasady encja na twarz i pchasz.

0

Tylko czym dla przykładu różnią się te dwie wersje kodu i dlaczego jedna według ciebie jest straszna i do niczego się nie nadaje, a druga jest w porzadku.

var user = await dbContext.Users.Find(id);
var user = await userProvider.GetAsync(id);

Tam gdzie ma to sens też jestem za oddzieleniem operacji na bazie od logiki, ale jeśli handler ma tylko odczytać w prosty sposób coś z bazy, a później to przemapować i zwrócić z API to jaki jest problem w użyciu DbContextu bezpośrednio w handlerze?

0

Niczym się nie różnią, żadna z nich nie wymaga testowania jednostkowego. A właściwie, to testowanie jednostkowe którejkolwiek z nich nie ma żadnego sensu.

jeśli handler ma tylko odczytać w prosty sposób coś z bazy, a później to przemapować i zwrócić z API to jaki jest problem w użyciu DbContextu bezpośrednio w handlerze?

Żaden problem, nigdy nie twierdziłem, że to złe. Po prostu tego nie ma po co testować jednostkowo.

1 użytkowników online, w tym zalogowanych: 0, gości: 1