Есть неприятный тип зелёного теста: он проверяет нашу фантазию о базе.
В платежах это видно особенно заметно. Ситуация: поллер статуса выбирает Pending-платежи, тест на List<T>.AsQueryable() проходит, а на проде всплывает транслятор запросов, уникальный индекс, сортировка или гонка двух воркеров. Код тот же, уверенность та же, результат другой.
В прошлой статье про интеграцию без песочницы я рассказывал, как мы писали стаб банка по pdf-файлу со спецификацией. Стаб был полезен, но закрепил нашу картину протокола. С in-memory базой в тестах происходит похожая вещь: она закрепляет нашу картину поведения БД.
Я не предлагаю тащить контейнер с Postgres или Mongo в каждый unit-тест. Вопрос начинается там, где тест якобы проверяет запрос к базе, уникальный индекс, сортировку, транзакцию или работу двух воркеров.
Ниже будет пример именно из этой зоны: фоновый поллер платежей, который выбирает незавершённые оплаты и спрашивает банк об их актуальном статусе.
Тест, которому хочется верить
После истории с потерянными нотификациями в платёжном агрегаторе появляется фоновый воркер. Он берёт платежи в статусе Pending, по которым уже пора спросить банк, и отправляет запрос статуса напрямую провайдеру.
public interface IPaymentReadStore{ IQueryable<PaymentDocument> Payments { get; }}public enum PaymentStatus { Pending, Paid, Failed }public sealed record PaymentDocument( Guid Id, string? ProviderPaymentId, PaymentStatus Status, DateTimeOffset CreatedAt, DateTimeOffset? NextStatusCheckAt){ public bool ShouldCheckStatus(DateTimeOffset now) => Status == PaymentStatus.Pending && ProviderPaymentId is not null && NextStatusCheckAt.HasValue && NextStatusCheckAt.Value <= now && CreatedAt > now.AddDays(-2);}public sealed class PendingPaymentQuery(IPaymentReadStore store){ public IReadOnlyList<PaymentDocument> TakeDueForStatusCheck(DateTimeOffset now) => store.Payments .Where(p => p.ShouldCheckStatus(now)) .OrderBy(p => p.NextStatusCheckAt) .Take(100) .ToList();}
Такой код даже хочется похвалить. Условие не размазано по запросу, имя метода говорит доменным языком, тест получается коротким.
Для примера я оставлю IQueryable прямо на границе store. В реальном коде эта граница может жить глубже – внутри репозитория или query object. Расхождение от этого не меняется: часть выражения всё равно должна быть переведена в запрос к базе.
public sealed class InMemoryPaymentReadStore(IEnumerable<PaymentDocument> payments) : IPaymentReadStore{ public IQueryable<PaymentDocument> Payments => payments.AsQueryable();}[Fact]public void TakeDueForStatusCheck_WhenPaymentIsPendingAndDue_ReturnsPayment(){ var now = DateTimeOffset.Parse("2026-07-07T12:00:00+03:00"); var duePaymentId = Guid.NewGuid(); var store = new InMemoryPaymentReadStore(new[] { new(duePaymentId, "p-1", PaymentStatus.Pending, now.AddHours(-1), now.AddMinutes(-1)), new(Guid.NewGuid(), "p-2", PaymentStatus.Pending, now.AddMinutes(-3), now.AddMinutes(10)), new(Guid.NewGuid(), "p-3", PaymentStatus.Paid, now.AddHours(-1), now.AddMinutes(-1)) }); var payments = new PendingPaymentQuery(store).TakeDueForStatusCheck(now); Assert.Equal(duePaymentId, Assert.Single(payments).Id);}
Тест зелёный. И, что хуже, он не выглядит глупым. Он правда проверяет понятное правило: просроченный Pending-платёж с ProviderPaymentId должен попасть в выборку, будущий и уже оплаченный – нет.
Вот на этом месте я обычно и начинаю нервничать.
Проблема спрятана в одной строке:
.Where(p => p.ShouldCheckStatus(now)) // БД этот метод не знает
Для List<T>.AsQueryable() это обычный вызов C#-метода. Объект уже в памяти, и рантайм вызывает ShouldCheckStatus без участия базы.
У настоящей базы такого метода нет. Там есть документ или строка, индекс, язык запросов и провайдер, который должен перевести LINQ-выражение в запрос. Метод ShouldCheckStatus он не обязан понимать. В лучшем случае запрос упадёт на трансляции в тесте против реальной БД. В худшем – команда уже привыкла доверять in-memory тесту и узнает о расхождении позже, когда код придётся срочно переписывать под настоящее хранилище.
И это только первое расхождение. Даже если переписать условие прямо в Where, остаются null, сортировка строк, уникальность и конкуренция воркеров. In-memory тест удобен именно потому, что сглаживает эти углы. Продовая база их не сглаживает.
Когда метод становится частью запроса
Самая маленькая правка здесь выглядит скучно: не прятать условие выборки в обычный метод объекта.
public sealed class PendingPaymentQuery(IPaymentReadStore store){ public IReadOnlyList<PaymentDocument> TakeDueForStatusCheck(DateTimeOffset now) { var minCreatedAt = now.AddDays(-2); // считаем до запроса return store.Payments .Where(p => p.Status == PaymentStatus.Pending) // было внутри ShouldCheckStatus .Where(p => p.ProviderPaymentId != null) // теперь явно в запросе .Where(p => p.NextStatusCheckAt != null && p.NextStatusCheckAt <= now) // теперь явно в запросе .Where(p => p.CreatedAt > minCreatedAt) // без вызова AddDays внутри LINQ .OrderBy(p => p.NextStatusCheckAt) .Take(100) .ToList(); }}
Такой код не стал красивее. Зато теперь видно, что мы пишем запрос, а не вызываем доменное правило. Это разные виды кода, и тестировать их нужно по-разному.
Метод ShouldCheckStatus можно оставить, если он нужен доменной модели или отдельному расчёту. Его тест тогда проверит правило объекта:
[Fact]public void ShouldCheckStatus_WhenPaymentIsPendingAndDue_ReturnsTrue(){ var now = DateTimeOffset.Parse("2026-07-07T12:00:00+03:00"); var payment = new PaymentDocument( Guid.NewGuid(), "p-1", PaymentStatus.Pending, now.AddHours(-1), now.AddMinutes(-1)); Assert.True(payment.ShouldCheckStatus(now));}
А запрос TakeDueForStatusCheck должен хотя бы один раз пройти через тот провайдер, который будет выполнять его на проде. Для MongoDB это означает проверку через драйвер и реальную коллекцию, для EF – через реальный провайдер базы. В этом месте мы проверяем уже не C#-объект.
В FAQ MongoDB C# Driver отдельно разобран такой сценарий: если LINQ-выражение или builder-выражение нельзя перевести в Query API, можно получить Unsupported filter или Expression not supported. Причины тоже ожидаемые: в .NET может быть возможность, которой нет в MongoDB-представлении, или драйвер просто не поддерживает конкретную трансформацию.
EF Core в разделе про стратегию тестирования говорит похожую вещь с другой стороны: in-memory provider поддерживает меньше видов запросов, не является реляционной БД, не поддерживает транзакции и в целом не рекомендуется как тестовая подмена для запросов. Такой спор лучше не сводить к вкусам: ограничение записано в документации инструмента.
null, которого не было в тесте
В платежах ProviderPaymentId часто появляется не сразу. Сначала мы создаём свою запись, потом идём в банк, потом сохраняем внешний идентификатор. Между этими шагами запись уже есть, но ProviderPaymentId ещё нет.
Тест выше эту реальность почти не показывает. В нём три аккуратных объекта: один должен попасть в выборку, второй ещё рано проверять, третий уже оплачен. На живой базе рядом будут другие варианты:
-
платёж только что создан,
ProviderPaymentIdещёnull; -
старый документ был создан до появления поля
NextStatusCheckAt; -
поле есть, но расписание следующей проверки ещё не рассчитано;
-
миграция данных прошла не везде, и часть документов живёт в старой форме.
В памяти всё это обычно схлопывается в C#-объект с nullable-свойством. Если поле не передали в конструктор, тест всё равно видит понятное null. Настоящая база может различать “поле есть и равно null” и “поля нет”, а может специально склеивать эти случаи в одном запросе.
MongoDB, например, в запросе { field: null } находит и документы с field: null, и документы без field вообще. Иногда это удобно. Иногда это тот самый баг, который in-memory тест не покажет, потому что у объекта в памяти нет понятия “поля не было в документе”.
Для нашего поллера это практический вопрос. Если мы хотим брать только платежи, у которых уже есть внешний идентификатор и расписание проверки, запрос должен явно это говорить: “хранилище отфильтровало нужное состояние данных”. Одного ощущения, что объект в тесте выглядит заполненным, тут мало.
Сортировка строк не обязана совпасть
В самом поллере сортировка идёт по NextStatusCheckAt, и с collation там ничего интересного. Но как только рядом появляется витрина для админки – поиск по плательщику, назначению платежа или произвольным данным из формы – in-memory тест снова начинает проверять не ту систему.
OrderBy(row => row.PayerName) поверх List<T> сортирует строки средствами .NET. База сортирует по своим правилам: collation, locale, настройки регистра, акцентов и иногда числового порядка. MongoDB описывает collation как правила сравнения строк, включая регистр и диакритику; индекс и запрос при этом должны использовать совместимые настройки collation, иначе индекс может не помочь.
На тестовых данных это часто не видно. Три плательщика называются Anna, Boris, Maria, сортировка совпала, тест зелёный. Потом в проде появляются кириллица, разный регистр, Ё/Е, пробелы, цифры в назначении платежа или локаль, и порядок меняется.
Я бы не стал покрывать каждую сортировку интеграционным тестом. Но если порядок влияет на бизнес-сценарий, пагинацию, экспорт, ручной разбор платежей в саппорте или SLA обработки, in-memory тест тут слабый свидетель. Он подтверждает только то, как строки упорядочились внутри CLR.
Уникальность проверяет индекс, а не список
В платёжной системе внешний идентификатор провайдера обычно работает и как строка для поиска, и как защита от дублей. Если банк дважды прислал одну и ту же нотификацию, мы должны дважды найти один платёж, а не создать рядом второй такой же.
В реальной базе это обычно держится уникальным индексом. Например, пара (Provider, ProviderPaymentId) должна встречаться только один раз, когда внешний идентификатор уже известен.
Если ProviderPaymentId появляется позже, индекс обычно делают частичным: уникальность включается только для документов, где внешний id уже есть. Защита строится не на аккуратном Any() перед вставкой. Её держит база: она физически не даст сохранить дубль.
In-memory store по умолчанию ничего такого не знает. Список примет два объекта с одинаковым ProviderPaymentId, словарь перетрёт один другим, самописная подмена поведёт себя так, как мы её написали. И тут возникает неприятная развилка: либо подмена не проверяет уникальность вообще, либо мы начинаем реализовывать внутри неё кусок поведения настоящей БД.
Второй вариант выглядит старательно, но пахнет странно. Мы пишем тестовую базу, чтобы не поднимать настоящую, потом добавляем в неё уникальные индексы, правила null, сортировку, транзакции и блокировки. В какой-то момент дешевле и честнее поднять настоящую.
Документация здесь тоже без сюрпризов. EF Core пишет, что попытка вставить больше одной сущности с теми же значениями уникального индекса приводит к исключению. MongoDB описывает unique index как механизм, который не даёт нескольким документам иметь одинаковые значения индексируемых полей, и отдельно показывает нюансы null/missing для unique index. Для платежей с отложенным ProviderPaymentId это как раз не мелкая деталь.
Если это поведение важно для домена, один тест должен пройти через реальный индекс. HashSet в тестовой подмене и мок репозитория здесь проверяют соседнюю идею, а не то место, которое будет останавливать дубль на проде.
Выбрать платежи и забрать платежи в работу – разные операции
Теперь самый неприятный кусок. Поллер редко работает в одном экземпляре. Сегодня один воркер, завтра два инстанса сервиса, послезавтра горизонтальное масштабирование на фоне распродажи или зарплатного дня.
Наш метод называется TakeDueForStatusCheck, но на самом деле он ничего не “берёт”. Он читает подходящие платежи.
|
Операция |
Что происходит |
|---|---|
|
прочитать платежи на проверку |
база вернула документы, состояние не изменилось |
|
забрать платежи на проверку в работу |
база пометила документы как занятые этим воркером |
In-memory тест выше проверяет только первую строку. Один поток положил три объекта в массив, один поток сделал запрос, один поток получил результат. В такой вселенной два воркера никогда не схватят один платёж одновременно, потому что второго воркера там нет.
В проде картина другая:
-
Воркер A читает первые 100 платежей на проверку.
-
Воркер B в ту же секунду читает те же первые 100 платежей на проверку.
-
Оба спрашивают банк.
-
Оба пытаются применить результат.
Если запрос статуса у банка идемпотентный, может показаться, что ничего страшного. До первой побочки: лимиты провайдера, лишняя нагрузка, двойные записи в историю, гонка при применении статуса, разные ответы в разные моменты времени. Платежи любят наказывать за “ну это же только чтение”.
Я бы менял контракт. Названия вроде ReadDue и TakeDue всё ещё маскируют чтение. Здесь нужно имя ближе к реальному действию:
public interface IPendingPaymentStore{ Task<IReadOnlyList<PaymentDocument>> ClaimDueForStatusCheck( // не read, а захват DateTimeOffset now, string workerId, // кто забрал платежи int limit, CancellationToken ct);}
Смысл метода: найти платежи, у которых настал срок проверки, и в той же операции поставить временную блокировку (lease) – например, StatusCheckLockedBy и StatusCheckLeaseUntil. Другой воркер должен увидеть, что платёж уже занят, и пройти мимо.
Реализация будет разной для разных хранилищ. В MongoDB это может быть цикл с findOneAndUpdate: фильтр выбирает свободный платёж, срок проверки которого уже наступил, update проставляет StatusCheckLockedBy/StatusCheckLeaseUntil, операция возвращает уже забранный документ. MongoDB в документации по atomicity отдельно подчёркивает, что запись атомарна на уровне одного документа и при параллельных update проверяет, что условие запроса всё ещё совпадает.
В SQL это будет транзакция, блокировки или вариант UPDATE ... RETURNING / SKIP LOCKED, в зависимости от базы. В SQL Server один из вариантов для очередей – table hints вроде READPAST и UPDLOCK: Microsoft описывает UPDLOCK как update locks, которые держатся до конца транзакции, а READPAST – как пропуск строк, заблокированных другими транзакциями. Я бы не переносил этот кусок вслепую в любую базу. Здесь важен сам факт: корректный захват опирается на конкретную семантику хранилища.
Детали важны, но для статьи важнее граница: это уже не unit-тест. Если корректность зависит от атомарного захвата, тест должен гонять два параллельных воркера против настоящего механизма захвата. На List<T> такой баг просто не воспроизвести.
Где in-memory тесты я оставляю
После всего этого легко сделать неправильный вывод и начать тащить настоящую БД в каждый тест. Я так не делаю.
В памяти нормально тестировать код, который не просит хранилище вести себя как хранилище:
-
payment.ApplyStatus(status)не должен откатыватьPaidобратно вPending; -
повторная нотификация с тем же статусом не должна создавать второе доменное событие;
-
поздний
FailedпослеPaidдолжен уйти в инцидент, а не перетереть оплату; -
ScheduleNextCheck(now)должен дать следующий интервал по нашей политике backoff; -
ShouldCheckStatus(now)может проверять правило объекта, если мы не притворяемся, что это тест запроса.
Такие тесты быстрые, дешёвые и полезные. Они проверяют нашу логику, а не поведение базы. На них не надо навешивать Docker, миграции и старт контейнера.
Проблема начинается, когда in-memory тест получает чужую ответственность. Он вроде бы проверяет запрос, но не запускает транслятор. Проверяет уникальность, но без индекса. Проверяет конкуренцию, но без второго воркера. Проверяет сортировку, но без collation.
Как я теперь делю такие тесты
У меня граница сейчас такая:
|
Что проверяем |
Где тестировать |
|---|---|
|
правило объекта |
in-memory unit-тест |
|
расчёт следующего состояния |
in-memory unit-тест |
|
запрос к БД |
настоящая БД или контейнер с тем же движком |
|
сортировку и collation |
настоящая БД |
|
уникальность и индексы |
настоящая БД |
|
транзакции, атомарный захват и конкуренцию воркеров |
настоящая БД |
Я не пытаюсь покрыть интеграционными тестами каждый запрос в системе. Это быстро превращается в медленную и хрупкую стену. Но для запросов, которые держат деньги, статусы, идемпотентность, дедупликацию или очереди обработки, один тест на реальной БД обычно дешевле, чем один вечер ручного разбора зависших платежей.
Testcontainers тут удобный способ не спорить с локальной инфраструктурой. Его документация по .NET описывает библиотеку как способ поднимать одноразовые контейнеры для тестов; в getting started прямо говорится про тесты, которые ходят в сервисы того же типа, что и прод, без моков и сервисов в памяти. Можно делать иначе: отдельная тестовая БД, docker compose, встроенный стенд. Инструмент вторичен.
Подготовку контейнера я обычно прячу в fixture. В статье важна не обвязка, а форма проверки: два воркера одновременно пытаются забрать один платёж, и тест смотрит, что документ достался только одному.
[Fact]public async Task ClaimDueForStatusCheck_WhenTwoWorkersRunConcurrently_ClaimsPaymentOnlyOnce(){ var now = DateTimeOffset.Parse("2026-07-07T12:00:00+03:00"); var ct = CancellationToken.None; await using var db = await PaymentTestDatabase.StartMongoAsync(); // не List<T> var store = new MongoPendingPaymentStore(db); var payment = new PaymentDocument( Guid.NewGuid(), "p-1", PaymentStatus.Pending, now.AddHours(-1), now.AddMinutes(-1)); await store.InsertAsync(payment, ct); var results = await Task.WhenAll( // два воркера одновременно store.ClaimDueForStatusCheck(now, "worker-a", 100, ct), store.ClaimDueForStatusCheck(now, "worker-b", 100, ct)); var claimedIds = results.SelectMany(x => x).Select(x => x.Id).ToArray(); Assert.Equal(payment.Id, Assert.Single(claimedIds));}
PaymentTestDatabase.StartMongoAsync() здесь не API Testcontainers, а условная обёртка проекта над контейнером и подготовкой коллекции. В другом проекте это может быть fixture с Postgres, docker compose или тестовая БД. Для этой статьи важно другое: тест проходит через настоящий механизм захвата, а не через List<T>.
Главная проверка проще: если тест проверяет правило моего объекта, in-memory подходит. Если тест проверяет поведение базы, пусть в тесте будет база.
Иначе зелёный тест снова начинает означать только одно: наша удобная модель мира согласована сама с собой.
Что почитать
-
Vladimir Khorikov – “Unit Testing: Principles, Practices, and Patterns” (Manning, 2020). Хорошо раскладывает зависимости, которые вы контролируете, и зависимости вне вашего контроля. База, индекс и транзакция в этой статье – как раз та часть, которую опасно заменять собственной фантазией.
-
EF Core Docs – “Choosing a testing strategy”. Короткий официальный ориентир по тестовым подменам базы. Полезен как sanity check, когда хочется верить, что in-memory provider достаточно похож на настоящий.
-
Testcontainers for .NET – документация. Практический вариант для редких тестов, которым нужна настоящая база без ручной инфраструктуры. Хорошо ложится на проверки трансляции запроса, индексов и конкуренции воркеров.
ссылка на оригинал статьи https://habr.com/ru/articles/1056684/