Тестирование с базой данных в .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 в тестовом проекте:
Фреймворк
Так как данный подход очень мало распространен в .NET — почти нет никаких готовых библиотек для его реализации. Поэтому я оформил наработки в этой области в небольшую библиотеку DbTest. Вы можете посмотреть исходники и примеры на гитхаб или установить в проект через nuget. Проект в предварительной версии и может меняться API — так что будьте осторожны.
Начальные данные
В реальной системе много отношений между моделями, чтобы вставить хотя бы одну строку в целевую таблицу необходимо заполнить множество связанных таблиц. Например, товар (Good) может ссылаться на производителя (Manufacturer), который в свою очередь ссылается на страну (Country).
Чтобы упростить дальнейшее создание тестовых сценариев, необходимо создать минимальный набор общих для системы данных.
Чтобы было немного веселее, давайте в качестве товаров возьмем бутылки с виски. Начнем с модели, у которой нет зависимостей — страна производителя (Country):
public class Countries : IModelFixture
{
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
. Экземпляры моделей объявляются статическими, чтобы обеспечить к ним доступ из других фикстур и тестов. Вы должны явно указывать первичные ключи (Id
) и следить за их уникальностью в рамках одной модели.Теперь можно создавать производителей:
class Manufacturers : IModelFixture
{
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
{
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
private static Good _famousGrouseFinest = new Lazy(() => 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(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 (мок для статических системных методов)
Комментарии (40)
16 января 2017 в 12:19
+1↑
↓
Это очень мощный и гибкий подход, который тем не менее имеет некоторые недостатки — увеличение сложности, разделение логики, взрывной рост количества типов.
Разделение логики — это достоинство, а не недостаток. А «взрывной рост» наблюдается только там, где при проектировании допущена ошибка.
Тестовый фреймворк предоставляет чистую базу для каждого теста и вы можете создать в ней тестовый сценарий. Это проще и дает больше уверенности в тестах.
Проще, серьезно? «Проще» это только тогда, когда вы для каждого теста создаете нужную ему (и только ему) БД. Но вы представляете себе, насколько это медленно? Поэтому начинают переиспользовать БД между несколькими тестами —, а это уже анти-паттерн shared fixture, ну и понеслась…
А еще представьте себе, как просто это делать на билд-агентах при каждом билде.
По крайней мере серверное время намного дешевле времени разработчика.
Это пока разработчик не начинает простаивать, ожидая выполнения чего-то на сервере.
Однако в области тестирования есть очень хорошая альтернатива, которая имеет другой набор преимуществ и недостатков.
Интеграционные тесты — это не альтернатива DI. Интеграционные тесты — это «альтернатива» юнит-тестам; хотя на самом деле, интеграционные тесты — это другой способ тестирования, не способный заменить юнит-тестирование (в обратную сторону тоже верно).
16 января 2017 в 12:24 (комментарий был изменён)
+1↑
↓
Разделение логики — это достоинство, а не недостаток.
Только если это разделение по ответственности или еще каким-то логическим критериям, а не искусственное — чтобы отделить обращение к базе.
Но вы представляете себе, насколько это медленно? Поэтому начинают переиспользовать БД между несколькими тестами —, а это уже анти-паттерн shared fixture, ну и понеслась…
Не надо придумывать того, что не написано — не надо переиспользовать. Не настолько медленно как принято представлять, и есть куда думать, чтобы ускорить.
Интеграционные тесты — это не альтернатива DI.
Альтернатива — то как можно тестировать. А интеграционные тесты это дополнение к unit-тестам.
16 января 2017 в 12:37
0↑
↓
Только если это разделение по ответственности или еще каким-то логическим критериям, а не искусственное — чтобы отделить обращение к базе.
Обращение к БД — это и есть другая ответственность. Поэтому разделение работы с БД и разделение бизнес-логики — это разделение по ответственности.
Не настолько медленно как принято представлять
Понимаете ли, я опираюсь не на «принято представлять», а на свою ежедневную деятельность, в которой много интеграционных тестов. И они — медленные. На несколько порядков медленее, чем юнит-тесты.
, и есть куда думать, чтобы ускорить.
Например? Потому что в моем опыте «куда ускорить» неизбежно приводит к shared fixture, потому что все рано или поздно упирается во время развертывания чистой БД.
Альтернатива — то как можно тестировать. А интеграционные тесты это дополнение к unit-тестам.
Если дополнение — значит, от DI вы отказаться не сможете. Поэтому и не альтернатива.
16 января 2017 в 12:45
0↑
↓
Обращение к БД — это и есть другая ответственность. Поэтому разделение работы с БД и разделение бизнес-логики — это разделение по ответственности.
Тут можно поспорить — так принято в .NET, что работа с БД это отдельная ответственность. И я считаю, что во многом из-за того, что по другому не протестировать.
значит, от DI вы отказаться не сможете
Не могу и не хочу, а еще не хочу микроскопом гвозди забивать. У меня в проектах есть логика, которая тестируется и unit-тестами и интеграционными — потому что там ответственно и сложно, а есть где только интеграционные — потому что ну нет там смысла городить весь этот огород.
16 января 2017 в 12:58
0↑
↓
Тут можно поспорить — так принято в .NET, что работа с БД это отдельная ответственность.
Далеко не только в .net. Вы Фаулера читали?
И я считаю, что во многом из-за того, что по другому не протестировать.
Нет, потому что так сложность меньше.
16 января 2017 в 13:04
0↑
↓
Читал, и наверное его читали создатели Ruby on Rails, Django, Yii2 и тем не менее выбрали эту схему. Я могу привести мнение DHH (создателя RoR), но может быть лучше не авторитетами давить, а аргументированно критиковать?
И еще раз — я не против DI как такового… я про то, что это часто избыточно.
16 января 2017 в 13:32
0↑
↓
Читал, и наверное его читали создатели Ruby on Rails, Django, Yii2 и тем не менее выбрали эту схему
Какую «эту»? Слияния логики работы с БД с бизнес-логикой? Или все-таки интеграционного тестирования?
И еще раз — я не против DI как такового… я про то, что это часто избыточно.
А я и не про DI, я про разделение ответственностей. DI — лишь один из способов решения этой задачи.
16 января 2017 в 13:40
0↑
↓
Слияния логики работы с БД с бизнес-логикой
Да, не разделять их. Иногда это полезно, иногда нет. Я слышу про отделение базы только в контексте двух сценариев: тестирование и гипотетическая смена базы в будущем. Первое можно готовить и по другому, а второе похоже на раннюю оптимизацию.
Иногда полезно разделить бизнес-логику и обращение к данным. Это оправданно с точки зрения потока данных, алгоритмов — и я не имею к этому никаких претензий. Это может быть оправданно даже с позиции тестов — если вам их надо прогнать тычячи. Но этому есть своя цена и надо знать, что есть и альтернативы.
16 января 2017 в 13:42
0↑
↓
Я слышу про отделение базы только в контексте двух сценариев: тестирование и гипотетическая смена базы в будущем.
Хотя я изначально сказал вам о третьем: это разные ответственности, и их разделение уменьшает сложность кода, ответственного за бизнес-логику.
16 января 2017 в 13:48
0↑
↓
Увеличение уровней абстракции не факт, что ведет к уменьшению сложности. А очень даже наоборот — вам теперь нужно помнить как работают две сущности и их взаимодействие вместо одной.
Попробуйте пописать на python — довольно неплохо прочищает мозги. Мне C# милее в сотню раз, но свой отпечаток питон оставил.16 января 2017 в 13:51
0↑
↓
Увеличение уровней абстракции не факт, что ведет к уменьшению сложности
А никто не говорит, заметим, про увеличение уровней абстракции — можно просто заменить одну абстракцию другой.
А очень даже наоборот — вам теперь нужно помнить как работают две сущности и их взаимодействие вместо одной.
Мне не нужно помнить, как работает DAL, мне нужно знать, какой контракт он выполняет. И это ничем не отличается от того, чтобы помнить, какой контракт поддерживает Entity Framework или ADO.NET.
16 января 2017 в 13:47
0↑
↓
второе похоже на раннюю оптимизацию
В качестве мимокрокодила отмечу, что когда-то несколько лет назад тоже на это забил, а теперь сильно жалею об этом, так как для перехода с mysql на postgresql оказывается нужным по сути переписать ВСЁ, так и не перехожу до сих пор
16 января 2017 в 13:09
0↑
↓
Меня бы полностью удовлетворила такая формулировка: есть подход А и Б, вот их плюсы и минусы, решайте, что вам дороже обойдется. К сожалению часто звучит «есть только А, остальное ересь» и это напоминает картинку про PNG и JPEG.
16 января 2017 в 13:33
0↑
↓
Ну вот мы эти плюсы и минусы сейчас обсуждаем.
16 января 2017 в 12:35
+1↑
↓
«Проще» это только тогда, когда вы для каждого теста создаете нужную ему (и только ему) БД. Но вы представляете себе, насколько это медленно?
Я не знаю как с этим дела в C#, но в своих проектах на Python и Ruby я только так тесты и писал, всё тестирование с постоянным пересозданием этих баз занимало от нескольких секунд до 5–10 минут в зависимости от размера и оптимизированности проекта, имхо вполне приемлемо16 января 2017 в 12:37
0↑
↓
О… как я ждал этого комментария! Ирония в том, что так делают очень многие, но в мире .NET про это мало кто знает и порицается хуже чем goto))
16 января 2017 в 12:38
0↑
↓
При каком количестве тестов? БД создается на каждый тест?
16 января 2017 в 12:40
0↑
↓
Есть много способов как быстро подготовить базу без пересоздания — от использования транзакций (мне не нравится, т.к. хочу тестировать и транзакции тоже), до быстрой очистки.
16 января 2017 в 12:43
+1↑
↓
быстро
Насколько быстро?
16 января 2017 в 12:47
0↑
↓
Около 600 мс на тест
16 января 2017 в 12:50
0↑
↓
ALTER DATABASE ... SET RECOVERY SIMPLE ALTER DATABASE ... SET DELAYED_DURABILITY = FORCED
А если так, то сколько будет? :)16 января 2017 в 12:53
0↑
↓
А что здесь происходит? Можно попробовать замерить
16 января 2017 в 12:57
+1↑
↓
При создании новой базы настройки наследуются от базы model (если не учитывать некоторые нюансы). По дефолту в model стоит FULL. Если база создалась и для нее сделался бекап, то это приведет к разрастанию лога, если нет, то в Вашей базе будет неявно использоваться SIMPLE модель.Для базы с тестами мы также включаем модель восстановления SIMPLE и отложенную запись в лог DELAYED_DURABILITY = FORCED. В теории это самый простой путь без лишних телодвижений снизить время на подготовку данных для теста.
16 января 2017 в 12:59
0↑
↓
…, а у меня на (юнит-)тест уходит меньше 10 мс. Вот вам и порядок.
16 января 2017 в 13:19
0↑
↓
Зато при программировании и поддержке цифры меняются местами… там конечно, не будет отличия на порядок, но и время там подороже стоит.
16 января 2017 в 13:34
0↑
↓
Зато при программировании и поддержке цифры меняются местами…
Почему вдруг?
там конечно, не будет отличия на порядок, но и время там подороже стоит.
Понимаете ли, время, потраченное на выполнение интеграционного теста — это тоже мое время.
16 января 2017 в 12:41 (комментарий был изменён)
+1↑
↓
До пары тысяч бывало. Сейчас пилю Python-проект, полтысячи тестов выполняются за 20 секунд (с «пересозданием» БД на каждый тест, ага)16 января 2017 в 12:58
0↑
↓
полтысячи тестов выполняются за 20 секунд
У вас БД со всем наполнением создается за 40 мс?
(ну и да, я вот тут рядом попинал юнит-тесты, на тест уходит меньше 10 мс — и их еще и можно параллелить)
16 января 2017 в 13:07
0↑
↓
Наполнение у меня почти отсутствует, так что почему бы и нет)Сейчас попробовал принудительно создавать по тысяче записей перед каждым тестом (честной неоптимизированной тысячей insert-запросов :) — время выполнения увеличилось до 40 секунд, но я всё ещё считаю это приемлемым
Но всё равно так «в лоб» обычно редко делают, есть куча оптимизаций «пересоздания», в разной степени применимых в каждом конкретном случае)
16 января 2017 в 13:35
0↑
↓
Сейчас попробовал принудительно создавать по тысяче записей перед каждым тестом (честной неоптимизированной тысячей insert-запросов :) — время выполнения увеличилось до 40 секунд, но я всё ещё считаю это приемлемым
Понимаете ли, в чем дело, у меня тут под боком система, где ~1000 коротких интеграционных тестов идет где-то 40 минут. А начинали с секунд, да.
16 января 2017 в 13:37
0↑
↓
И я как-то сильно сомневаюсь, что в этих интеграционных тестах узким местом является или будет являться именно пересоздание БД)16 января 2017 в 13:39
0↑
↓
Там узкое место — это операции с БД. Включая ее инициализацию в корректное (нужное для каждого отдельного теста) состояние.
16 января 2017 в 13:44
0↑
↓
Ну от операций с БД мы в любом случае никуда не убежим, а топик вроде как лишь про её пересоздание)(Правда, я ничего не могу сказать про ту конкретную реализацию, что описана в топике, так как C# не юзаю)
16 января 2017 в 13:46
0↑
↓
Ну от операций с БД мы в любом случае никуда не убежим
Если использовать юнит-тесты вместо интеграционных — еще как убежим.
16 января 2017 в 12:37
+1↑
↓
Самая большая ложка дегтя с интеграционными тестами — это время выполнения, они намного медленнее, но это решаемая проблема.
У Вас БД для каждого теста пересоздается? Если да, то может помочь Instant File Initialization. Либо лучше базу вообще один раз создать, а потом использовать database snapshot для каждого теста. Начиная с 2016 SP1 эта функциональность и в Express редакции доступна.Как сделать быстрее тут когда-то публиковал про Delayed Durability. Для OLTP нагрузки как раз поможет снизить выполнение Ваших тестов.
16 января 2017 в 12:38
0↑
↓
База не пересоздается — в ней отключаются constraints и она чистится, получается очень быстро.
16 января 2017 в 12:40
0↑
↓
Констрейнты включаются после того как в таблицах появились свежие порции данных для нового теста?16 января 2017 в 12:40
0↑
↓
Конечно
16 января 2017 в 12:57
0↑
↓
Спасибо! Я попробую эти варианты!
16 января 2017 в 13:01
0↑
↓
ИМХО самый лучший вариант: создается база, создается snapshot, накатываются данные, тест проверяется, snapshot откатывается и все по-новому. Тут Вам и минимальная нагрузка на диск + не надо чистить каждый раз базу. В идеале конечно включить Delayed Durability, чтобы снизить WRITELOG ожидания коих при OLTP нагрузке будет достаточно.