Dzisiejszy świat opiera się na bazach danych. W ramach testów jednostkowych zewnętrzne zależności np. do bazy danych mockujemy. Co w przypadku gdy popełniliśmy literówkę w nazwie procedury lub zapytaniu SQL, albo liczba przekazanych parametrów nie zgadza się.  W  celu rozwiązania powyższego problemu należy zaimplementować testy integracyjne z wykorzystaniem bazy danych. Testy integracyjne nie testują reguł biznesowych, ale współprace poszczególnych komponentów systemu. W analizowanym przypadku sprawdzamy czy metody klasy typu Repository prawidłowo komunikują się z bazą danych, oraz czy zachowanie metod jest zgodne z naszymi oczekiwaniami. Testy integracyjne powinny być powtarzalne, czyli zawartość tabel bazy danych przez każdym testem powinna być taka sama. Nie powinno dojść do sytuacji  że wcześniejszy test dodał, edytował lub usunął rekord z tabeli bazy danych. Powyższe operacje mogą wpłynąć na wynik kolejnego testu. Rozwiązaniem powyższego problemu może być inicjalizacja bazy danych przez każdym testem, lub wykorzystanie mechanizmu transakcji. Dzisiaj zaprezentuję drugą opcję wykorzystującą klasę TransactionScope.

TransactionScope

W ramach szybkiego wprowadzenia, czym jest transakcja? Transakcja jest sekwencją logicznie powiązanych operacji, które stanowią całość. W ramach transakcji zostaną wykonane wszystkie operacje, albo żadna z nich.  W świecie .NET do wykonania transakcji możemy wykorzystać klasę TransactionScope. W celu wykorzystania TransactionScope w projekcie, należy:

  1. Dodać referencje do System.Transactions
  2. Utworzyć transakcje za pomocą TransactionScope
  3. Dodać kod wspierający transakcje
  4. Wywołać metodę TransactionScope.Complete, aby zatwierdzić i zakończyć transakcje.

W przypadku błędu wykonane operacje zostaną cofnięte. Jak wykorzystać mechanizm transakcji na potrzeby testów integracyjnych, to już dowiecie się za chwile.

Testy integracyjne

Teoria była czas na praktykę. Nie każdy projekt potrzebuję od razu korzystać z ORM, do mniejszych projektów wystarczy micro ORM. W testowanych projekcie wykorzystałem micro ORM Dapper, który wspiera transakcje.  Do projektu z NuGet pobieramy NUnit i Dapper. W ramach testów dodałem jedną tabele i procedure składową do bazy danych w SQL Server.

CREATE TABLE dbo.Employees
(
    Id INT NOT NULL IDENTITY(1,1) PRIMARY KEY,
    FirstName [NVARCHAR](50) NOT NULL,
    LastName [NVARCHAR](50) NOT NULL
);
GO

CREATE PROCEDURE [dbo].[AddEmployee]
    @FirstName		NVARCHAR(50),
    @LastName		NVARCHAR(50)
AS
	BEGIN
		INSERT INTO dbo.Employees (FirstName, LastName) Values (@FirstName, @LastName)
	END
GO

Logikę związaną z transakcją na potrzeby testów umieściłem w klasie abstrakcyjnej TestBase. Każda klasa z  testami musi dziedziczyć po klasie abstrakcyjnej TestBase. Poniższy kod zapewnia cofanie zmian wprowadzonych w bazie danych w trakcie wywołania metody testowej z atrybutem Test w ramach transakcji. Przed uruchomieniem testu wywoływana jest zawsze metoda konfiguracji z atrybutem SetUp, która przypisuje do zmiennej prywatnej instancje TransactionScope. W następnym kroku wykonywana jest metoda testowa w ramach transakcji. Dzięki temu że zmiany na bazie nie zostały zatwierdzone na poziomie klasy (trans.Complete()), metoda z atrybutem TearDown w ramach operacji sprzątania inicjuje cofanie zmian. Stosując poniższy szablon unikniemy sytuacji modyfikacji bazy danych przez testy. Atrybuty SetUp i TearDown pochodzą z NUnit.

using System.Threading.Tasks;

namespace TS.EndToEnd
{
    public interface IEmployeesRepository
    {
        Task AddAsync(string firstName, string lastName);
    }
}
using System;
using System.Data;
using System.Threading.Tasks;
using Dapper;

namespace TS.EndToEnd
{
    public class EmployeesRepository : IEmployeesRepository
    {
        private readonly Func<IDbConnection> _dbConnectionFactory;

        public EmployeesRepository(Func<IDbConnection> dbConnectionFactory)
        {
            _dbConnectionFactory = dbConnectionFactory;
        }

        public async Task AddAsync(string firstName, string lastName)
        {
            using (var conn = _dbConnectionFactory())
            {
                conn.Open();
                string procedureName = "AddEmployee";
                var parameters = new DynamicParameters();
                parameters.Add("@FirstName", firstName, DbType.String);
                parameters.Add("@LastName", lastName, DbType.String);
                await conn.ExecuteAsync(procedureName, parameters, commandType: CommandType.StoredProcedure);
            }
        }
    }
}
using System.Transactions;
using NUnit.Framework;

namespace TS.EndToEnd
{
    public abstract class TestBase
    {
        private TransactionScope trans = null;

        [SetUp]
        protected void SetUp()
        {
            trans = new TransactionScope(TransactionScopeOption.Required);
        }

        [TearDown]
        protected void TearDown()
        {
            trans?.Dispose();
        }
    }
}
using System;
using System.Data;
using System.Data.SqlClient;
using NUnit.Framework;

namespace TS.EndToEnd
{
    [TestFixture]
    public class UTestEmployeesRepoistory : TestBase
    { 
    
        public IEmployeesRepository GetSUT()
        {
            var connectionString = "SERVER = (local); Initial Catalog = TS; Integrated Security = SSPI; ";
            Func<IDbConnection> dbConnectionFactory = () => new SqlConnection(connectionString);
            return new EmployeesRepository(dbConnectionFactory);
        }

        [Test]
        public void AddEmployee_EmployeeIsValid_DoesNotThrowAnException()
        {
            var sut = GetSUT();
           
            Assert.DoesNotThrowAsync(async () => await sut.AddAsync("Adam", "Nowak"));         
        }
    }
}

Zróbmy teraz jakiś błąd np. literówkę w nazwie procedury, dodajmy literę 'd’ na koniec nazwy. W tym przypadku procedura nie została znaleziona w bazie danych i zostanie wyrzucony wyjątek typu SqlException „Could not find stored procedure 'AddEmployeed’.”. Gdy popełnimy literówkę w parametrze otrzymamy wyjątek o treści „Procedure or function 'AddEmployee’ expects parameter '@FirstName’, which was not supplied.”. W przypadku nieprawidłowego typu parametru, może nie udać się wykryć błędu np. wartość int zostanie niejawnie przekonwertowana do stringa. Warto pamiętać że w przypadku prawidłowego wykonania metody następuję autoinkrementacja kolumny Id w tabeli Employees. Wykonywany rollback transakcji nie powoduje cofnięcia wartości IDENTITY, wartość ta jest zawsze zwiększona o wartość inkrementacji.

Podsumowanie

Wykorzystując TransactionScope możemy dodawać, aktualizować, usuwać dane w ramach testu integracyjnego, a ” żadne” zmiany nie zostaną utrwalone w bazie danych. Powyższe podejście do testów integracyjnych umożliwia weryfikacje metod, które komunikują się z bazą danych. Zabezpieczamy się tym samym przed zmianami po stronie bazy danych. Gdy ulegnie zmianie definicja procedury składowej testy integracyjne uruchamiane cyklicznie lub na żądanie wykryją tą sytuacje..