Как стать автором
Обновить

Unit-тестирование в Go с помощью интерфейсов

Время на прочтение4 мин
Количество просмотров13K
Вместо вступления

Эта статья посвящается тем, кто, как и я, пришел в Go из мира Django. Так вот, Django нас избаловал. Стоит только запустить тесты, как он сам, под капотом, создаст тестовую БД, прогонит миграции, а после прогона сам за собой приберёт. Удобно? Безусловно. Вот только времени на прогон миграций уходит — вагон, но это кажется разумной платой за комфорт, плюс всегда есть --reuse-db. Тем сильнее бывает культурный шок, когда бывалые джангисты приходят в другие языки, например в Go. То есть как-это никаких автомиграций до и после? Руками? А базу? Тоже руками? А после тестов? Что, и тирдаун руками? Ну и далее программист, перемежая код ахами и вздохами, начинает писать на Go джангу в отдельно взятом проекте. Конечно, выглядит всё это очень печально. Однако, в Go вполне возможно писать быстрые и надёжные юнит-тесты без использования сторонних сервисов типа тестовой БД или кэша.


Об этом и будет мой рассказ.


Что тестируем?

Давайте представим, что нам надо написать функцию, которая проверяет наличие сотрудника в БД по номеру телефона.


func CheckEmployee(db *sqlx.DB, phone string) (error, bool) {
    err := db.Get(`SELECT * FROM employees WHERE phone = ?`, phone)
    if err != nil {
        return err, false
    }
    return nil, true
}

Окей, написали. А как это протестировать? Можно, конечно, перед запуском тестов создать тестовую БД, создать в ней таблицы, а после прогона эту БД аккуратно грохнуть.


Но есть и другой путь.


Интерфейсы

Как вы уже обратили внимание, наша функция ничего не знают о происходящем снаружи, она просто принимает на вход некоего клиента БД и взаимодействуют с ним, вызывая метод Get. А это значит, что по-идее, для тестирования можно вместо реального клиента подсунуть на вход некую заглушку, которая вернёт то, что мы хотим, для того, чтобы проверить, правильно ли отрабатывает наша логика.


И сделать это можно с помощью интерфейсов. Что такое интерфейс в Go? Очень грубо упрощая можно сказать, что интерфейс — это мета-тип, который реализуют все структуры, которые, в свою очередь, реализуют все методы, описанные в интерфейсе. Немного запутанно, не так ли?


Давайте на картошках.


Если у нас есть следующий интерфейс:


type ExampleInterface interface {
    Method() error
}

и, например, такая структура с методом:


type ExampleStruct struct {}
func (es ExampleStruct) Method() error {
    return nil
}

то мы говорим, что структура ExampleStruct реализует интерфейс ExampleInterface и, соответственно, если какая-либо функция будет ждать на вход аргумент типа ExampleInterface, то мы смело сможем передать ей структуру ExampleStruct.


Что это даёт нам в контексте тестирования?


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


Talk is cheap, let's code!

Посмотрим сигнатуру этого метода:


Get(dest interface{}, query string, args ...interface{}) error

Напишем базовый интерфейс, имеющий функцию Get с такой же сигнатурой:


type BaseDBClient interface {
    Get(interface{}, string, ...interface{}) error
}

И перепишем немного нашу функцию:


func CheckEmployee(db BaseDBClient, phone string) (err error, exists bool) {
    var employee interface{}
    err = db.Get(&employee, `SELECT name FROM employees WHERE phone = ?`, phone)
    if err != nil {
        return err, false
    }
    return nil, true
}

Как видите, в теле функции ничего не изменилось, кроме того, наш код вполне работоспособен, поскольку мы скопировали сигнатуру из sqlx.Get, а значит теперь sqlx, хочет он этого или нет, реализует наш интерфейс BaseDBClient.


Тесты

Обычно я стараюсь сначала писать тесты, и только потом код.
Но здесь, для наглядности, мы поступим по старинке.


Давайте создадим структуру, которая тоже будет реализовывать наш интерфейс BaseDBClient:


type TestDBClient struct {}

func (tc *TestDBClient) Get(interface{}, string, ...interface{}) error {
    return nil
}

Обратите внимание, нет никакого значения, что это за структура, пусть даже пустая, важно, что у неё есть метод с сигнатурой, идентичной той, что обозначена в интерфейсе.


Всё, что осталось — в тесте передать функции CheckEmployee нашего подменыша:


func TestCheckEmployee() {
    test_client := TestDBClient{}
    err, exists := CheckEmployee(&test_client, "nevermind")
    assert.NoError(t, err)
    assert.Equal(t, exists, true)
}

Добавим стероидов

Разумеется, это лишь первое приближение. Например, для проверки всех вариантов ответа БД, схему можно несколько усложнить:


type BaseDBClient interface {
    Get(interface{}, string, ...interface{}) error
}

type TestDBClient struct {
    success bool
}

func (tс *TestDBClient) Get(interface{}, string, ...interface{}) error {
    if tс.success {
        return nil
    }
    return fmt.Errorf("This is a test error")
}

func TestCheckEmployee(t *testing.T) {
    type args struct {
        db BaseDBClient
    }
    tests := []struct {
        name       string
        args       args
        wantErr    error
        wantExists bool
    }{
        {
            name: "Employee exists",
            args: args{
                db: &TestDBClient{success: true},
            },
            wantErr:    nil,
            wantExists: true,
        }, {
            name: "Employee don't exists",
            args: args{
                db: &TestDBClient{success: false},
            },
            wantErr:    fmt.Errorf("This is a test error"),
            wantExists: false,
        },
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            gotErr, gotExists := CheckEmployee(tt.args.db, "some phone")
            if !reflect.DeepEqual(gotErr, tt.wantErr) {
                t.Errorf("CheckEmployee() gotErr = %v, want %v", gotErr, tt.wantErr)
            }
            if gotExists != tt.wantExists {
                t.Errorf("CheckEmployee() gotExists = %v, want %v", gotExists, tt.wantExists)
            }
        })
    }
}

Готово! Не используя базы данных, но предполагая ожидаемые ответы, мы можем тестировать нашу функцию в самых разнообразных условиях и для этого не понадобится ни специального окружения, ни сторонних сервисов, ничего, кроме компилятора go.


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


Итого

Конечно, у этого подхода есть свои минусы. Например, если ваша логика завязана на некую внутреннюю логику БД, то такое тестирование не сможет выявить ошибки, причиной которых стала база. Но я полагаю, что тестирование с участием БД и сторонних сервисов — это уже не про юнит-тесты, это скорее интеграционные или даже e2e-тесты, а они несколько выходят за рамки этой статьи.


Спасибо за то, что прочитали и пишите тесты!

Теги:
Хабы:
Всего голосов 9: ↑6 и ↓3+3
Комментарии3

Публикации