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