Обычным подходом в .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 (мок для статических системных методов)
Комментарии (81)
AlanDenton
16.01.2017 12:37+4Самая большая ложка дегтя с интеграционными тестами — это время выполнения, они намного медленнее, но это решаемая проблема.
У Вас БД для каждого теста пересоздается? Если да, то может помочь Instant File Initialization. Либо лучше базу вообще один раз создать, а потом использовать database snapshot для каждого теста. Начиная с 2016 SP1 эта функциональность и в Express редакции доступна.
Как сделать быстрее тут когда-то публиковал про Delayed Durability. Для OLTP нагрузки как раз поможет снизить выполнение Ваших тестов.justserega
16.01.2017 12:38База не пересоздается — в ней отключаются constraints и она чистится, получается очень быстро.
AlanDenton
16.01.2017 12:40Констрейнты включаются после того как в таблицах появились свежие порции данных для нового теста?
justserega
16.01.2017 12:57Спасибо! Я попробую эти варианты!
AlanDenton
16.01.2017 13:01+1ИМХО самый лучший вариант: создается база, создается snapshot, накатываются данные, тест проверяется, snapshot откатывается и все по-новому. Тут Вам и минимальная нагрузка на диск + не надо чистить каждый раз базу. В идеале конечно включить Delayed Durability, чтобы снизить WRITELOG ожидания коих при OLTP нагрузке будет достаточно.
mihasic
16.01.2017 16:41+3ИМХО, как-то у Вас все хардкорно. Маленькая библиотека — прям фрэймворк, который указывает на необходимый дизайн приложения и вносит зависимости (интерфейсы). При это все, что требуется для тестового набора — получить базу.
Так как данный подход очень мало распространен в .NET — почти нет никаких готовых библиотек для его реализации.
Подход распространен, нет смысла создавать библиотеку вокруг System.Data.SqlLocalDb. Пример, https://github.com/damianh/SqlStreamStore/blob/master/src/SqlStreamStore.MsSql.Tests/MsSqlStreamStoreFixture.cs
А для простых вставок данных (если абстрагироваться от логики самого приложения), достаточно и dapper-dot-net как легковесного решения (как пример подхода с минимумом абстракций).
У нас обычный тест выглядит так:
- получить базу (connection string)
- накатить схему
- добавить тестовые данные
- выполнить сам тест
- очистить ресурсы
Для ускорения, пустая база со схемой создается во время компиляции (post-build). А после, в каждом тесте/наборе, файл копируется и присоединяется (простой скрипт на master: CREATE DATABASE [...] ON (filename = ...)[, (filename = ...)]).
За наполнение данными отвечает сам тестируемый модуль, т.к. не всегда БД это только лишь CRUD, иногда есть поток сообщений/событий, из которого создаются проекции. Заодно и последнее тестируется.
Что касается "медленно" — так тут уже решается в рамках проекта, что и как тестировать. Как подсказали, иногда снэпшоты помагают. А иногда и разделение кода по репозиториям, как и сама оптимизация работы продукта.
justserega
16.01.2017 16:50-1Подход не очень распространен — нет решений, чтобы просто взять и начать тестировать. Все приходится собирать самому по кусочкам. Это и была попытка сделать решение работающее из коробки с рекомендациями как построить процесс для максимально простого тестирования.
Cromathaar
16.01.2017 16:44+1Обычным подходом в .NET к тестированию приложений работающих с базой данных является внедрение зависимостей (Dependency Injection). Предлагается отделить код работающий с базой, от основной логики путем создания абстракции, которую в дальнейшем можно подменить в тестах.
То, о чем вы говорите — это не внедрение зависимостей, а инверсия зависимостей. И подменяется, если уж на то пошло, не абстракция, а ее реализации.Cromathaar
16.01.2017 16:51Это очень мощный и гибкий подход, который тем не менее имеет некоторые недостатки — увеличение сложности, разделение логики, взрывной рост количества типов.
Логика в данном случае никак не разделяется — она как лежала в условном классе репозитория, так и лежит. Взрывного роста количества типов тоже никакого нет — добавляется всего-лишь по одному интерфейсу/абстрактному классу на каждый репозиторий. Отсюда резонный вывод, что сложность если и растет, то крайне незначительно, и увеличение косвенности в данном случае отбивается многократно уменьшением связанности модулей.justserega
16.01.2017 16:55Ну вот есть у вас сложный запрос — он уходит в репозиторий, его теперь не видно из кода бизнес-логики… а в нем почти вся суть метода. А его ведь еще и протестировать нужно. И для чего мне тогда разделять их?
Cromathaar
16.01.2017 16:59Что значит «не видно»? А как его было видно до этого? Или вы имеете в виду, что по нажатию F12 в студии вас теперь кидает на файл с абстрактным классом/интерфейсом, а не на класс реализации?
justserega
16.01.2017 17:04F12 это тоже проблема конечно, но меня больше смущает, что результат выполнения этого метода никому кроме моего бизнес-кода в этом конкретном методе и не нужен — зачем его выделять в отдельную сущность.
Cromathaar
16.01.2017 19:05Чтобы он был тестируемым, очевидно (я имею в виду здесь модульное тестирование). Если у вас код доступа к БД находится в самой модели (сиречь объекте бизнес-логики), то вполне очевидно, что вы не сможете покрыть эту модель юнит-тестами. Этим вы усложните для себя рефакторинг, а для всего проекта — внесение изменений, т.к. дизайн будет сильно связанным. Если вы пишете утилитку из трех файлов, то конечно на это можно плюнуть в угоду скорости разработки, но на большом проекте в долгосрочной перспективе вы сами роете себе яму.
lair
16.01.2017 17:01+1Ну вот есть у вас сложный запрос — он уходит в репозиторий, его теперь не видно из кода бизнес-логики… а в нем почти вся суть метода.
Если у вас суть метода бизнес-логики в сложном запросе, который вы перенесли в репозиторий, то у вас теперь бизнес-логика в репозитории. Который надо (теперь) тестировать теми же средствами, которыми тестируется остальная бизнес-логика.
justserega
16.01.2017 17:02И каким же?
lair
16.01.2017 17:05"Такими же". Если у вас бизнес-логика тестировалась юнит-тестами — значит, и это место надо покрывать юнит-тестами. Если у вас бизнес-логика тестировалась интеграционными тестами — значит, надо покрывать интеграционным. Если вперемешку — то, понятное дело, никто вам не указ.
(впрочем, в большей части случаев и так никто не указ, чего уж...)
justserega
16.01.2017 17:09Ну вот приехали… то есть бывают случаи когда проще применить интеграционный тест?
Danik-ik
16.01.2017 22:30Вот Вам пример, живее всех живых: приложение предназначено для обмена данными. Определяются граничные условия, делается подготовленная выборка, которая потом загоняется в какой-нибудь самобытный формат. И таких «обменов» в приложении более пятидесяти. Подготовка выборки вкупе с первичной обработкой данных самым естественным образом делегируется серверу. Таким образом, одними из главных кандидатов на тестирование выступают SQL запросы, тем более, что большая часть доработки тоже падает на них (запросов в среднем три-четыре на «обмен», реже от одного до десятка).
Вы умеете тестировать sql-запросы модульными тестами?
Cromathaar
16.01.2017 16:57Есть более простой подход, широко распространенный в мире динамических языков. Вместо создания абстракции, которую можно контролировать в тестах, этот подход предлагает контролировать саму базу.
Модульное и интеграционное тестирование — это два принципиально разных подхода. Сравнивать их подобно сравнению теплого и мягкого. Первый служит, вообще говоря, для спецификации интерфейсов и облечения рефакторинга, второй же — непосредственно для поиска дефектов. Чего вы хотите добиться, заменяя одно другим, остается непонятным еще с прошлой статьи.justserega
16.01.2017 17:00-1То, что за пределами нашего привычного опыта тестируют по другому. И это работает и довольно неплохо.
Cromathaar
16.01.2017 17:03Конечно, оно работает. Так же, как и ручное тестирование, например. Вы же не хотите предложить сообществу отказаться от юнит-тестов, потому что приложение можно потыкать руками? :)
justserega
16.01.2017 17:05Оно работает не в том смысле, что тесты загораются зеленым и красным. А в том, что достигается тот же и даже больший эффект меньшими усилиями.
Cromathaar
16.01.2017 18:58Я об том и говорю, что эффекты кардинально разные. Юнит-тестирование не предназначено для выявления дефектов. 99% всего интересного ловят функциональные и интеграционные тесты.
oxidmod
16.01.2017 17:33-1Я не знаю как в мире дот нета, но в пыхе мы покрываем юнит тестами отдельные классы. Юнит-тесты пишутся так, чтобы работал лишь код тестируемого метода, все остальные зависимости мокаются.
А функциональными тестами покрываются "юзкейсы". Число этих функциональных тестов равно сумме произведений количества ендпоинтов с которыми взаимодействует клиент приложения на количество вариантов ответов. В целом их выходит не много, потому пересоздавать бд не слишком накладно. Кроме того их запросто можно паралелить, так как они полностью изолированы друг от друга.
KYKYH
18.01.2017 05:15+1А я всегда тестирую приложение с тестовой базой, на реальных данных, всегда в дополнение к юнит тестам. Юнит тесты это очень хорошо, но написать юнит тест, который проведёт настоящие, рабочие данные от начала до конца процесса и проверит получился ли приемлемый результат вы задолбаетесь. В то же время, тестовая платформа, рабочие данные, и ожидаемый результат у вас есть всегда.
Dropsonde
19.01.2017 13:28+1То есть у разработчика есть доступ к реальным данным, пусть и к копии базы? Рискну предположить, что не все рассматривают это как вариант.
Fortop
20.01.2017 16:25- На этапе разработки этих данных может тупо не быть в природе
- Данные могут быть настолько объёмные, что не помещаются в дев.окружение.
Так что рассматривать не всегда получается
KYKYH
20.01.2017 23:47Если у вас есть проект, решающий реальную рыночную задачу, то обязательно есть специалист, который разбирается в рынке достаточно, чтобы решать эту задачу не частным образом, а понимать как она решается принципиально. Этот специалист курирует проект. Вот он, если не может сам создать реальный, рабочий набор данных, пусть и фиктивных, то должен как минимум объяснить команде разработчиков природу этих данных достаточно доходчиво, чтобы они их создали сами, или сделали генератор, который на это способен.
А если у вас нету возможности сделать прикладной тест, то вы всегда будете производить кота в мешке. Все юнит тесты пройдут хоть тысячу раз, а реальный юз-кейс типа “ввёл стопку чеков, нажал кнопку, и получил годовой отчёт” всё равно может не пройти. А если ваша рабочая задача сделать экспорт из своего приложения в другое, и процесс экспорта подразумевает обработку данных? А если на выходе этих данных должно быть несколько гигабайт? С рандомно намоканной абракадаброй (а я такое видел) это не протестируешь, а внедрять всё это в определение какого-нибудь мега-монстро-теста кто угодно сойдёт с ума.
Для любых приложений нужны тестовые базы данных, будь то финансовая отчётность, учёт инвентаря, автоматизация рабочих процессов, машинное обучение, прогнозирование любой фигни от потребления птичьего корма до расхода топлива.lair
21.01.2017 01:27-1Так вот, фиктивные данные — они фиктивные, они не реальные. Это тоже мок, просто другого уровня.
Fortop
21.01.2017 01:57Причем печаль этих тестовых БД в том, что автор этих данных «знает» какие правильные данные должны быть со всеми их связями.
Кейсы для случаев когда в БД оказались некорректные данные зачастую не тестируются вовсе.
Возможно их и стоило бы опускать при 100% покрытии. Вот только его в сложных случаях не бывает.
А именно там (в тех самых сложных случаях) тесты приносят наибольшую пользу.
amakhrov
18.01.2017 08:36Мой юз-кейз: Создание отчета по набору фильтров, заданному пользователем.
Существенная часть логики — конструирование сложного SQL-запроса по этому набору фильтров.
Я тестирую эту логику на настоящей (тестовой) базе — берем пустую схему, тестовый метод наполняет его набором данных, проверяем, что сконструированный запрос вернет ожидаемые данные.
Вопросы к знатокам:
- Это еще юнит-тест или уже интеграционный? (тут уточню, что создание и выполнение запроса это еще не все, что делает приложение при генерации отчета — там и пост-обработка, и слияние с данными из других источников).
- Можно ли (и имеет ли смысл) такой сценарий тестировать без БД? (речь идет о тестирование именно той части, которая делает первичную выборку данных на основе переданных фильтров)
justserega
18.01.2017 08:43- Это точно не юнит-тест. По большинству терминологий, юнит-тест — это тест без зависимостей и выполняющийся за очень короткое время
- Без БД это очень проблематично и не дает гарантии, что нет ошибок
mayorovp
18.01.2017 08:51Можно использовать для тестирования In-Memory DB. Юнит-тестами это будет если выполнение запроса, пост-обработка, слияние с другими источниками и генерация самого отчета будут проверяться раздельно.
amakhrov
18.01.2017 10:54Спасибо.
In-Memory DB
На другую БД заменить непросто, используются специфичные вещи типа JSON-функций в MySQL.
выполнение запроса, пост-обработка, слияние с другими источниками и генерация самого отчета будут проверяться раздельно
Да, я специально уточнил, что проверяется только логика построения запроса, который должен вернуть ожидаемые сырые данные (пост-обработка тестируется отдельно, уже без БД). Тогда это юнит-тест?
lair
Разделение логики — это достоинство, а не недостаток. А "взрывной рост" наблюдается только там, где при проектировании допущена ошибка.
Проще, серьезно? "Проще" это только тогда, когда вы для каждого теста создаете нужную ему (и только ему) БД. Но вы представляете себе, насколько это медленно? Поэтому начинают переиспользовать БД между несколькими тестами — а это уже анти-паттерн shared fixture, ну и понеслась...
А еще представьте себе, как просто это делать на билд-агентах при каждом билде.
Это пока разработчик не начинает простаивать, ожидая выполнения чего-то на сервере.
Интеграционные тесты — это не альтернатива DI. Интеграционные тесты — это "альтернатива" юнит-тестам; хотя на самом деле, интеграционные тесты — это другой способ тестирования, не способный заменить юнит-тестирование (в обратную сторону тоже верно).
justserega
Только если это разделение по ответственности или еще каким-то логическим критериям, а не искусственное — чтобы отделить обращение к базе.
Не надо придумывать того, что не написано — не надо переиспользовать. Не настолько медленно как принято представлять, и есть куда думать, чтобы ускорить.
Альтернатива — то как можно тестировать. А интеграционные тесты это дополнение к unit-тестам.
lair
Обращение к БД — это и есть другая ответственность. Поэтому разделение работы с БД и разделение бизнес-логики — это разделение по ответственности.
Понимаете ли, я опираюсь не на "принято представлять", а на свою ежедневную деятельность, в которой много интеграционных тестов. И они — медленные. На несколько порядков медленее, чем юнит-тесты.
Например? Потому что в моем опыте "куда ускорить" неизбежно приводит к shared fixture, потому что все рано или поздно упирается во время развертывания чистой БД.
Если дополнение — значит, от DI вы отказаться не сможете. Поэтому и не альтернатива.
justserega
Тут можно поспорить — так принято в .NET, что работа с БД это отдельная ответственность. И я считаю, что во многом из-за того, что по другому не протестировать.
Не могу и не хочу, а еще не хочу микроскопом гвозди забивать. У меня в проектах есть логика, которая тестируется и unit-тестами и интеграционными — потому что там ответственно и сложно, а есть где только интеграционные — потому что ну нет там смысла городить весь этот огород.
lair
Далеко не только в .net. Вы Фаулера читали?
Нет, потому что так сложность меньше.
justserega
Читал, и наверное его читали создатели Ruby on Rails, Django, Yii2 и тем не менее выбрали эту схему. Я могу привести мнение DHH (создателя RoR), но может быть лучше не авторитетами давить, а аргументированно критиковать?
И еще раз — я не против DI как такового… я про то, что это часто избыточно.
lair
Какую "эту"? Слияния логики работы с БД с бизнес-логикой? Или все-таки интеграционного тестирования?
А я и не про DI, я про разделение ответственностей. DI — лишь один из способов решения этой задачи.
justserega
Да, не разделять их. Иногда это полезно, иногда нет. Я слышу про отделение базы только в контексте двух сценариев: тестирование и гипотетическая смена базы в будущем. Первое можно готовить и по другому, а второе похоже на раннюю оптимизацию.
Иногда полезно разделить бизнес-логику и обращение к данным. Это оправданно с точки зрения потока данных, алгоритмов — и я не имею к этому никаких претензий. Это может быть оправданно даже с позиции тестов — если вам их надо прогнать тычячи. Но этому есть своя цена и надо знать, что есть и альтернативы.
lair
Хотя я изначально сказал вам о третьем: это разные ответственности, и их разделение уменьшает сложность кода, ответственного за бизнес-логику.
justserega
Увеличение уровней абстракции не факт, что ведет к уменьшению сложности. А очень даже наоборот — вам теперь нужно помнить как работают две сущности и их взаимодействие вместо одной.
Попробуйте пописать на python — довольно неплохо прочищает мозги. Мне C# милее в сотню раз, но свой отпечаток питон оставил.
lair
А никто не говорит, заметим, про увеличение уровней абстракции — можно просто заменить одну абстракцию другой.
Мне не нужно помнить, как работает DAL, мне нужно знать, какой контракт он выполняет. И это ничем не отличается от того, чтобы помнить, какой контракт поддерживает Entity Framework или ADO.NET.
andreymal
Carburn
В чём проблема? Вы писали SQL запросы вручную?
andreymal
В те времена да. Теперь не пишу)
Fortop
Если у вас SQL запросы локализованы в слое DBAL, то переписать их это не означает переписать «ВСЁ»
sshikov
Я вижу разделение базы чуть ли не каждый день. И замену одной базы на другую — например, в тестах вместо PROD базы MS SQL используется in-process база H2, а зачастую и in-memory база тоже. Для тестов, да. Это очень, очень широко распространенная практика в мире Java.
justserega
Меня бы полностью удовлетворила такая формулировка: есть подход А и Б, вот их плюсы и минусы, решайте, что вам дороже обойдется. К сожалению часто звучит "есть только А, остальное ересь" и это напоминает картинку про PNG и JPEG.
lair
Ну вот мы эти плюсы и минусы сейчас обсуждаем.
andreymal
Я не знаю как с этим дела в C#, но в своих проектах на Python и Ruby я только так тесты и писал, всё тестирование с постоянным пересозданием этих баз занимало от нескольких секунд до 5-10 минут в зависимости от размера и оптимизированности проекта, имхо вполне приемлемо
justserega
О… как я ждал этого комментария! Ирония в том, что так делают очень многие, но в мире .NET про это мало кто знает и порицается хуже чем goto ))
lair
При каком количестве тестов? БД создается на каждый тест?
justserega
Есть много способов как быстро подготовить базу без пересоздания — от использования транзакций (мне не нравится, т.к. хочу тестировать и транзакции тоже), до быстрой очистки.
lair
Насколько быстро?
justserega
Около 600 мс на тест
AlanDenton
А если так, то сколько будет? :)
justserega
А что здесь происходит? Можно попробовать замерить
AlanDenton
При создании новой базы настройки наследуются от базы model (если не учитывать некоторые нюансы). По дефолту в model стоит FULL. Если база создалась и для нее сделался бекап, то это приведет к разрастанию лога, если нет, то в Вашей базе будет неявно использоваться SIMPLE модель.
Для базы с тестами мы также включаем модель восстановления SIMPLE и отложенную запись в лог DELAYED_DURABILITY = FORCED. В теории это самый простой путь без лишних телодвижений снизить время на подготовку данных для теста.
lair
… а у меня на (юнит-)тест уходит меньше 10 мс. Вот вам и порядок.
justserega
Зато при программировании и поддержке цифры меняются местами… там конечно, не будет отличия на порядок, но и время там подороже стоит.
lair
Почему вдруг?
Понимаете ли, время, потраченное на выполнение интеграционного теста — это тоже мое время.
justserega
Потому что меньше кода, он лучше локализован, тесты максимально приближены к реальной системе (никаких моков) — создал за 10 минут сценарий и погнали.
lair
Меньше кода где?
Локализация тут вообще ни при чем.
10 минут — это круто, да. Хотел бы я вам поверить, но не выходит.
А главное, быстрое выполнение тестов — это больше тестов и более частое их выполнение — более раннее обнаружение багов — меньше затрат на саппорт. И упрощение рефакторинга.
andreymal
До пары тысяч бывало. Сейчас пилю Python-проект, полтысячи тестов выполняются за 20 секунд (с «пересозданием» БД на каждый тест, ага)
lair
У вас БД со всем наполнением создается за 40 мс?
(ну и да, я вот тут рядом попинал юнит-тесты, на тест уходит меньше 10 мс — и их еще и можно параллелить)
andreymal
Наполнение у меня почти отсутствует, так что почему бы и нет)
Сейчас попробовал принудительно создавать по тысяче записей перед каждым тестом (честной неоптимизированной тысячей insert-запросов :) — время выполнения увеличилось до 40 секунд, но я всё ещё считаю это приемлемым
Но всё равно так «в лоб» обычно редко делают, есть куча оптимизаций «пересоздания», в разной степени применимых в каждом конкретном случае)
lair
Понимаете ли, в чем дело, у меня тут под боком система, где ~1000 коротких интеграционных тестов идет где-то 40 минут. А начинали с секунд, да.
andreymal
И я как-то сильно сомневаюсь, что в этих интеграционных тестах узким местом является или будет являться именно пересоздание БД)
lair
Там узкое место — это операции с БД. Включая ее инициализацию в корректное (нужное для каждого отдельного теста) состояние.
andreymal
Ну от операций с БД мы в любом случае никуда не убежим, а топик вроде как лишь про её пересоздание)
(Правда, я ничего не могу сказать про ту конкретную реализацию, что описана в топике, так как C# не юзаю)
lair
Если использовать юнит-тесты вместо интеграционных — еще как убежим.
ZOXEXIVO
Обожаю читать разносы .NET разработчиков от liar