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 metody testowania, w których system produkcyjnej bazy danych jest zamieniany przez testowe podwaja; Aby uzyskać więcej informacji, zobacz stronę przeglądu testowania . Należy pamiętać, że testowanie pod kątem innej bazy danych niż używana w środowisku produkcyjnym (np. Sqlite) nie jest tutaj omówione, ponieważ inna baza danych jest używana jako test podwójne; To podejście zostało omówione w temacie Testowanie bez produkcyjnego systemu bazy danych.

Główną przeszkodą w testowaniu, które obejmuje rzeczywistą bazę danych, jest zapewnienie właściwej izolacji testowej, tak aby testy uruchomione równolegle (a nawet szeregowe) nie zakłócały siebie nawzajem. Pełny przykładowy kod poniższych można wyświetlić tutaj.

Porada

Na tej stronie przedstawiono techniki xUnit , ale podobne koncepcje istnieją w innych strukturach testowania, 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ż baza danych jest wystarczająco łatwa do zainstalowania za pomocą zwykłego mechanizmu instalacji, gotowe do użycia obrazy platformy Docker są dostępne dla większości głównych baz danych i mogą ułatwić instalację w ciągłej integracji. W przypadku środowiska deweloperskiego obszary robocze usługi GitHub kontener deweloperski może 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 rzeczach.

W niektórych przypadkach bazy danych mają specjalną wersję lub wersję, która może być przydatna do testowania. W przypadku korzystania z SQL Server baza danych LocalDB może służyć do uruchamiania testów lokalnie bez konfiguracji praktycznie bez konfiguracji, podczas uruchamiania wystąpienia bazy danych na żądanie i ewentualnie zapisywania zasobów na mniej zaawansowanych maszynach deweloperskich. Jednak baza danych LocalDB nie jest bez problemów:

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

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

W przypadku korzystania z bazy danych w chmurze zwykle należy przetestować lokalną wersję bazy danych, zarówno w celu zwiększenia szybkości, jak i obniżenia kosztów. Na przykład w przypadku korzystania z Usługi SQL Azure w środowisku produkcyjnym można przetestować SQL Server zainstalowane lokalnie — te dwa są bardzo podobne (chociaż nadal warto uruchamiać testy względem Usługi SQL Azure się przed przejściem do środowiska produkcyjnego). W przypadku korzystania z usługi Cosmos emulator usługi Cosmos 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 testowych, dlatego potrzebujemy logiki, aby upewnić się, że baza danych została utworzona i rozmieszczona dokładnie raz w okresie istnienia przebiegu testu.

W przypadku korzystania z narzędzia Xunit można to zrobić za pośrednictwem urządzenia klasy, który reprezentuje bazę danych i jest współużytkowany w wielu przebiegach testów:

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

    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ższego urządzenia jest ono używane EnsureDeleted() do porzucania bazy danych (w przypadku, gdy istnieje z poprzedniego przebiegu), a następnie EnsureCreated() do utworzenia jej przy użyciu najnowszej konfiguracji modelu (zobacz dokumentację dla tych interfejsów API). Po utworzeniu bazy danych element zawiera dane, których możemy użyć w naszych testach. Warto poświęcić trochę czasu na myślenie o danych inicjacyjnych, ponieważ zmiana ich później na nowy test może spowodować niepowodzenie istniejących testów.

Aby użyć oprawy 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 Fixture teraz właściwość , która może być używana przez testy do tworzenia w pełni funkcjonalnego wystąpienia kontekstu:

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

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

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

Wreszcie, być może zauważyłeś pewne blokowanie w logice tworzenia oprawy powyżej. Jeśli urządzenie jest używane tylko w jednej klasie testowej, gwarantowane jest utworzenie wystąpienia dokładnie raz przez xUnit; ale często używa się tego samego urządzenia bazy danych w wielu klasach testowych. XUnit zapewnia oprawy kolekcji, ale ten mechanizm uniemożliwia równoległe działanie klas testowych, co jest ważne dla wydajności testów. Aby bezpiecznie zarządzać tym za pomocą urządzenia klasy xUnit, stosujemy prostą blokadę wokół tworzenia i rozmieszczania bazy danych i 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. Natomiast testy, które modyfikują dane, są bardziej problematyczne, ponieważ mogą zakłócać wzajemnie. Jedną z typowych technik izolowania testów pisania jest zawijanie testu w transakcji i wycofanie tej transakcji na końcu testu. Ponieważ nic nie jest rzeczywiście zatwierdzane w bazie danych, inne testy nie widzą żadnych modyfikacji i nie jest unikana interferencja.

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

[HttpPost]
public ActionResult AddBlog(string name, string url)
{
    _context.Blogs.Add(new Blog { Name = name, Url = url });
    _context.SaveChanges();

    return Ok();
}

Możemy przetestować tę metodę przy użyciu następujących elementów:

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

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

    context.ChangeTracker.Clear();

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

}

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

  • Rozpoczynamy transakcję, aby upewnić się, że poniższe zmiany nie są zatwierdzane w bazie danych i nie zakłócają innych testów. Ponieważ transakcja nigdy nie jest zatwierdzana, jest niejawnie cofana na końcu testu, gdy wystąpienie kontekstu jest usuwane.
  • Po wprowadzeniu żądanych aktualizacji wyczyścimy monitor zmian wystąpienia kontekstu za pomocą ChangeTracker.Clearpolecenia , aby upewnić się, że faktycznie załadowaliśmy 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 obiekcie , CreateContextaby testy odbierały wystąpienie kontekstu, które jest już w transakcji i gotowe do aktualizacji. Może to pomóc w zapobieganiu przypadkom przypadkowego zapomnienia transakcji, co prowadzi do zakłócenia testu, co może być trudne do debugowania. Można również oddzielić testy tylko do odczytu i zapisu w różnych klasach testowych.

Testy, które jawnie zarządzają transakcjami

Istnieje jedna ostateczna kategoria testów, która stanowi dodatkową trudność: testy, które modyfikują dane, a także jawnie zarządzają transakcjami. Ponieważ bazy danych zwykle nie obsługują zagnieżdżonych transakcji, nie można używać transakcji do izolacji, ponieważ muszą być używane przez rzeczywisty kod produktu. Chociaż te testy są zwykle rzadsze, należy je obsługiwać 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.

Przyjrzyjmy się następującej metodzie kontrolera jako przykładu:

[HttpPost]
public 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.
    using var transaction = _context.Database.BeginTransaction(IsolationLevel.Serializable);

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

    blog.Url = url;
    _context.SaveChanges();

    transaction.Commit();
    return Ok();
}

Załóżmy, że z jakiegoś powodu metoda wymaga użycia transakcji możliwej do serializacji (zwykle 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 inny element z własną, oddzielną bazą danych, aby upewnić się, że nie zakłócamy innych testów, które już pokazano powyżej:

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

    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();
    }
}

To urządzenie jest podobne do użytego powyżej, ale w szczególności zawiera metodę Cleanup . Wywołamy to po każdym teście, aby upewnić się, że baza danych zostanie zresetowana do stanu początkowego.

Jeśli to urządzenie będzie używane tylko przez jedną klasę testową, możemy odwołać się do niej jako oprawa klasy, jak powyżej - xUnit nie zrównaje testów w tej samej klasie (przeczytaj więcej na temat kolekcji testów i przetwarzania równoległego 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ąć zakłóceń. W tym celu użyjemy tego jako oprawy kolekcji xUnit, a nie jako oprawy klasy.

Najpierw definiujemy kolekcję testową, która odwołuje się do naszego urządzenia i będzie używana przez wszystkie klasy testów transakcyjnych, 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; }

Wreszcie, czynimy naszą klasę testową jednorazową, rozmieszczając metodę urządzenia Cleanup do wywołania po każdym teście:

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

Należy pamiętać, że ponieważ xUnit tylko kiedykolwiek tworzy wystąpienie urządzenia kolekcji raz, nie ma potrzeby używania blokady wokół tworzenia i rozmieszczania bazy danych, jak to zrobiliśmy powyżej.

Pełny przykładowy kod dla powyższych danych można wyświetlić tutaj.

Porada

Jeśli masz wiele klas testowych z testami, które modyfikują bazę danych, nadal możesz 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() testów i EnsureCreated() przed rozpoczęciem testów, aby upewnić się, że mamy aktualną testową bazę danych. 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 testowych.

Wadą tego podejścia jest to, że w przypadku zmiany modelu 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 oczyszczanie 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ąć zakłóceń. 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 zamiast tego usunąć tabelę:

DELETE FROM [Blogs] WHERE 1=1;

Warto również rozważyć użycie pakietu respawn , który efektywnie czyści bazę danych. Ponadto nie wymaga określenia tabel do wyczyszczenia, dlatego kod oczyszczania nie musi być aktualizowany, ponieważ tabele są dodawane do modelu.

Podsumowanie

  • Podczas testowania pod kątem rzeczywistej bazy danych warto odróżnić następujące kategorie testowe:
    • 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 odizolowane.
    • Testy transakcyjne są najbardziej problematyczne, wymagając logiki zresetowania bazy danych z powrotem do pierwotnego stanu, a także wyłączenia przetwarzania równoległego.
  • Rozdzielenie tych kategorii testów na oddzielne klasy może uniknąć nieporozumień i przypadkowej interferencji między testami.
  • Podawaj kilka przemyślanych danych testowych z wyprzedzeniem i spróbuj napisać testy w sposób, który nie ulegnie zbyt często awarii, jeśli te dane inicjujące się zmienią.
  • Użyj wielu baz danych, aby zrównoleglizować testy, które modyfikują bazę danych, a być może także zezwalać na różne konfiguracje danych inicjujących.
  • Jeśli szybkość testu jest problemem, warto przyjrzeć się bardziej wydajnym technikom tworzenia testowej bazy danych i czyszczeniu danych między przebiegami.
  • Zawsze należy pamiętać o testowaniu równoległym i izolacji.