On testing

Пытаюсь переосмыслить подходы к написанию тестов, в основном этот пост вдохновлен следующим гайдом

learn-go-with-tests/testing-fundamentals/working-without-mocks

Идея в чем, обычный подход в написании теста для компонента состоит из следующего: - в компонент внедряются зависимости, но не в виде конкретных типов, а через интерфейсы - на основе интерфейса генерятся моки - в тестах мы вызываем методы тестируемого компонента, засетапив сначала ожидаемые вызовы мока

Этот подход приводит к тому, что в тестах фиксируется внутренняя работа компонента, хотя все, что мы хотим проверить - это, что компонент делает то что нужно, без разницы как он это делает внутри

Простой пример, иллюстрирующий завязанность теста на внутренней реализации: пусть компонент в тестируемом методе делает один и тот же запрос в бд два раза. Соответственно в моке этот вызов должен ожидаться два раза и два раза возвращать одно и то же. Очевидно мы можем соптимизировать компонент и делать только один запрос. Работать компонент будет так же (только быстрее), но тест зафейлится, т.к. мок ожидал ровно ДВА запроса.

Что можно с этим сделать. Предложение состоит в том, чтобы не использовать моки, т.к. они "не являются зависимостями, который ожидает компонент", в том смысле, что если мы зависим от отправлялки сообщений, то SenderTelegram это отправлялка сообщений, SenderEmail это отправлялка сообщений, даже SenderLogs который вместо отправки принтит в консоль это отправлялка сообщений. Мок же отправлялки сообщений должен быть заново настроен в каждом отдельном тесте чтобы имитировать отправлялку сообщений.

Вместо этого предлагается использовать фейки - реализацию интерфейса, которая втупую реализует логику интерфейса и работает в памяти и на фиксированных при создании фейка данных. Допустим есть интерфейс:

type Cache interface {
    Get(key string) (value string, ok bool)
    Put(key, value, string)
}

Тогда фейк для него будет выглядеть примерно так:

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), и дальнейшая оптимизация - типы фиксированы и я могу наконец-то спокойно переходить на определение метода вместо того, чтобы переходить на метод интерфейса, переходить к всем реализациям этого метода этого интерфейса и среди мока и реальной реализации выбирать реальную реализацию