Использование табличных тестов в 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.


ссылка на оригинал статьи https://habr.com/ru/articles/771428/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *