Обычным подходом в .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 в тестовом проекте:
<?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.5l",
ManufacturerId = Manufacturers.BrownForman.Id,
IsDeleted = false
};
public static Good FamousGrouseFinest => new Good
{
Id = 2,
Name = "The Famous Grouse Finest, 0.5l",
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.5l",
ManufacturerId = Manufacturers.TheEdringtonGroup.Id,
IsDeleted = false
};
public static Good FamousGrouseFinest => _famousGrouseFinest.Value;
Подготовка окружения
Тестовое окружение в первую очередь это база данных, также это могут быть синглтоны и статические переменные (например, в asp.net можно установить HttpContext
). Лучше собрать все эти операции в одном месте и запускать перед каждым тестом. Мы назвали у себя такое место — World. Чтобы подготовить базу данных — нужно вызвать метод ResetWithFixtures
и передать туда список начальных фикстур.
static class World
{
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 (мок для статических системных методов)
Автор: justserega