On testing
Пытаюсь переосмыслить подходы к написанию тестов, в основном этот пост вдохновлен следующим гайдом
learn-go-with-tests/testing-fundamentals/working-without-mocks
Идея в чем, обычный подход в написании теста для компонента состоит из следующего: - в компонент внедряются зависимости, но не в виде конкретных типов, а через интерфейсы - на основе интерфейса генерятся моки - в тестах мы вызываем методы тестируемого компонента, засетапив сначала ожидаемые вызовы мока
Этот подход приводит к тому, что в тестах фиксируется внутренняя работа компонента, хотя все, что мы хотим проверить - это, что компонент делает то что нужно, без разницы как он это делает внутри
Простой пример, иллюстрирующий завязанность теста на внутренней реализации: пусть компонент в тестируемом методе делает один и тот же запрос в бд два раза. Соответственно в моке этот вызов должен ожидаться два раза и два раза возвращать одно и то же. Очевидно мы можем соптимизировать компонент и делать только один запрос. Работать компонент будет так же (только быстрее), но тест зафейлится, т.к. мок ожидал ровно ДВА запроса.
Что можно с этим сделать. Предложение состоит в том, чтобы не использовать моки, т.к. они "не являются зависимостями, который ожидает компонент", в том смысле, что если мы зависим от отправлялки сообщений, то SenderTelegram
это отправлялка сообщений, SenderEmail
это отправлялка сообщений, даже SenderLogs
который вместо отправки принтит в консоль это отправлялка сообщений. Мок же отправлялки сообщений должен быть заново настроен в каждом отдельном тесте чтобы имитировать отправлялку сообщений.
Вместо этого предлагается использовать фейки - реализацию интерфейса, которая втупую реализует логику интерфейса и работает в памяти и на фиксированных при создании фейка данных. Допустим есть интерфейс:
Тогда фейк для него будет выглядеть примерно так:
type cacheFake struct {
data map[string]string
}
func NewCacheFake(initData map[string]string) cacheFake {
if initData == nil {
initData = map[string]string{}
}
return cacheFake{data: initData}
}
func (f cacheFake) Get(key string) (string, bool) {
value, ok := f.data[key]
return value, ok
}
func (f cacheFake) Put(key, value string) {
f.data[key] = value
}
Эта реализация подходит под определение фейка: - она тупая - она работает в памяти - она соблюдает контракты интерфейса
Остановлюсь подробнее на соблюдении контрактов интерфейса. Помимо реализации методов интерфейса под этим подразумевается то, что методы ведут себя "логично". В примере выше, например, стоит при любой реализации кеша ожидать, что если сначала мы вызовем cache.Put("ilyuha", "zhopich")
и затем сразу же cache.Get("ilyuha")
мы получим "zhopich", true
.
Благо большинство интерфейсов сводятся к CRUD, кажется, что подобные фейки можно реализовать и для более сложных интерфейсов, как то - бд - кеши - сторонние сервисы - взаимодействие с ОС
Тогда в тестах нужно будет: - настроить начальное состояние фейка - вызвать тестируемый метод компонента с данными из тесткейса - проверить что ответ компонента соответствует ожидаемому - если надо, проверить состояние фейка, что нужные данные туда попали или изменились или исчезли, в общем вы поняли
Так мы проверяем ровно то что хотим проверять в тесте: что компонент отвечает и как влияет на зависимые подсистемы.
Как вариант, подобные фейки может предоставлять сама тестируемая система как пакет, наряду с сгенерированными протобафами, но это имхо уже звучит не очень, все таки на контракт полагаются внешние системы, пусть они и фиксируют свои ожидания в своих тестах. В нашей же системы мы можем поломать контракт и обновить фейк под новый контракт, но внешние потребители все равно должны понять, что контракт изменился.
Вторая идея, которую я хотел описать в этом посте про то, как упростить создание/инициализацию фейков. Все таки связные сервисы должны иметь связные данные и при сложных моделях это занимает время, чтобы все правильно заполнить. Предлагается делать следующее: реальный сервис мы оборачиваем в обертку вида
type cacheRecorder struct {
impl Cache
calls []struct{Method, In, Out string}
}
func NewCacheRecorder(impl Cache) cacheRecorder {
// можно инициализировать фейком, если его хватит
return cacheRecorder{impl: impl}
}
func (r cacheRecorder) Get(key string) (string, bool) {
res1, res2 := r.impl.Get(key)
r.calls = append(r.calls, {Method: "Get", In: key, Out: `${res1}, ${res2}`})
return res1, res2
}
func (r cacheRecorder) Put(key, value string) {
r.impl.Put(key, value)
r.calls = append(r.calls, {Method: "Put", In: `${key}, ${value}`})
}
То есть это обертка над реальной подсистемой, которая записывает вызовы. Запись вызовов может быть в разном виде: - тупо писать в логи, например для дебага через принтфы который я люблю - записывать в файл для ручного просмотра - записывать в файл для инициализации фейков с помощью полученных данных - запись напрямую в фейк и дамп этого фейка куда нибудь в файл, чтобы автоматизировать предыдущий пункт
Последнее звучит удобнее всего для использования, но, кажется что, нетривиально в реализации. Например, рекордер залогировал вызов Get("ilyuha")
, который вернул "zhopich", true
, а значит эти ключ и значения должны быть в фейке еще до вызова.
Таким образом, если фейк решал задачу имитации подсистемы: состояние фейка + данные на входе -> данные на выходе, соответствующие контракту
, то рекордер для генерации фейка (только для этого, если нам это не нужно, рекордер может работать и без этого) должен решить задачу: есть (данные на входе -> данные на выходе, соответствующие контракту) -> состояние фейка, которое при таких данных на входе даст по контракту такие данные на выходе
, проще говоря, нужно обратить контракт в обратную сторону.
Наконец последнее соображение, самое экспериментальное среди описанных, состоит в том, чтобы не использовать интерфейсы для инжекта зависимостей. Понятно, что сразу встает вопрос "ЧТО?! КАК?! А к чему тогда все эти рассуждения про моки, фейки, рекордеры? Это же все теряет смысл, мы не сможем это использовать без интерфейсов." Так вот, можем, используя механизм компиляции. Идея в том, что при компиляции релиза мы используем одну реализацию типа, а при билде тестов тестовую реализацию подсистемы (например фейк). Набросок реализации можно посмотреть здесь, более подробно описывать не буду, т.к. я еще предыдущие две идеи не испробовал.
Что нам может дать избавления от инжекта зависимостей через интерфейс: - отсутствие виртуальных вызовов, что само по себе ускорит код на пару наносекунд - гораздо большие возможности инлайнинга, который когда-нибудь таки доделают (COPIUM), и дальнейшая оптимизация - типы фиксированы и я могу наконец-то спокойно переходить на определение метода вместо того, чтобы переходить на метод интерфейса, переходить к всем реализациям этого метода этого интерфейса и среди мока и реальной реализации выбирать реальную реализацию