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 przez testowe podwojenia; Aby uzyskać więcej informacji, zobacz stronę przeglądu testowania. Należy pamiętać, że testowanie względem 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 dwukrotnie; 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 testów, dzięki czemu testy uruchomione równolegle (a nawet w szeregowych) nie zakłócają siebie nawzajem. Pełny przykładowy kod poniższego kodu można wyświetlić tutaj.

Napiwek

Na tej stronie przedstawiono techniki xUnit , ale podobne koncepcje istnieją w innych platformach 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 dość ł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 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 baza danych LocalDB może służyć do uruchamiania testów lokalnie z praktycznie żadną konfiguracją, uruchomić wystąpienie bazy danych na żądanie i ewentualnie zaoszczędzić zasoby na mniej zaawansowanych maszynach deweloperskich. Jednak baza danych LocalDB nie jest bez problemów:

  • Nie obsługuje wszystkiego, co robi program 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.

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 (choć nadal mądry do uruchamiania testów Usługi 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 aplikacji 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ą 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 używa EnsureDeleted() go do porzucania bazy danych (w przypadku, gdy istnieje z poprzedniego uruchomienia), a następnie EnsureCreated() do utworzenia jej 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ć oprawy w klasie testowej, po prostu zaimplementuj IClassFixture typ 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 Fixture ma teraz właściwość, która może być używana przez testy w celu utworzenia 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. Narzędzie 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ą 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 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 można uniknąć zakłóceń.

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ę, wykonując następujące czynności:

[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 śledzenie zmian wystąpienia kontekstu za ChangeTracker.Clearpomocą polecenia , 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 oprawie CreateContext, aby 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 testowania interferencji, co może być trudne do debugowania. Możesz 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ś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, ponieważ muszą być używane 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 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 (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";

    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żywanego powyżej, ale szczególnie 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 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 tego jako oprawy kolekcji xUnit, a nie jako oprawy klasy.

Najpierw definiujemy kolekcję testów, która odwołuje się do naszego urządzenia i będzie używana przez wszystkie transakcyjne klasy testów, które 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 sprawiamy, że nasza klasa testowa jest jednorazowa, 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ż narzędzie xUnit kiedykolwiek tworzy wystąpienie urządzenia kolekcji raz, nie ma potrzeby używania blokady wokół tworzenia i rozmieszczania bazy danych, tak jak powyżej.

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

Napiwek

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() testów i EnsureCreated() przed uruchomieniem 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 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 usuwa 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 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.
  • Przekaż 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ó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 należy pamiętać o przetwarzaniu równoległym testów i izolacji.