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

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


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

Эти подходы, как и предложный мной, не лишены своих недостатков (созданную базу надо удалять, транзакция не всегда откатывается быстрее и накладывает ограничения на хранимые процедуры), но серьёзно рассмотреть их стоит.

Similar posts

Ads
AdBlock has stolen the banner, but banners are not teeth — they will be back

More

Comments 25

    +7
    Почему у Вас возникает проблема подключённых пользователей? Можно же просто создавать для тестов совершенно новую базу данных, имя которой никто кроме Вас не знает и соединиться кроме тестового скрипта не может.
      –1
      Не вижу особой разницы — разворачивать бэкап каждый раз в новую базу или в одну и ту же. Есть ли разница в скорости?
      Ещё вопрос — вы предлагаете создавать базу на прогон или на каждый тест?
      Ограничить права по подключениям — кажется, здраво. Но как и любая строгость — ведёт к неудобствам. Хочется без заморок подключаться к тестовой базе, используя какой то стандартный логин/пароль и известное имя. Я не обижусь, если меня выкинет, когда я помешаю системе работать.
        +1
        У Вас основное, что делает Ваша система подготовки базы — это отключение пользователей. 1) а этим пользователям работать с базой не нужно? и 2) вы запускаете тесты в базе, в которой кто-то уже пошалил и в принципе непонятно годится ли она в качестве эталонной базы. Все проблемы решаются одним — для всего прогона, или для каждого отдельного теста — как будет лучше — создавать новую базу. Можно нового пользователя для подключения к ней — заодно оттестируете и этот аспект, ошибки при смене имени пользователя тоже бывают.

        В результате две страницы скриптов превращаются в одну строчку восстановления базы из архива :)
          0
          Признаться, было просто просто интересно написать отключающий скрипт и поглядеть как оно работает. Ну а бенефит как раз и получился в том, что базу менять не надо.
          В целом, согласен, что решение с созданием базы вяглядит проще.

          Не понял тезис про «пошалил» — я ведь перед тестом из бекапа восстанавливаю. Там только если успеть во время теста что то сделать.
      +2
      Могу вам посоветовать вместо KILL использовать 'ALTER DATABASE SET OFFLINE WITH ROLLBACK IMMEDIATE'
        +1
        Категорические поддерживю. Приведенный ТС килл-код во-первых громоздок, во-вторых не исключает подключений в процессе тестирования. Но только сделал бы не SET OFFLINE, а SET SINGLE_USER.
          –1
          категорически спасибо за совет, опробую. Посмотрю на поведение при подключенных многих юзерах. Managmet studio у меня не могла перевести базу в автономный режим, когда к ней висели левые подключения ещё — и я не стал копать в этом направлении.
        0
        Поставил "-" за «Юнит-тесты и БД», если есть база данных то тесты как минимум интеграционные
          –1
          Если у меня в базе есть хранимая функция [Core].[String_Split] и хранимка, ее тестирующая — [Test].[Core.String_Split], то вызов последней — интеграционный тест?
          0
          Не совсем понял, зачем городить такой огород с откатами, когда есть транзакции, поэтому опишу работающую схему в том виде, как это происходит у нас.

          Для тестов используется отдельная база, с имеющимся необходимым набором данных, которая лежит на сервере и доступна всем и каждому и на всякий случай каждую ночь восстанавливается из бэкапа.
          Если тест изменяет какие-то данные, он оборачивается в транзакцию (TransactionScope в .NET, например), которая после выполнения теста отказывается — таким образом главная задача оставить состояние в том виде, которое было до выполнения теста, решается.
          При необходимости добавить в тестовую базу новые данные, они добавляются и делается бэкап, который подменяет тот, который используется для ночного восстановления.
            +2
            Ну и конечно юнит-тесты в идеале к БД отношения не имеют, это уже скорее интеграционное тестирование.
              +1
              Хм, а какое решение, если тестируемый модуль работает с базой данных? Делать какую-то абстракцию и использовать заглушки?
                +1
                Ну да, абстракция, мок-объекты.
                  +1
                  А если за работу с базой отвечает ORM? Много труда будет используемые фишки ORM закрывать через mock-объекты. Хотя это, конечно, правильнее :)
                    0
                    Стандартная и часто описываемая ситуация: есть ORM, есть репозиторий, через ORM тягающий данные. Создаются mock-репозитории, остальное не меняется.
                    На деле же конечно не всегда все получается так радужно, многие работают сразу напрямую в бизнес-логике через ORM, без всяких прослоек. Так что суровая реальность иногда играет роль, тут вы правы.
              –1
              Для сколь-нибудь серьезного теста использовать транзакцию может быть очень накладно по времени. Я бы даже сказал ооооочень.
                0
                Если уж на то пошло, то юнит тесты на то и юнит тесты, что бы тестировать очень небольшой кусок, о каких серьёзных тестах речь?
                Кроме того, это не накладнее по времени, чем делать какие-то откаты после каждого теста скриптами. Если же откаты не делать после каждого теста, а делать после выполнения целой тест-сессии, то нарушается весь принцип тестирования — каждый предыдущий тест начинает влиять на следующий.
                  0
                  Давайте себе представим простейший тест, который сравнивает вычисляемое некоторой функцией значение с заданным. За функцией же кроется замысловатый агрегат по некоторым тестовым наборам данных, которые определены на десятках таблиц с миллионами записей. Это «очень небольшой кусок» или как? А если в процессе заполнения этим тестовым набором данных я буду тестировать процедуры заполнения — это будет «нарушение принципа тестирования»?

                  В общем и целом я просто хотел предупредить, поскольку именно этот способ мы использовали долгое время, что иногда можно столкнуться с ситуацией, когда роллбек после теста идет от нескольких часов, до нескольких дней. И речь идет не о каких-то абстрактных тестах, а конкретных требованиях и наборах данных, полученных от конкретных заказчиков, с конкретным требованием в ТЗ, чтобы во время приемо-сдаточных испытаний получаемые значения соответствовали эталонным.
                    0
                    Надо сокращать наборы данных на которых фактически выполняются тесты, например вводом фиктивных параметров в хранимки и WHILE (@a is null or @a=tbl.field)
                0
                Абсолютно также делаем. TransactionScope — лучшее и самое красивое решение.
                  +1
                  А если у нас в тестах есть свои транзакции, и тесты исследуют подробности их проведения или отката?
                  Возможны ли какие то сторонние эффекты от такого подхода?
                    0
                    договоренность по использованию (точнее не использованию) транзакций в хранимках. По крайней мере для тестируемых
                    0
                    интересно, можно ли (теоретически, хотя бы) на небольшом базовом наборе нагородить таких транзакций, чтобы они откатывались дольше применения бекапа?
                      0
                      Чисто теоретически — да, практически — нужно очень много данных. Тут дело не в количестве транзакций, а в количестве модифицированных страниц в базе (т.е. операций вставки/удаления/обновления). Чем больше страниц изменено/добавлено -> тем больше записей в журнале транзакций -> тем больше изменений нужно откатывать. А вообще — it depends. Я попробовал сделать это у себя и вот что получилось: я добавил одну строку и затем обновил её же 500000 раз (все это, конечно же, в одной транзакции). Откат этих операций занял примерно 3222 ms. Восстановление базы из бэкапа — 1728 ms.
                    0
                    момент первый — «юнит-тесты работают с базой и меняют её» — это, извините, ни в какие ворота. при тестировании работы с базой уместно говорить об интеграционных тестах.
                    момент второй — используйте FluentMigrator

                    Only users with full accounts can post comments. Log in, please.