Привет, меня зовут Ксюша Астахова, и я инженер-программист в Контуре. Хочу поделиться способом изоляции базы данных для автотестов. Статья будет полезна бэкенд-разработчикам и тестировщикам. 🙌

Проблема
Предположим, что мы работаем над фотогалереей. Пользователи что-то фоткают, а также могут отметить фотки удалёнными – для них это значит, что мы перекладываем фотографии в «Недавно удалённые», а через 30 дней их по-настоящему удаляем. Пусть это делается по расписанию раз в день в полночь. С точки зрения системы у нас есть сервис PhotoService
, который ходит в базу данных, и демон, который раз в полночь дёргает метод photoService.ClearRecentlyDeleted
:
class Photo {
Guid Id { get; init; }
byte[] Content { get; init; }
}
interface IPhotoService {
Task<IEnumerable<Photo>> Get();
Task<IEnumerable<Photo>> GetRecentlyDeleted();
Task Save(Photo photo);
Task MarkAsDeleted(Guid id, DateTime timestamp);
Task ClearRecentlyDeleted();
}
Мы, как порядочные разработчики, пишем тесты, проверяющие различные сценарии. Например, хотим проверить, что после того, как мы отметили несколько фоток удалёнными, через какой-то промежуток времени они действительно удалятся. Для этого нам нужно запустить демона параллельно запуску тестов, а сам тест написать примерно следующим образом:
[Test]
public async Task Delete_Photos_That_Were_Recently_Deleted()
{
var photo1 = CreatePhoto();
var photo2 = CreatePhoto();
await photoService.Save(photo1);
await photoService.Save(photo2);
await photoService.MarkAsDeleted(photo1);
await photoService.MarkAsDeleted(photo2);
await Task.Delay(2.Seconds()); // ждём, пока демон в фоне окончательно удалит недавно удалённые фото. Подстроим периодичность работы демона сообразно тесту
var actualPhotos = await photoService.GetRecentlyDeleted();
actualPhotos.Should().NotContain(new [] { photo1.Id, photo2.Id });
}
А ещё мы хотим протестировать более простой сценарий: после того, как мы пометили фотку удаленной, она отображается у нас в галерее в «Недавно удаленных»:
[Test]
public async Task Save_Photo()
{
var photo = CreatePhoto();
await photoService.Save(photo);
await photoService.MarkAsDeleted(photo.Id);
var actual = await photoService.GetRecentlyDeleted();
actual.Should().Contain(photo.Id);
}
Если эти тесты гоняются параллельно, то может возникнуть следующая ситуация: во втором тесте мы положили фото в БД, потом в первом тесте дождались, пока демон удалит все фото за последние две секунды, далее мы пытаемся во втором тесте найти наше фото и… его нет. :( Если вы будете дебажить это локально, то, скорее всего, будете гонять только упавший тест, который сам по себе проходит успешно. Не сразу станет понятно, что проблема упавшего теста находится вообще в другом классе, да и тест будет падать далеко не всегда.
Решение
Небольшое отступление для объяснения терминов. Так как мы пользуемся MongoDB, то таблица в БД у нас называется коллекцией, далее я буду использовать именно этот термин. Подходы в этой статье описаны как можно более обобщённым образом, поэтому использование других СУБД не должно пугать читателей.
Как можно решить возникшую проблему? В некоторых случаях возможно написать тесты так, чтобы они друг другу не мешали. И это будет самый классный вариант. Сделать так получается не всегда, поэтому обсудим способы изоляции коллекций.
Первый способ, который приходит в голову — создание своей БД под проблемный тест. Например, в качестве имени БД можно указать имя теста. Тогда туда точно никто не залезет. Однако с ростом числа тестов увеличивается и количество создаваемых БД. Это влечёт за собой несколько нежелательных последствий:
десятки наименований в просмотрщике баз;
предыдущей проблемы не будет, если мы будем поднимать контейнер с БД для каждого теста, но тогда тесты будут гоняться медленнее, потому что для каждого теста потребуется накатывать схему, а мы не хотели этого;
созданные коллекции наполняются и потребляют память до следующего прогона;
за один прогон создаётся коллекций больше, чем нужно одновременно. Уровень параллелизма тестов ограничен. В нашей команде он равен восьми. Если тестов, которые нужно изолировать, больше 50, то большинство созданных коллекций под каждый тест в моменте нам не нужны.
Раньше мы пользовались созданием коллекций под каждый изолированный тест с добавлением хеша от полного имени теста в качестве названия БД. Однако затем решили попробовать другой, а именно — использование пула коллекций, о концепции которого и пойдёт речь дальше.
Пул коллекций выглядит следующим образом:
interface IPool
{
ICollection GetIsolatedCollection();
void Return(ICollection collection)
}
Суть такого метода заключается в том, что если нам для теста нужна изолированная коллекция, то мы запросим из пула «как будто бы только что созданную» коллекцию. Давайте будем считать, что в пуле коллекции всегда чистые, тогда нам нужно просто запросить коллекцию из пула с помощью pool.GetIsolatedCollection()
. Затем после того, как тест завершился, на диспоузе коллекции мы вернём ее в пул. И на этом всё! Да, вот так просто это звучит. Далее обсудим подробности реализации такого способа.
Улучшения
Честно говоря, задача подразумевала не только изоляцию базы данных, но и несколько предложений по улучшению написания и отладки этих тестов. А именно, нам хотелось, чтобы:
мы могли увидеть, что осталось в БД после прогона, если тест упал. Это помогает проще отлаживать тесты. Эту проблему мы решили так: если тест упал, то используемые в нём коллекции не возвращались обратно в пул, а оставались с положенными в них данными. Нужно также обеспечить то, чтобы их в итоге можно было без труда отыскать;
оптимизировать заведомо «пустые» запросы на чтение: если из изолированной коллекции читают до записи, то не делать реальный запрос, а эмулировать его, возвращая эквивалент пустого чтения, ведь мы помним, что взятая нами коллекция всегда пуста;
появилась ещё одна оптимизация: при возврате в пул не очищать коллекцию, в которую не производилось записей.
Всего этого получилось достичь.
Реализация
Очистка коллекций обеспечивается методом Return
. Внутри него мы создаём и запускаем таску, которая почистит коллекцию, а уже после этого вернёт её в пул. Таким образом, из пула мы берём всегда чистую коллекцию.
Отмечу, что мы не дожидаемся очистки, а лишь инициируем её, иначе прогон теста замедлился бы возвращением всех используемых им коллекций. В конце прогона всех тестов мы дожидаемся выполнения всех тасок.
Уровень параллелизма наших тестов равен восьми. Чтобы не ждать очистки коллекции, нам хватит 16 экземпляров коллекций на одну оригинальную. Работает это так: мы запустили несколько тестов одновременно, и все они используют, к примеру, коллекцию photos. Тогда каждому тесту достанется свой экземпляр этой коллекции. Когда какой-то тест завершит работу, то отправит коллекцию в фоновую очистку, а для нового теста будет готова коллекция, которую уже не надо очищать. Представим, что мы запустили прогон тестов и в одно время запустились восемь тестов, использующих одну и ту же коллекцию. Если все восемь тестов закончат свою работу примерно в одно время, то следующей восьмерке не надо ждать, пока завершится очистка после предыдущих тестов – можно использовать ещё восемь коллекций. За время пока работает новая восьмерка тестов, мы успеем очистить первые восемь коллекций. Таким образом, ждать практически не приходится.
Как изолировать тест в коде? На самом деле изолированный тест мало чем отличается от обычного, за исключением дополнительной конфигурации DI-контейнера, которую получилось уложить в небольшой метод расширения. Он подкладывает вместо обычного хранилища изолированную реализацию. Изолированные коллекции мы храним в БД с именами оригинальных коллекций, но добавляем префикс {machine_name}_{env}_isolated
. К примеру, если «обычная» база данных называется gallery, а коллекции в нём – users и photos, то в изолированной среде база данных будет называться agent8_testing_isolated_gallery, а коллекции – users.0, users.1, … и photos.0, photos.1, …
Как мне кажется, идея решения звучит довольно понятно и вполне реализуемо, но при разработке мы столкнулись со множеством подводных камней, начиная от правильной архитектуры, учитывающей всю существующую, и накатывания индексов, заканчивая настройкой транзакций. В этой статье об этих нюансах я писать не буду, так как у каждой команды такое решение обрастёт своей спецификой.
Если интересно узнать о трудностях, с которыми мы столкнулись, напишите комментарий к этой статье, расскажу подробнее.
Результаты
Изолировать тесты стало проще и приятнее, также это способствовало другим идеям нашей команды по продвижению более управляемых сервисов. При этом, много сил ушло на раскопки разных спецэффектов и их починку, на что мы изначально не рассчитывали и не закладывали на них время.