Программирование GoТестированиеGo-разработчик серверных сервисов

Сервис вызывает mock зависимость из нескольких горутин. Как сделать проверку вызовов корректной?

Сервис вызывает mock-зависимость из нескольких горутин. Как сделать проверку вызовов корректной?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Состояние mock-объекта, в котором записываются вызовы, нужно защищать синхронизацией: например, мьютексом или потокобезопасной структурой. Проверять вызовы следует только после того, как завершились все горутины, иначе тест может читать журнал одновременно с записью или увидеть неполный результат.

Исторический контекст

Mock-объекты появились как способ изолировать unit-тест от внешних зависимостей и проверять взаимодействие с ними. Изначально многие mock-реализации использовались в последовательных тестах, где обычного среза вызовов было достаточно.

С появлением конкурентных сервисов одна и та же зависимость стала вызываться из нескольких горутин. Прежний подход оказался небезопасным: сам mock мог стать источником гонки и сделать результат теста недостоверным.

Постановка проблемы

Если mock добавляет записи в общий срез или изменяет map без синхронизации, параллельные вызовы могут привести к гонке данных, повреждению состояния или потере части записей. Даже если тест иногда проходит, race detector может обнаружить проблему, а поведение при обычном запуске останется недетерминированным.

Отдельная ошибка — проверять журнал сразу после запуска горутин. Защищённый мьютексом mock всё равно не гарантирует, что все вызовы уже произошли: синхронизация защищает доступ к данным, но не ожидает завершения работы.

Подробное решение

В mock нужно синхронизировать каждую операцию над общим состоянием: добавление вызова, чтение журнала и изменение счётчиков. Обычно запись выполняется под sync.Mutex, а метод чтения делает копию данных под тем же мьютексом, чтобы вызывающий код не работал с внутренним срезом после разблокировки.

type MockStore struct { mu sync.Mutex calls []string } func (m *MockStore) Load(key string) string { m.mu.Lock() defer m.mu.Unlock() m.calls = append(m.calls, key) return "value" } func (m *MockStore) Calls() []string { m.mu.Lock() defer m.mu.Unlock() return append([]string(nil), m.calls...) }

В полном примере требуется импортировать sync. Копирование в Calls важно: возврат внутреннего среза позволил бы внешнему коду читать или изменять его без защиты.

Ожидание завершения горутин должно быть отдельной частью теста, обычно через sync.WaitGroup, канал завершения или другой явный протокол. Нельзя заменять это time.Sleep: задержка не устанавливает причинно-следственную связь и делает тест зависимым от нагрузки.

Мьютекс прост и надёжен, но может усложнить mock и скрыть чрезмерную конкурентность тестируемого кода. Каналы подходят, если журналирование естественно строится как передача событий, однако для обычного накопления вызовов они часто избыточны. Готовый mock-фреймворк допустим только при условии, что его документация явно описывает безопасность параллельных вызовов.

Ситуация из практики

Сервис параллельно загружал данные для нескольких клиентов и использовал mock хранилища, который добавлял ключи в обычный срез. В последовательном запуске тест проходил, но при -race появлялись отчёты о конфликтующих доступах, а иногда число записей отличалось от ожидаемого.

Рассматривались три варианта: убрать параллельность из сервиса, поставить time.Sleep перед проверкой или сделать mock потокобезопасным и явно дождаться всех рабочих горутин. Первый вариант изменял проверяемое поведение, второй не устранял гонку и оставался нестабильным.

Выбрали третий вариант: журнал mock защищали мьютексом, метод чтения возвращал копию, а тест завершал WaitGroup перед проверкой. В результате проверка стала детерминированной, а go test -race перестал сообщать о гонке в тестовом коде.

Что кандидаты часто упускают

  1. Достаточно ли защитить запись в журнал mock мьютексом?

Нет. Чтение журнала и любые операции над связанными полями также должны использовать тот же механизм синхронизации. Иначе запись будет защищена, но параллельная проверка или вычисление длины среза всё равно создаст гонку.

  1. Гарантирует ли потокобезопасный mock, что тест проверяет все вызовы?

Нет. Потокобезопасность гарантирует согласованный доступ к состоянию, но не завершение горутин. Тест должен отдельно дождаться всех операций; только после этого можно сравнивать количество, аргументы и порядок вызовов.

  1. Можно ли безопасно проверять порядок вызовов при конкурентном выполнении?

Только если порядок является частью контракта и сам тест задаёт соответствующее упорядочивание. При независимых параллельных операциях глобальный порядок может быть случайным, поэтому обычно проверяют множество вызовов или свойства каждого вызова, а не конкретную последовательность.