Udostępnij za pośrednictwem


Testowanie w systemie produkcyjnej bazy danych

Na tej stronie omówiono techniki pisania testów automatycznych, które obejmują system bazy danych, na którym działa aplikacja w środowisku produkcyjnym. Istnieją alternatywne podejścia do testowania, w których system produkcyjnej bazy danych jest zamieniany na dublery testowe. Aby uzyskać więcej informacji, zobacz stronę przeglądu testowania . Należy pamiętać, że testowanie względem innej bazy danych niż używanej w środowisku produkcyjnym (np. Sqlite) nie jest tutaj omówione, ponieważ inna baza danych jest używana jako dubler testowy; to podejście zostało omówione w Testowanie bez użycia produkcyjnego systemu baz danych.

Główną przeszkodą w testowaniu, które obejmuje rzeczywistą bazę danych, jest zapewnienie właściwej izolacji testów, dzięki czemu testy uruchomione równolegle (a nawet w szeregowych) nie zakłócają siebie nawzajem. Pełny kod przykładowy dla poniższego można wyświetlić tutaj.

Wskazówka

Na tej stronie przedstawiono techniki xUnit, ale podobne pojęcia istnieją w innych frameworkach testowych, w tym NUnit.

Konfigurowanie systemu bazy danych

Większość systemów baz danych w dzisiejszych czasach można łatwo zainstalować zarówno w środowiskach ciągłej integracji, jak i na maszynach deweloperskich. Chociaż bazę danych można dość łatwo zainstalować za pomocą zwykłego mechanizmu instalacji, gotowe do użycia obrazy Docker są dostępne dla większości głównych baz danych i mogą ułatwić instalację w procesie ciągłej integracji (CI). W środowisku deweloperskim GitHub Workspaces, Dev Container można skonfigurować wszystkie potrzebne usługi i zależności, w tym bazę danych. Chociaż wymaga to początkowej inwestycji w konfigurację, po wykonaniu tego zadania masz działające środowisko testowe i możesz skoncentrować się na ważniejszych kwestiach.

W niektórych przypadkach bazy danych mają specjalną wersję lub wersję, która może być przydatna do testowania. W przypadku korzystania z programu SQL Server można używać LocalDB do uruchamiania testów lokalnie bez praktycznie żadnej konfiguracji, tworząc wystąpienie bazy danych na żądanie i ewentualnie oszczędzając zasoby na mniej wydajnych maszynach deweloperskich. Jednak baza danych LocalDB nie jest bez problemów:

  • Nie obsługuje wszystkiego, co SQL Server Developer Edition.
  • Jest ona dostępna tylko w systemie Windows.
  • Może to spowodować opóźnienie podczas pierwszego uruchomienia testu, ponieważ usługa jest uruchamiana po raz pierwszy.

Ogólnie zalecamy zainstalowanie wersji SQL Server Developer, a nie LocalDB, ponieważ zapewnia pełny zestaw funkcji programu SQL Server i jest ogólnie bardzo łatwy w obsłudze.

W przypadku korzystania z bazy danych w chmurze zazwyczaj należy przetestować lokalną wersję bazy danych, zarówno w celu zwiększenia szybkości, jak i zmniejszenia kosztów. Na przykład w przypadku korzystania z usługi SQL Azure w środowisku produkcyjnym można przetestować program SQL Server zainstalowany lokalnie — te dwa są bardzo podobne (chociaż nadal warto uruchamiać testy na platformie SQL Azure przed przejściem do środowiska produkcyjnego). W przypadku korzystania z usługi Azure Cosmos DB emulator usługi Azure Cosmos DB jest przydatnym narzędziem do tworzenia lokalnie i uruchamiania testów.

Tworzenie, rozmieszczanie i zarządzanie testową bazą danych

Po zainstalowaniu bazy danych możesz rozpocząć korzystanie z niej w testach. W większości prostych przypadków zestaw testów ma pojedynczą bazę danych współdzieloną między wieloma testami w wielu klasach testów, dlatego potrzebujemy logiki, aby upewnić się, że baza danych jest tworzona i rozmieszczana dokładnie raz w okresie istnienia przebiegu testu.

W przypadku korzystania z narzędzia Xunit można to zrobić za pomocą fikstury klasy, która reprezentuje bazę danych i jest współużytkowana w wielu przebiegach testów.

public class TestDatabaseFixture
{
    private const string ConnectionString = @"Server=(localdb)\mssqllocaldb;Database=EFTestSample;Trusted_Connection=True;ConnectRetryCount=0";

    private static readonly object _lock = new();
    private static bool _databaseInitialized;

    public TestDatabaseFixture()
    {
        lock (_lock)
        {
            if (!_databaseInitialized)
            {
                using (var context = CreateContext())
                {
                    context.Database.EnsureDeleted();
                    context.Database.EnsureCreated();

                    context.AddRange(
                        new Blog { Name = "Blog1", Url = "http://blog1.com" },
                        new Blog { Name = "Blog2", Url = "http://blog2.com" });
                    context.SaveChanges();
                }

                _databaseInitialized = true;
            }
        }
    }

    public BloggingContext CreateContext()
        => new BloggingContext(
            new DbContextOptionsBuilder<BloggingContext>()
                .UseSqlServer(ConnectionString)
                .Options);
}

Po utworzeniu wystąpienia powyższej fikstury program używa EnsureDeleted() do usunięcia bazy danych (jeśli istnieje z poprzedniego uruchomienia), a następnie EnsureCreated() do jej utworzenia przy użyciu najnowszej konfiguracji modelu (zobacz dokumentację dla tych interfejsów API). Po utworzeniu bazy danych urządzenie rozsiewuje je z niektórymi danymi, których mogą używać nasze testy. Warto poświęcić trochę czasu na przemyślenie danych inicjacyjnych, ponieważ zmiana ich później na nowy test może spowodować niepowodzenie istniejących testów.

Aby użyć urządzenia w klasie testowej, po prostu zaimplementuj IClassFixture nad typem urządzenia, a xUnit wstrzykuje go do konstruktora:

public class BloggingControllerTest : IClassFixture<TestDatabaseFixture>
{
    public BloggingControllerTest(TestDatabaseFixture fixture)
        => Fixture = fixture;

    public TestDatabaseFixture Fixture { get; }

Klasa testowa ma teraz właściwość Fixture, która może być używana przez testy do utworzenia w pełni działającej instancji kontekstu.

[Fact]
public async Task GetBlog()
{
    using var context = Fixture.CreateContext();
    var controller = new BloggingController(context);

    var blog = (await controller.GetBlog("Blog2")).Value;

    Assert.Equal("http://blog2.com", blog.Url);
}

Wreszcie, być może zauważyłeś pewne blokowanie w logice tworzenia elementu mocującego powyżej. Jeśli fikstura jest używana tylko w jednej klasie testowej, xUnit gwarantuje dokładnie jeden raz jej utworzenie; ale często używa się tej samej fikstury bazy danych w wielu klasach testowych. Narzędzie xUnit zapewnia fikstury kolekcji, ale ten mechanizm uniemożliwia równoległe działanie klas testowych, co jest istotne dla wydajności testów. Aby bezpiecznie zarządzać tym za pomocą oprawy klasy xUnit, stosujemy prostą blokadę wokół tworzenia i rozmieszczania bazy danych oraz używamy flagi statycznej, aby upewnić się, że nigdy nie musimy tego robić dwa razy.

Testy modyfikujące dane

W powyższym przykładzie pokazano test tylko do odczytu, który jest łatwym przypadkiem z punktu widzenia izolacji testowej: ponieważ nic nie jest modyfikowane, interferencja testowa nie jest możliwa. Z kolei testy, które modyfikują dane, są bardziej problematyczne, ponieważ mogą ingerować w siebie nawzajem. Jedną z typowych technik izolowania testów zapisowych jest umieszczenie ich w transakcji, która jest wycofywana na końcu testu. Ponieważ nic nie jest rzeczywiście zatwierdzane w bazie danych, inne testy nie zauważają żadnych modyfikacji, dzięki czemu zakłócenia są unikane.

Oto metoda kontrolera, która dodaje blog do naszej bazy danych:

[HttpPost]
public async Task<ActionResult> AddBlog(string name, string url)
{
    _context.Blogs.Add(new Blog { Name = name, Url = url });
    await _context.SaveChangesAsync();

    return Ok();
}

Możemy przetestować tę metodę, wykonując następujące czynności:

[Fact]
public async Task AddBlog()
{
    using var context = Fixture.CreateContext();
    context.Database.BeginTransaction();

    var controller = new BloggingController(context);
    await controller.AddBlog("Blog3", "http://blog3.com");

    context.ChangeTracker.Clear();

    var blog = await context.Blogs.SingleAsync(b => b.Name == "Blog3");
    Assert.Equal("http://blog3.com", blog.Url);

}

Niektóre uwagi dotyczące powyższego kodu testowego:

  • Rozpoczynamy transakcję, aby mieć pewność, że poniższe zmiany nie zostaną zatwierdzone w bazie danych i nie będą kolidować z innymi testami. Ponieważ transakcja nigdy nie jest zatwierdzana, jest niejawnie cofana na końcu testu, gdy wystąpienie kontekstu jest usuwane.
  • Po dokonaniu żądanych aktualizacji, wyczyścimy śledzenie zmian kontekstu wystąpienia przy użyciu ChangeTracker.Clear, aby upewnić się, że faktycznie załadujemy blog z poniższej bazy danych. Zamiast tego możemy użyć dwóch wystąpień kontekstu, ale musimy upewnić się, że ta sama transakcja jest używana przez oba wystąpienia.
  • Możesz nawet chcieć uruchomić transakcję w CreateContextkomponentu, aby testy otrzymywały wystąpienie kontekstu, które już znajduje się w transakcji i jest gotowe do wprowadzania zmian. Może to pomóc w uniknięciu sytuacji, w której transakcja jest przypadkowo zapomniana, prowadząc do zakłóceń testowych, które mogą być trudne do debugowania. Możesz również oddzielić testy tylko do odczytu od testów zapisu, umieszczając je w różnych klasach testowych.

Testy, które jawnie zarządzają transakcjami

Istnieje jedna ostateczna kategoria testów, która stanowi dodatkową trudności: testy, które modyfikują dane, a także jawnie zarządzają transakcjami. Ponieważ bazy danych zwykle nie obsługują transakcji zagnieżdżonych, nie można używać transakcji do izolacji w opisywany sposób, ponieważ muszą być stosowane przez rzeczywisty kod produktu. Chociaż te testy wydają się być rzadsze, należy je obsłużyć w specjalny sposób: należy wyczyścić bazę danych do pierwotnego stanu po każdym teście, a równoległość musi być wyłączona, aby te testy nie zakłócały siebie nawzajem.

Przeanalizujmy następującą metodę kontrolera jako przykład:

[HttpPost]
public async Task<ActionResult> UpdateBlogUrl(string name, string url)
{
    // Note: it isn't usually necessary to start a transaction for updating. This is done here for illustration purposes only.
    await using var transaction = await _context.Database.BeginTransactionAsync(IsolationLevel.Serializable);

    var blog = await _context.Blogs.FirstOrDefaultAsync(b => b.Name == name);
    if (blog is null)
    {
        return NotFound();
    }

    blog.Url = url;
    await _context.SaveChangesAsync();

    await transaction.CommitAsync();
    return Ok();
}

Załóżmy, że z jakiegoś powodu metoda wymaga użycia transakcji możliwej do serializacji (zazwyczaj nie jest to przypadek). W związku z tym nie możemy użyć transakcji w celu zagwarantowania izolacji testowej. Ponieważ test rzeczywiście zatwierdzi zmiany w bazie danych, zdefiniujemy inne urządzenie z własną, oddzielną bazą danych, aby upewnić się, że nie zakłócamy innych testów, które zostały już pokazane powyżej:

public class TransactionalTestDatabaseFixture
{
    private const string ConnectionString = @"Server=(localdb)\mssqllocaldb;Database=EFTransactionalTestSample;Trusted_Connection=True;ConnectRetryCount=0";

    public BloggingContext CreateContext()
        => new BloggingContext(
            new DbContextOptionsBuilder<BloggingContext>()
                .UseSqlServer(ConnectionString)
                .Options);

    public TransactionalTestDatabaseFixture()
    {
        using var context = CreateContext();
        context.Database.EnsureDeleted();
        context.Database.EnsureCreated();

        Cleanup();
    }

    public void Cleanup()
    {
        using var context = CreateContext();

        context.Blogs.RemoveRange(context.Blogs);

        context.AddRange(
            new Blog { Name = "Blog1", Url = "http://blog1.com" },
            new Blog { Name = "Blog2", Url = "http://blog2.com" });
        context.SaveChanges();
    }
}

Ta fikstura jest podobna do używanej powyżej, ale szczególnie zawiera metodę Cleanup. Będziemy ją wywoływać po każdym teście, aby upewnić się, że baza danych zostanie zresetowana do stanu początkowego.

Jeśli ta fikstura będzie używana tylko przez jedną klasę testową, możemy odwołać się do niej jako do fikstury klasy, jak powyżej - xUnit nie równoległy testów w tej samej klasie (przeczytaj więcej na temat kolekcji testów i równoległości w dokumentacji xUnit ). Jeśli jednak chcemy udostępnić to urządzenie między wieloma klasami, musimy upewnić się, że te klasy nie działają równolegle, aby uniknąć żadnych zakłóceń. W tym celu użyjemy go jako oprawy kolekcji xUnit , a nie jako oprawy klasy .

Najpierw definiujemy kolekcję testową , która odnosi się do naszej stałej konfiguracji i będzie używana przez wszystkie transakcyjne klasy testów, które tego wymagają.

[CollectionDefinition("TransactionalTests")]
public class TransactionalTestsCollection : ICollectionFixture<TransactionalTestDatabaseFixture>
{
}

Teraz odwołujemy się do kolekcji testów w naszej klasie testowej i akceptujemy urządzenie w konstruktorze tak jak poprzednio:

[Collection("TransactionalTests")]
public class TransactionalBloggingControllerTest : IDisposable
{
    public TransactionalBloggingControllerTest(TransactionalTestDatabaseFixture fixture)
        => Fixture = fixture;

    public TransactionalTestDatabaseFixture Fixture { get; }

Na koniec organizujemy naszą klasę testową jako jednorazową, tak aby metoda urządzenia Cleanup była wywoływana po każdym teście.

public void Dispose()
    => Fixture.Cleanup();

Należy pamiętać, że ponieważ xUnit tworzy instancję elementu kolekcji tylko raz, nie ma potrzeby stosowania blokad podczas tworzenia i inicjalizowania bazy danych, jak zrobiliśmy to powyżej.

Pełny kod przykładowego programu powyższego można tutaj wyświetlić .

Wskazówka

Jeśli masz wiele klas testowych z testami, które modyfikują bazę danych, nadal można uruchamiać je równolegle, używając różnych urządzeń, z których każda odwołuje się do własnej bazy danych. Tworzenie i używanie wielu testowych baz danych nie jest problematyczne i powinno być wykonywane zawsze, gdy jest to przydatne.

Wydajne tworzenie bazy danych

W powyższych przykładach użyliśmy EnsureDeleted() i EnsureCreated() przed uruchomieniem testów, aby upewnić się, że mamy testową bazę danych up-todate. Te operacje mogą być nieco powolne w niektórych bazach danych, co może być problemem podczas iterowania zmian kodu i ponownego uruchamiania testów. Jeśli tak jest, możesz tymczasowo oznaczyć komentarz EnsureDeleted w konstruktorze urządzenia: spowoduje to ponowne użycie tej samej bazy danych w ramach przebiegów testów.

Wadą tego podejścia jest to, że jeśli zmienisz model EF Core, schemat bazy danych nie będzie aktualny, a testy mogą zakończyć się niepowodzeniem. W związku z tym zalecamy tylko tymczasowe wykonanie tej czynności podczas cyklu programowania.

Wydajne czyszczenie bazy danych

Widzieliśmy powyżej, że gdy zmiany są rzeczywiście zatwierdzane w bazie danych, musimy wyczyścić bazę danych między każdym testem, aby uniknąć interferencji. W powyższym przykładzie testu transakcyjnego wykonaliśmy to za pomocą interfejsów API platformy EF Core w celu usunięcia zawartości tabeli:

using var context = CreateContext();

context.Blogs.RemoveRange(context.Blogs);

context.AddRange(
    new Blog { Name = "Blog1", Url = "http://blog1.com" },
    new Blog { Name = "Blog2", Url = "http://blog2.com" });
context.SaveChanges();

Zazwyczaj nie jest to najbardziej wydajny sposób wyczyszczenia tabeli. Jeśli szybkość testu jest problemem, możesz użyć nieprzetworzonego kodu SQL, aby usunąć tabelę:

DELETE FROM [Blogs];

Warto również rozważyć użycie pakietu respawn, który efektywnie czyści bazę danych. Ponadto nie musisz określać tabel do wyczyszczenia, więc kod czyszczący nie wymaga aktualizacji, gdy do modelu dodawane są nowe tabele.

Podsumowanie

  • Podczas testowania pod kątem rzeczywistej bazy danych warto odróżnić następujące kategorie testów:
    • Testy tylko do odczytu są stosunkowo proste i zawsze mogą być wykonywane równolegle względem tej samej bazy danych bez konieczności martwienia się o izolację.
    • Testy zapisu są bardziej problematyczne, ale transakcje mogą służyć do upewnienia się, że są one prawidłowo izolowane.
    • Testy transakcyjne są najbardziej problematyczne, wymagając logiki zresetowania bazy danych z powrotem do pierwotnego stanu, a także wyłączania równoległości.
  • Rozdzielenie tych kategorii testów na oddzielne klasy może uniknąć nieporozumień i przypadkowej interferencji między testami.
  • Zastanów się wcześniej nad danymi początkowymi swoich testów i spróbuj napisać testy tak, aby były odporne na częste zmiany tych danych.
  • Użyj wielu baz danych, aby zrównoleglić testy, które modyfikują bazę danych, a może także zezwalać na różne konfiguracje danych inicjacyjnych.
  • Jeśli szybkość testowania jest problemem, warto przyjrzeć się bardziej wydajnym technikom tworzenia testowej bazy danych i czyszczeniu danych między przebiegami.
  • Zawsze miej na uwadze równoległość i izolację testów.