При написании бэкенда работа с базой данных зачастую составляет большую часть кода в проекте. Но несмотря на то, что в го стандартная библиотека для тестирования довольно удобная, она требует написания большого количества кода. Поэтому иногда вместо того, чтобы писать тесты разработчики могут ограничить тестирование при помощи какого-либо клиента (например, при помощи tableplus или другого sql-клиента), либо тестируют уже конечные точки API используя postman. С одной стороны, это, конечно может быть быстрее для первого тестирования, но с другой — такие методы не обеспечивают должного покрытия тестами приложения.

Решить проблему с написанием большого количества кода может помочь библиотека testify, которая позволяет писать тесты более выразительно и с меньшим количеством кода. В testify есть пакеты require и assert, в первом случае выполнение теста будет прервано, а во втором — продолжено. В статье будет использоваться пакет assert. Для облегчения понимания кода можно использовать табличное тестирование, которое поможет определить, какие случаи проверяются даже при беглом взгляде на код. 

Тесты будут проводиться для crud-операций над таблицей пользователей. Для тестирования будет использоваться postgres, которую запустим в докер-контейнере (исходный код тут). Для начала нужно создать базу данных в postgres и таблицу в базе данных со следующим кодом.

CREATE TABLE "users" (
    "name" varchar PRIMARY KEY,
    "email" varchar UNIQUE NOT NULL,
    "created_at" timestamptz NOT NULL DEFAULT (now())
);

Обратите внимание на то, что в большинстве случаев заполнение столбцов по типу «created_at» можно возложить на плечи СУБД, которая может автоматически проставлять дату создания записи. Для упрощения в таблице будет только три столбца. Тестировать будем следующий функционал:

  • добавление нового пользователя

  • удаление пользователя

  • получение пользователя по имени

  • обновление email у пользователя

Для начала создадим нового пользователя в базе данных. В файле по пути internal/models/user.go создаём структуру для пользователя:

package models

import "time"

type User struct {
    Email     string
    Name      string
    CreatedAt time.Time
}

При помощи данной структуры мы будем создавать/получать пользователей. Затем в файле internal/api/server.go пишем код для создания пользователя в БД.

package api

import (
    "database/sql"
    "testifyexample/internal/models"
)

type Server struct {
    db *sql.DB
}

func (s *Server) CreateUser(u *models.User) (*models.User, error) {

    createUserQuery := "insert into users(name,email) values($1, $2) returning *"

    stm, err := s.db.Prepare(createUserQuery)
    if err != nil {
        return nil, err
    }

    res := stm.QueryRow(u.Name, u.Email)
    if res.Err() != nil {
        return nil, res.Err()
    }
    var resUser models.User
    err = res.Scan(&resUser.Name, &resUser.Email, &resUser.CreatedAt)
    if err != nil {
        return nil, err
    }

    return &resUser, nil
}

В метод для работы с БД мы передаём структуру пользователя, из которой будем получать поля Name и Email. В запросе не забываем использовать sql placeholders ($1 и $2) для избежания sql-инъекций, также в конце запроса пишем «returning *», чтобы возвращать объект, который вставили, в нашем случае это упростит тестирование.

Теперь перейдём к тестированию этого кода. В данном материале мы не будем покрывать код тестами на 100%, а рассмотрим только наиболее типичные проблемы. В этом случае у нас могут возникнуть следующие варианты:

  • успешное создание пользователя

  • ошибка создания пользователя из-за того, что пользователя уже существует

  • ошибка создания пользователя из-за того, что email уже занят

Давайте начнём с первого, когда нам нужно создать нового пользователя. Чтобы каждый раз при запуске тестов создавался новый пользователь, мы создадим вспомогательный пакет util. Данный пакет будет использоваться для генерирования случайных имён и email для пользователя. Теперь создаём файл internal/util/random.go:

package util

import (
    "math/rand"
    "strings"
    "time"
)

const alpha = "abcdefghijklmnopqrstuvwxyz"

func init() {
    rand.Seed(time.Now().UnixNano())
}

func RandomString(n int) string {
    var sb strings.Builder
    k := len(alpha)

    for i := 0; i < n; i++ {
        c := alpha[rand.Intn(k)]
        sb.WriteByte(c)
    }

    return sb.String()
}


func RandomName() string {
    return RandomString(12)
}

func RandomMail() string {
    var sb strings.Builder
    k := len(alpha)

    for i := 0; i < 10; i++ {
        c := alpha[rand.Intn(k)]
        sb.WriteByte(c)
    }

    sb.WriteString("@todo.com")

    return sb.String()
}

Данный код поможет каждый раз создавать случайные name и email для тестов. Конечно, существует вероятность того, что сгенерированные данные могут повториться, но она достаточно низка.  

Далее нам нужно создать файл internal/api/main_test.go. Функция TestMain в этом файле будет выполняться при запуске любого теста из пакета:

package api

import (
    "database/sql"
    _ "github.com/lib/pq"
    "log"
    "os"
    "testing"
)

var testServer Server

func TestMain(m *testing.M) {
    var err error
    apitestDB, err := sql.Open("postgres", "postgresql://todo:todo@localhost:7899/todo_db?sslmode=disable")
    if err != nil {
        log.Fatal(err)
    }

    testServer.db = apitestDB

    os.Exit(m.Run())
}

Данная функция выполняет подключение к БД и инициализирует экземпляр структуры Server. В файле internal/api/user_test.go напишем код для создания пользователя. Функция newRandomUser создаёт новый объект User со случайными значениями полей Name и Email. А функция CreateRandomUser вызывает метод CreateUser и создаёт нового пользователя в БД:

func newRandomUser() *models.User {
    return &models.User{
        Email: util.RandomMail(),
        Name:  util.RandomName(),
    }
}

func CreateRandomUser(t *testing.T) *models.User {
    newUser := newRandomUser()
    u, err := testServer.CreateUser(newUser)
    assert.NoError(t, err)
    assert.Equal(t, newUser.Name, u.Name)
    assert.Equal(t, newUser.Email, u.Email)
    assert.NotZero(t, u.CreatedAt)
    return u
}

После создания пользователя при помощи testify мы проверяем значения, которые вернула функция создания нового пользователя. При успешном создании пользователя функция не должна возвращать ошибок. Проверяем это при помощи функции NoError и затем сравниваем поля переданного в функцию объекта и полученного из БД. Поля должны совпадать — проверяем это при помощи функции Equal. Затем при помощи функции NotZero убедимся, что время, вставленное в БД, не равно начальному значению времени. Наш первый тест готов. Если запустим его, то увидим, что всё работает нормально и пользователи в БД создаются.

Но поскольку у нас предполагается несколько тестов для функции CreateUser, то вполне логично использовать подход с табличными тестами. Для этого создадим слайс структур, в которых будет два поля name — название поля с тестом и testScript — функция, которая будет запускать сценарий тестирования. Также добавим в таблицу 2 других теста, но пока с пустыми функциями testScript. Для запуска тестов мы будем использовать функцию Run, в которую мы будем передавать название теста и сценарий теста. Если что-то пойдёт не так, то мы получим информацию о том, в каком именно тесте возникла проблема.

func TestCreateUser(t *testing.T) {
    testCases := []struct {
        name       string
        testScript func()
    }{
        {"create random user", func() {
            CreateRandomUser(t)
        }},
        {"unique name error", func() {

        }},
        {"unique email error", func() {

        }},
    }

    for _, tc := range testCases {
        t.Run(tc.name, func(t *testing.T) {
            tc.testScript()
        })
    }

}

Теперь перейдём к написанию тестов для «unique name error» и «unique email error». В целом, идея тестов довольно простая. Сначала мы создаём случайного пользователя, а затем меняем у данного пользователя email/name и пробуем повторно создать такого пользователя в базе. В результате мы должны получить ошибку «duplicate key violates unique constraint», которая возникает при создании дубликатов в таблице, при условии, что их быть не должно. Код для тестов представлен ниже:

const (
    UniqueViolationErr = pq.ErrorCode("23505")
)

func IsErrorCode(err error, errcode pq.ErrorCode) bool {
    if pgerr, ok := err.(*pq.Error); ok {
        return pgerr.Code == errcode
    }
    return false
}


func TestCreateUser(t *testing.T) {
    //...
        {"unique name error", func() {

            u := CreateRandomUser(t)

            u.Email = util.RandomMail()
            violationUser, err := testServer.CreateUser(u)
            assert.Equal(t, true, IsErrorCode(err, UniqueViolationErr))
            assert.Nil(t, violationUser, nil)

        }},
        {"unique email error", func() {

            u := CreateRandomUser(t)

            u.Name = util.RandomName()
            violationUser, err := testServer.CreateUser(u)
            assert.Equal(t, true, IsErrorCode(err, UniqueViolationErr))
            assert.Nil(t, violationUser)

        }},
    }

    //...

}

Первое, на что стоит обратить внимание — в го не предоставляется стандартной ошибки для обнаружения дубликатов. В целом, есть несколько вариантов для идентификации такой ошибки. Все ошибки в postgres имеют свой определённый номер, в нашем случае код ошибки — 23505. В случае возникновения ошибки драйвер для postgres (в нашем случае "github.com/lib/pq") вернёт ошибку с пользовательским типом pq.Error, в которой будет содержаться информация о коде ошибки. Для проверки мы создадим функцию IsErrorCode, которая будет преобразовывать стандартную ошибку к типу pq.Error и сравнивать значение кода ошибки внутри этой переменной и переданный код (у нас — с кодом 23505). Ещё нужно иметь в виду, что в случае ошибки пользовательский объект должен быть nil, но из-за некоторых особенностей го, мы не всегда можем просто сравнивать объект с nil. Поэтому использовать функцию Equal для сравнения с nil — это неправильно. Для это testify содержит специальную функцию Nil. 

На этом тестирование для добавления пользователя в БД окончено. Перейдём к тестированию кода для обновления email у пользователя. Для начала создадим код для обновления данных пользователя:

func (s *Server) UpdateUserEmail(u *models.User) (*models.User, error) {

    updateUserQuery := "update users SET email = $1 where name = $2 RETURNING *"

    stm, err := s.db.Prepare(updateUserQuery)
    if err != nil {
        return nil, err
    }

    res := stm.QueryRow(u.Email, u.Name)
    if res.Err() != nil {
        return nil, res.Err()
    }
    var resUser models.User
    err = res.Scan(&resUser.Name, &resUser.Email, &resUser.CreatedAt)
    if err != nil {
        return nil, err
    }

    return &resUser, nil
}

Рассмотрим два случая. В первом — обновление данных проходит успешно и не возникает никаких проблем, нам возвращается обновлённый объект и nil. Во втором случае мы попытаемся обновить email у несуществующего пользователя, поэтому мы должны получить nil и ошибку sql.ErrNoRows. Код для теста будет выглядеть следующим образом:

func TestUpdateUser(t *testing.T) {
    testCases := []struct {
        name       string
        testScript func()
    }{
        {"update user email", func() {

            user := CreateRandomUser(t)
            user.Email = util.RandomMail()
            updUser, err := testServer.UpdateUserEmail(user)

            assert.NoError(t, err)
            assert.Equal(t, user.Name, updUser.Name)
            assert.Equal(t, user.Email, updUser.Email)
            assert.WithinDuration(t, user.CreatedAt, updUser.CreatedAt, time.Second)

        }},
        {"update unknown user", func() {
            user := CreateRandomUser(t)
            user.Email = util.RandomMail()
            user.Name = util.RandomName()
            updUser, err := testServer.UpdateUserEmail(user)
            assert.ErrorIs(t, err, sql.ErrNoRows)
            assert.Nil(t, updUser)
        }},
    }

    for _, tc := range testCases {
        t.Run(tc.name, func(t *testing.T) {
            tc.testScript()
        })
    }
}

В целом, концепция у теста такая же, как и у предыдущего, в первом случае мы создаём пользователя и потом у объекта меняем email и обновляем. Обратите внимание на то, что для сравнения времени не стоит использовать функцию Equal, т. к., скорее всего, она будет работать неправильно из-за незначительной разницы во времени. Вместо этого лучше применить функцию WithinDuration, которая позволяет указать допустимую разницу во времени между сравниваемыми значениями. Во втором случае мы пытаемся обновить email у несуществующего пользователя, поэтому должны получить пустой объект и ошибку. Для таких ситуаций в го предусмотрена специальная ошибка sql.ErrNoRows. Поэтому для проверки мы используем функцию ErrorIs, которая гарантирует, что мы получили именно эту ошибку, а не другую.

Следующим мы протестируем функционал для получения пользователя. Мы будем искать пользователя только по имени. Код представлен ниже:

func (s *Server) GetUser(name string) (*models.User, error) {

    getUserQuery := "select * from users where name = $1"

    stm, err := s.db.Prepare(getUserQuery)
    if err != nil {
        return nil, err
    }

    res := stm.QueryRow(name)
    if res.Err() != nil {
        return nil, res.Err()
    }
    var resUser models.User
    err = res.Scan(&resUser.Name, &resUser.Email, &resUser.CreatedAt)
    if err != nil {
        return nil, err
    }

    return &resUser, nil
}

Для этого метода мы протестируем две ситуации. В первой мы сделаем запрос для существующего пользователя, а во второй попытаемся сделать запрос для несуществующего. Код для тестов представлен ниже:

func TestGetUser(t *testing.T) {
    testCases := []struct {
        name       string
        testScript func()
    }{
        {"get user", func() {
            user := CreateRandomUser(t)
            newUser, err := testServer.GetUser(user.Name)
            assert.NoError(t, err)
            assert.Equal(t, user.Name, newUser.Name)
            assert.Equal(t, user.Email, newUser.Email)
            assert.WithinDuration(t, user.CreatedAt, newUser.CreatedAt, time.Second)

        }},
        {"get unknown user", func() {
            newUser, err := testServer.GetUser(util.RandomName())
            assert.ErrorIs(t, err, sql.ErrNoRows)
            assert.Nil(t, newUser)
        }},
    }

    for _, tc := range testCases {
        t.Run(tc.name, func(t *testing.T) {
            tc.testScript()
        })
    }

}

В первом случае мы должны получить пустую ошибку и пользователя, похожие проверки мы уже делали — тут ничего нового. Во втором же —должна быть ошибка sql.ErrNoRows и nil, в целом похожий код мы писали для случая с обновлением email.

И теперь перейдём к последнему методу, который нужно протестировать — удаление пользователей. Код для удаления представлен ниже.

func (s *Server) DeleteUser(name string) (*models.User, error) {

    deleteUserQuery := "delete from users where name = $1 returning *"

    stm, err := s.db.Prepare(deleteUserQuery)
    if err != nil {
        return nil, err
    }

    res := stm.QueryRow(name)
    if res.Err() != nil {
        return nil, res.Err()
    }
    var resUser models.User
    err = res.Scan(&resUser.Name, &resUser.Email, &resUser.CreatedAt)
    if err != nil {
        return nil, err
    }

    return &resUser, nil
}

Здесь рассмотрим также два случая: в первом мы успешно удаляем пользователя, а во втором — попытаемся удалить несуществующего пользователя:

func TestDeleteUser(t *testing.T) {
    testCases := []struct {
        name       string
        testScript func()
    }{
        {"delete user", func() {
            user := CreateRandomUser(t)
            newUser, err := testServer.DeleteUser(user.Name)
            assert.NoError(t, err)
            assert.Equal(t, user.Name, newUser.Name)
            assert.Equal(t, user.Email, newUser.Email)
            assert.WithinDuration(t, user.CreatedAt, newUser.CreatedAt, time.Second)
            newUser, err = testServer.GetUser(util.RandomName())
            assert.ErrorIs(t, err, sql.ErrNoRows)
            assert.Nil(t, newUser)

        }},
        {"delete unknown user", func() {
            newUser, err := testServer.DeleteUser(util.RandomName())
            assert.ErrorIs(t, err, sql.ErrNoRows)
            assert.Nil(t, newUser)
        }},
    }

    for _, tc := range testCases {
        t.Run(tc.name, func(t *testing.T) {
            tc.testScript()
        })
    }

}

В целом, код практически эквивалентен тому, что мы писали в прошлом тесте, отличается только вызываемый метод. В первом кейсе проверяем, что мы удалили нужного пользователя, а во втором — убеждаемся, что пользователя не существует.

После написания тестов мы можем перейти в корневую директорию проекта и запустить следующую команду, которая покажет нам, какие из тестов не проходят, а также покрытие тестами кода:

go test -v -cover ./...

При запуске команды получим примерно такой вывод.

?       github.com/syncname/testifyexample/internal/models      [no test files]
?       github.com/syncname/testifyexample/internal/util        [no test files]
=== RUN   TestCreateUser
=== RUN   TestCreateUser/create_random_user
=== RUN   TestCreateUser/unique_name_error
=== RUN   TestCreateUser/unique_email_error
--- PASS: TestCreateUser (0.07s)
    --- PASS: TestCreateUser/create_random_user (0.06s)
    --- PASS: TestCreateUser/unique_name_error (0.00s)
    --- PASS: TestCreateUser/unique_email_error (0.00s)
=== RUN   TestUpdateUser
=== RUN   TestUpdateUser/update_user_email
=== RUN   TestUpdateUser/update_unknown_user
--- PASS: TestUpdateUser (0.01s)
    --- PASS: TestUpdateUser/update_user_email (0.01s)
    --- PASS: TestUpdateUser/update_unknown_user (0.00s)
=== RUN   TestGetUser
=== RUN   TestGetUser/get_user
=== RUN   TestGetUser/get_unknown_user
--- PASS: TestGetUser (0.00s)
    --- PASS: TestGetUser/get_user (0.00s)
    --- PASS: TestGetUser/get_unknown_user (0.00s)
=== RUN   TestDeleteUser
=== RUN   TestDeleteUser/delete_user
=== RUN   TestDeleteUser/delete_unknown_user
--- PASS: TestDeleteUser (0.00s)
    --- PASS: TestDeleteUser/delete_user (0.00s)
    --- PASS: TestDeleteUser/delete_unknown_user (0.00s)
PASS
        github.com/syncname/testifyexample/internal/api coverage: 83.3% of statements
ok      github.com/syncname/testifyexample/internal/api 0.089s  coverage: 83.3% of statements

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

Достоинства данного подхода в написании тестов очевидны. Вы получаете хорошо читаемые тесты с достаточно большим процентом покрытия кода, вам не нужно создавать точки API для тестирования БД; вы сразу видите, какие случаи были протестированы.

Исходный код тут

Автор статьи @yurii_habr


НЛО прилетело и оставило здесь промокод для читателей нашего блога:
-15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS.

Комментарии (1)


  1. astec
    02.11.2023 08:23
    +1

    Непонятно каким образом это табличные тесты?

    Почему например в последнем «func TestDeleteUser» не вызвать напрямую t.Run()?

    в моем понимание тест табличный когда вход разный (из массива) а ассерты общие.