Юнит-тесты и БД. Как откатить изменения, сделанные тестом?

в 6:15, , рубрики: .net, MS SQL, метки: , ,

Формулировка задачи

Если юнит-тесты работают с базой и меняют её — что сделать, чтобы результаты прогона были повторимы?
Ответ — чистить базу перед тестом. Но хочется иметь какой то набор данных в базе, чтобы каждый раз его туда не записывать из тестов (будем называть такие данные “базовым набором”). Таким образом мы упростим сами тесты и их 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 <hh user=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 <hh user=twho> EXEC SP_WHO

DECLARE spid_cursor CURSOR FOR
SELECT SPID FROM <hh user=twho>
WHERE DBName = <hh user=dbname>

OPEN spid_cursor

DECLARE <hh user=SpidToClose> INT

FETCH NEXT FROM spid_cursor
INTO <hh user=SpidToClose>

WHILE @<hh user=FETCH_STATUS> = 0
BEGIN

IF @<hh user=SPID> <> <hh user=SpidToClose>
	BEGIN
		-- kill не может работать с параметром.
		declare <hh user=str> varchar(32)
		set <hh user=str>='KILL ' + cast(<hh user=SpidToClose> as varchar(16))
		exec(<hh user=str>)
	END

	FETCH NEXT FROM spid_cursor
	INTO <hh user=SpidToClose>
END
CLOSE spid_cursor;
DEALLOCATE spid_cursor;

Скрипт по откату базы:

USE master
RESTORE DATABASE [FSID_test] FROM DISK = N'c:BackupPathHereBackupNameHere.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("<hh user=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() — не знал как делать запросы к результатам работы хранимых процедур. Пришлось почитать и решение мне не нравится.

Итак, попробовав всё это на практике, можно смело утверждать — подход жизнеспособен и удобен. Особенности:

  • Выявление подвисших подключений в коде тестами. (если не чистить их насильно или добавить в очистку логику посложнее)
  • базовый набор в явном виде и не подвержен изменениям из тестов. Его легко менять, когда это надо.
  • Устранение попыток использовать ДБ тестов во время билда на сервере непрерывной интеграции.

Автор: Alexus1024

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js