Использование табличных тестов в go для тестирования запросов к БД + testify
При написании бэкенда работа с базой данных зачастую составляет большую часть кода в проекте. Но несмотря на то, что в го стандартная библиотека для тестирования довольно удобная, она требует написания большого количества кода. Поэтому иногда вместо того, чтобы писать тесты разработчики могут ограничить тестирование при помощи какого-либо клиента (например, при помощи 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.