Формулировка задачи
Если юнит-тесты работают с базой и меняют её — что сделать, чтобы результаты прогона были повторимы?
Ответ — чистить базу перед тестом. Но хочется иметь какой то набор данных в базе, чтобы каждый раз его туда не записывать из тестов (будем называть такие данные “базовым набором”). Таким образом мы упростим сами тесты и их setup во много раз.
Осмотр решений
Как это можно реализовать? В голову приходят несколько вариантов:
- Очищать БД полностью и вставлять в неё данные базового набора каждый раз из кода или скриптом;
- скриптом очистки удалять новые сущности. Но надо как то отделить сущности базового набора от изменяющихся. Так же есть опасность изменения сущностей базового набора из тестов;
- откатывать базу до резервной копии перед тестом;
- то же самое, но моментальные снимки (snapshot) вместо резервной копии.
(какие ещё варианты предложите вы?)
Выбор решения
Поработав некоторое время с подобным решением на базе “скрипта очистки”, было решено попробовать что то новое. Этим новым стал вариант с “резервной копией”.
Замечу, что моментальные снимки мне нравятся больше, но их нет в MS Sql Express, а я работаю с ним.
Весь подход разрабатывался для платформы .Net и MS Sql server.
Реализация
Первые пробы выявили следующие проблемы:
- Проблема занятости базы. Для выполнения команы RESTORE требуется получение эксклюзивного доступа к базе. Если с этой базой есть другие активные соединения — то выполнение завершается ошибкой.
- Бывает, тесты не закрывают подключения; Соостветсвенно, надо закрывать и следить за этим. Либо — прикрывать подвисшие поделючения насильно перед откатом.
- В .Net есть ConnectionPool. Он держит подключения даже после закрытия для повышения производительности. Решение — SqlConnection.ClearAllPools();
- Кто то левый может просто подключиться к базе. к примеру, через Managment Studio и испортить сборку тестов. Решение — выкидывать этих деятелей с тестовой базы закрывая их подключения.
- Команда RESTORE должна принимать источник для восстановлянеия. Это как правило имя файла на сервере MS Sql. Конечно, хотелось бы скрыть эту подробность в самой СУБД. Но не получается. Решением могли бы быть SNAPSHOT (тогда в код/скрипт пришлось бы забивать только имя базы-снимка, что приемлемо). Но их нет в express.
- Скорость применения бэкапа. Так как откат происходит в идеале перед каждым тестом, то его скорость весьма критична. Моя небольшая база в 11 Мб восстанавливалась за 0.216 секунды, что приемлемо. Характеристики роста этого параметра в зависимости от размера базы не исследовались толком.
- По моему опыту, размер базового набора не склонен как то сильно расти по мере развития проекта;
- При составлении базового набора стоит задуматься о минимизации его размера, в мегабайтах.
Итак, пришло время реализации.
Получается, перед тестом, в SetUp будут выполнятся скрипты по удалению лишних подключений к БД, а потом — восстановление из резервной копи.
Скрипт отключения пользователей:
DECLARE @twho TABLE( SPID int , ecid int , [STATUS] NVARCHAR(64) , [Loginame] NVARCHAR(64) , [HostName] NVARCHAR(64) , [Blk] int , [DBName] NVARCHAR(64) , cmd NVARCHAR(64), request_id INT) INSERT INTO @twho EXEC SP_WHO DECLARE spid_cursor CURSOR FOR SELECT SPID FROM @twho WHERE DBName = @dbname OPEN spid_cursor DECLARE @SpidToClose INT FETCH NEXT FROM spid_cursor INTO @SpidToClose WHILE @@FETCH_STATUS = 0 BEGIN IF @@SPID <> @SpidToClose BEGIN -- kill не может работать с параметром. declare @str varchar(32) set @str='KILL ' + cast(@SpidToClose as varchar(16)) exec(@str) END FETCH NEXT FROM spid_cursor INTO @SpidToClose END CLOSE spid_cursor; DEALLOCATE spid_cursor;
Скрипт по откату базы:
USE master RESTORE DATABASE [FSID_test] FROM DISK = N'c:\BackupPathHere\BackupNameHere.bak' WITH FILE = 2
Код для вызова из SetUp теста
public static void RevertDb() { // если кто то юзает базу - мы не сможем её откатить. закроем все чужие подключения var sb = new SqlConnectionStringBuilder(Utilities.ConnectionDb) { ConnectTimeout = 2, ApplicationName = "FSID Tests, clearing" }; using (var con = new SqlConnection(sb.ToString())) { con.Open(); using (var cmd = con.CreateCommand()) { cmd.CommandText = Utilities.CommandKillAllConectionsToDb; cmd.Parameters.AddWithValue("@ dbname", sb.InitialCatalog); // тут надо убрать пробел в литерале var result = cmd.ExecuteScalar(); } } // дотнет не закрывает подключения насовсем - он их в кэше приберегает, зараза. От этого откат базы ломается. Почистим кэш. SqlConnection.ClearAllPools(); using (var con = new SqlConnection(sb.ToString())) { con.Open(); using (var cmd = con.CreateCommand()) { cmd.CommandText = Utilities.CommandRevertTestDb; cmd.ExecuteScalar(); } } }
Сейчас всё выглядит довольно просто, но в процессе пришлось решить несколько мелких проблем, с которыми раньше на сталкивался:
- ConnectionPool — удивительное рядом. Долго искал, какая зараза держит подключение.
- KILL param — оказывается KILL нельзя вызывать с параметром. Есть обход через EXEC
- SELECT FROM SP() — не знал как делать запросы к результатам работы хранимых процедур. Пришлось почитать и решение мне не нравится.
Итак, попробовав всё это на практике, можно смело утверждать — подход жизнеспособен и удобен. Особенности:
- Выявление подвисших подключений в коде тестами. (если не чистить их насильно или добавить в очистку логику посложнее)
- базовый набор в явном виде и не подвержен изменениям из тестов. Его легко менять, когда это надо.
- Устранение попыток использовать ДБ тестов во время билда на сервере непрерывной интеграции.
UPD:
В комментариях советуют две вещи:
- создавать новую базу под каждый прогон тестов — избегая необходимости отключать левых пользователей.
- использовать для отката транзакции, а не резервную копию
Эти подходы, как и предложный мной, не лишены своих недостатков (созданную базу надо удалять, транзакция не всегда откатывается быстрее и накладывает ограничения на хранимые процедуры), но серьёзно рассмотреть их стоит.
