Тестування з базою даних .NET


Звичайним підходом .NET до тестування додатків, що працюють з базою даних є впровадження залежності (Dependency Injection). Пропонується відокремити код працює з базою, від основної логіки шляхом створення абстракції, яку в подальшому можна підмінити в тестах. Це дуже потужний і гнучкий підхід, який проте має деякі недоліки — збільшення складності, поділ логіки, вибухове зростання кількості типів. Докладніше в попередній статті щось не те з тестуванням .NET (Java і т. д.) або Wiki/Dependency Injection.
Є більш простий підхід, широко поширений в світі динамічних мов. Замість створення абстракції, яку можна контролювати в тестах, цей підхід пропонує контролювати саму базу. Тестовий фреймворк надає чисту базу для кожного тесту і ви можете створити в ній тестовий сценарій. Це простіше і дає більше впевненості в тестах.

Приклад
Як показала попередня стаття — приклад дуже важливий. Якщо він невдалий, критикується сам приклад, а не підхід. Тут я приділив йому більше уваги, але він звичайно теж не ідеальний:

Є якесь додаток для складського обліку товарів. Товари можна переміщати між складами з допомогою переміщення документів. Необхідний метод, що дозволяє отримувати залишки за вказаним складу на певний момент часу.
Для цього введемо наступний метод (його і потрібно буде протестувати):

public class ReminesService 
{
RemineItem[] GetReminesFor(Storage storage, DateTime time) { ... }
}

У статті не буде реалізації цього методу, але він є в репозиторії на гітхабі.

Тестова база даних
Нам знадобиться база даних для тестування. Для простих проектів можна використовувати SQLite, це непоганий компроміс між швидкістю тестів та їх надійністю. Для більш складних випадків краще використовувати таку ж БД, що і при розробці. В більшості випадків це не проблема — MySql і PostgreSql легковагі, для SQLServer є режим LocalDb.

Якщо ви працюєте з SQLServer, зручно скористатися LocalDb режимом для тестової бази — він набагато легше і швидше повної бази, при цьому повністю функціональний. Для цього потрібно конфігурувати App.config в тестовому проекті:

Конфігурація для SQLServer LocalDb
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<configSections>
<section name="entityFramework" type="System.Data.Entity.Internal.ConfigFile.EntityFrameworkSection, EntityFramework, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" requirePermission="false" />
</configSections>
<entityFramework>
<defaultConnectionFactory type="System.Data.Entity.Infrastructure.LocalDbConnectionFactory, EntityFramework">
<parameters>
<parameter value="MSSQLLocalDB" />
</parameters>
</defaultConnectionFactory>
<providers>
<provider invariantName="System.Data.SqlClient" type="System.Data.Entity.SqlServer.SqlProviderServices, EntityFramework.SqlServer" />
</providers>
</entityFramework>
</configuration>


Фреймворк
Так як даний підхід дуже мало поширений .NET — майже немає ніяких готових бібліотек для його реалізації. Тому я оформив напрацювання в цій області в невелику бібліотеку DbTest. Ви можете подивитися исходники та приклади на гитхаб або встановити в проект через nuget. Проект у попередньої версії і може змінюватися API — так що будьте обережні.

Початкові дані
В реальній системі багато відносин між моделями, щоб вставити хоча б один рядок у таблицю необхідно заповнити безліч зв'язаних таблиць. Наприклад, товар (Good) може посилатися на виробника (Manufacturer), який у свою чергу посилається на країну (Country).

Щоб спростити подальше створення тестових сценаріїв, необхідно створити мінімальний набір загальних для системи даних.

Щоб було трохи веселіше, давайте в якості товарів візьмемо пляшки з віскі. Почнемо з моделі, у якої немає залежностей — країна виробника (Country):

public class Countries : IModelFixture<Country>
{
public string TableName => "Countries";

public static Country Scotland => new Country
{
Id = 1,
Name = "Scotland",
IsDeleted = false
};

public static Country USA => new Country
{
Id = 2,
Name = "USA",
IsDeleted = false
};
}

Щоб фреймворк зрозумів, що це опис початкових даних, клас повинен реалізовувати інтерфейс
IModelFixture<T>
. Примірники моделей оголошуються статичними, щоб забезпечити до них доступ з інших фікстур і тестів. Ви повинні явно вказувати первинні ключі (
Id
) і стежити за їх унікальністю в рамках однієї моделі.

Тепер можна створювати виробників:

class Manufacturers : IModelFixture<Manufacturer>
{
public string TableName => "Manufacturers";

public static Manufacturer BrownForman => new Manufacturer
{
Id = 1,
Name = "Brown-Forman",
CountryId = Countries.USA.Id
IsDeleted = false
};

public static Manufacturer TheEdringtonGroup => new Manufacturer
{
Id = 2,
Name = "The Edrington Group",
CountryId = Countries.Scotland.Id
IsDeleted = false
};
}

Товари:

public class Goods : IModelFixture<Good>
{
public string TableName => "Goods";

public static Good JackDaniels => new Good
{
Id = 1,
Name = "Jack Daniels, 0.5 l",
ManufacturerId = Manufacturers.BrownForman.Id
IsDeleted = false
};

public static Good FamousGrouseFinest => new Good
{
Id = 2,
Name = "The Famous Grouse Finest, 0.5 l",
ManufacturerId = Manufacturers.TheEdringtonGroup.Id
IsDeleted = false
};
}

Зверніть увагу на зовнішні ключі — вони не вказуються явно, а посилаються на іншу фікстури.

Такий підхід має безліч переваг перед sql-файлами або json файлами фікстур:

  • при створенні початкових даних студія підказує які поля є в класі, контролює їх типи
  • легко зв'язати моделі між собою, таким же чином можна використовувати початкові дані в самих тестах
  • при розвитку системи і зміни в моделях — компілятор перевірить явні помилки в типах в фикстурах
Важливо! У цього підходу є недолік — при кожному зверненні до статичного властивості створюється екземпляр моделі і всіх залежних від нього моделей (і їх залежностей теж). Якщо виникають проблеми з продуктивністю або циклічними посиланнями, то можна виправити це за допомогою ледачою ініціалізації Lazy<T>.

private static Good _famousGrouseFinest = new Lazy<Good>(() => new Good
{
Id = 2,
Name = "The Famous Grouse Finest, 0.5 l",
ManufacturerId = Manufacturers.TheEdringtonGroup.Id
IsDeleted = false
};
public static Good FamousGrouseFinest => _famousGrouseFinest.Value;

Підготовка оточення
Тестове оточення в першу чергу це база даних, також це можуть бути синглтоны і статичні змінні (наприклад, у asp.net можна встановити
HttpContext
). Краще зібрати всі ці операції в одному місці і запускати перед кожним тестом. Ми назвали в себе таке місце — World. Щоб підготувати базу даних — потрібно викликати метод
ResetWithFixtures
і передати туди список початкових фікстур.

static World class
{
public static void InitDatabase()
{
using (var context = new MyContext())
{
var dbTest = new EFTestDatabase<MyContext>(context);

dbTest.ResetWithFixtures(
new Countries(),
new Manufacturers(),
new Goods()
);
}
}

public static void InitContextWithUser()
{
HttpContext.Current = new HttpContext(
new HttpRequest("", "http://your-domain.com", ""),
new HttpResponse(new StringWriter())
);
HttpContext.Current.User = new GenericPrincipal(
new GenericIdentity("root"),
new string[0]
);
}
}

Можливість задати статичні змінні і синглтоны особливо важлива при тестуванні legacy коду, де не так-то просто поміняти архітектуру — але є гостра необхідність у тестуванні. Поділ налаштування оточення на кілька методів дозволяє готувати оточення індивідуального для кожного тесту. Наприклад, в unit тестах не використовується база і немає сенсу очищати для них базу. Або у вас може бути необхідність підготувати різне оточення для різних станів системи (авторизований і неавторизований користувач).

Створення тестового сценарію
У тестах доводиться робити багато підготовчої роботи, Arrange фаза тесту найбільш відповідальна і складна. Тому бажано створювати хелпери, які спростять цей процес, зроблять код більш простим для читання. Одним із зручних механізмів, може бути створення ModelBuilder, який створює суті, зберігає їх в БД і повертає примірники для подальшого використання:

public class ModelBuilder
{
public MoveDocument CreateDocument(string time, Storage source, Storage dest)
{
var document = new MoveDocument
{
Number = "#",

SourceStorageId = source.Id
DestStorageId = dest.Id

Time = ParseTime(time),
IsDeleted = false
};

using (var db = new MyContext())
{
db.MoveDocuments.Add(document);
db.SaveChanges();
}

return document;
}

public MoveDocumentItem AddGood(MoveDocument document, Good good, decimal count)
{
var item = new MoveDocumentItem
{
MoveDocumentId = document.Id
GoodId = good.Id
Count = count
};

using (var db = new MyContext())
{
db.MoveDocumentItems.Add(item);
db.SaveChanges();
}

return item;
}
}

Тестуємо
Настав час зібрати всі разом і подивитися, що вийшло:

[SetUp]
public void SetUp()
{
World.InitDatabase(); // готуємо базу до кожного тесту
}

[Test]
public void CalculateRemainsForMoveDocuments()
{
/// ARRANGE - створюємо тестову ситуацію
var builder = new ModelBuilder(); 

// Прихід товарів на віддалений склад
var doc1 = builder.CreateDocument("15.01.2016 10:00:00", Storages.MainStorage, Storages.RemoteStorage);
builder.AddGood(doc1, Goods.JackDaniels, 10);
builder.AddGood(doc1, Goods.FamousGrouseFinest, 15);

// Витрати товарів з віддаленого складу
var doc2 = builder.CreateDocument("16.01.2016 20:00:00", Storages.RemoteStorage, Storages.MainStorage);
builder.AddGood(doc2, Goods.FamousGrouseFinest, 7);

/// ACT - викликаємо функцію тестовану
var remains = RemainsService.GetRemainFor(Storages.RemoteStorage, new DateTime(2016, 02, 01));

/// ASSERT - перевіряємо результат
Assert.AreEqual(2, remains.Count);
Assert.AreEqual(10, remains.Single(x => x.GoodId == Goods.JackDaniels.Id).Count);
Assert.AreEqual(8, remains.Single(x => x.GoodId == Goods.FamousGrouseFinest.Id).Count);
}

Зверніть увагу на використання початкових фікстур в коді тесту
Storages.MainStorage
,
Goods.JackDaniels
,
Goods.FamousGrouseFinest
і т. д.

Дуже зручно, що під рукою є всі об'єкти, які вже є в базі даних і їх можна використовувати в будь-якій фазі тіста.

Резюме
Даний підхід незаслужено обходиться стороною у світі строго-типізованих мов, при цьому він дуже широко розповсюджений в динамічних мовами. Це не срібна куля і не заміна для DI, але це дуже зручний і доречний у багатьох випадках підхід.

Порівняно з DI, тестування з цією базою має наступні переваги:

  • Менший вплив тестів на архітектуру
  • Менше шарів абстракції — менше складність і спрощується читання коду
  • Більше довіри до тестів, які насправді читають і вставляють дані в базу
  • Швидше в написанні і простіше у підтримці
Найбільша ложка дьогтю з інтеграційними тестами — це час виконання, вони набагато повільніше, але це розв'язувана проблема. Принаймні серверне час набагато дешевше часу розробника.

DI — це дуже хороша і улюблена мною техніка, нею повинен уміти користуватися будь-який поважаючий себе програміст. Однак в області тестування є дуже хороша альтернатива, яка має інший набір переваг і недоліків. Я за те, щоб в арсеналі був великий набір методів і підходів і кожен застосовувався ситуації.

Корисні посилання
DbTest (репозиторій з тестовим фреймворком і прикладами з статті)
Smocks (мок для статичних системних методів)
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.