Всем привет, рад вас снова видеть!
На предыдущей лекции мы узнали, как генерировать CRUD код на языке Golang для взаимодействия с базой данных. Сегодня мы узнаем, как написать unit тест для этих CRUD операций.
Ниже:
- Ссылка на плейлист с видео лекциями на Youtube
- И на Github репозиторий
Начнем с функции CreateAccount()
. Я собираюсь создать новый файл
account_test.go
в папке db/sqlc
.
В Golang принято помещать тестовый файл в ту же папку, что и код, и название
тестового файла должно заканчиваться суффиксом _test
.
Название пакета этого тестового файла будет db
, тот же пакет, в котором
находится наш CRUD код. Теперь давайте определим функцию TestCreateAccount()
.
func TestCreateAccount(t *testing.T) {
...
}
Каждая функция unit тестирования в Go должна начинаться с префикса
Test
(с прописной буквы T) и принимать объект testing.T
в качестве входных
данных. Мы будем использовать этот объект T
для управления состоянием теста.
Функция CreateAccount()
определена как метод объекта Queries
и требует
подключения к базе данных для взаимодействия с ней. Итак, чтобы написать тест,
мы должны сначала настроить соединение и объект Queries
. Правильное место
для этого — файл main_test.go
.
Я определю объект testQueries
как глобальную переменную, потому что мы будем
часто использовать его во всех наших unit тестах.
var testQueries *Queries
Объект Queries
определен в файле db.go
, который был сгенерирован sqlc
. Он
содержит DBTX
, который может быть либо соединением с базой данных, либо
транзакцией:
type Queries struct {
db DBTX
}
В нашем случае мы собираемся создать соединение с базой данных и использовать
его для создания объекта Queries
.
Я объявлю специальную функцию TestMain()
, которая принимает на вход объект
testing.M
.
func TestMain(m *testing.M) {
...
}
В Go принято, чтобы функция TestMain()
являлась основной точкой входа для всех
unit тестов внутри одного конкретного пакета Golang, которым в данном случае
является пакет db
.
Имейте в виду, что unit тесты в Golang запускаются отдельно для каждого
пакета, поэтому, если в вашем проекте несколько пакетов, у вас может быть
несколько файлов main_test.go
с разными точками входа TestMain()
.
Хорошо, теперь, чтобы создать новое соединение с базой данных, мы используем
функцию sql.Open()
и передаем драйвер базы данных и исходную строку базы
данных. А пока я просто объявлю их константами. В будущем мы научимся загружать
их из переменных окружения.
Драйвер БД должен быть postgres
. А подключение к БД мы можем скопировать из команды
migrate
, которую написали в предыдущей лекции.
const (
dbDriver = "postgres"
dbSource = "postgresql://root:secret@localhost:5432/simple_bank?sslmode=disable"
)
var testQueries *Queries
func TestMain(m *testing.M) {
conn, err := sql.Open(dbDriver, dbSource)
if err != nil {
log.Fatal("cannot connect to db:", err)
}
testQueries = New(conn)
os.Exit(m.Run())
}
Функция sql.Open()
возвращает объект соединения и ошибку. Если ошибка не
равна nil
, мы просто выводим сообщение о фатальной ошибке, что не можем
подключиться к базе данных.
В противном случае мы используем соединение для создания нового объекта
testQueries
. Функция New()
определена в файле db.go
, сгенерированном для
нас sqlc
.
Теперь testQueries
готов, все, что нам нужно сделать, это вызвать m.Run()
,
чтобы запустить unit тест. Эта функция вернет код после завершения работы,
который сообщит нам, пройдены тесты или нет. Затем мы должны сообщить об этом
программе, запустившей тест, с помощью команды os.Exit()
.
Хорошо, попробуем выполнить его!
У нас возникла ошибка: cannot connect to db: unknown driver "postgres"
(не
удается подключиться к БД: неизвестный драйвер "postgres").
Это связано с тем, что пакет database/sql
просто предоставляет общий интерфейс
для SQL базы данных. Его необходимо использовать в сочетании с драйвером базы
данных, чтобы взаимодействовать с конкретным движком базы данных.
Мы используем postgres
, поэтому я воспользуюсь драйвером lib/pq
. Давайте
откроем его страницу на Github и скопируем
команду go get
. Запустите его в терминале, чтобы установить пакет:
go get github.com/lib/pq
Теперь, если мы откроем файл go.mod, то увидим, что добавилась библиотека
lib/pq
.
Здесь в комментариях написано "indirect", потому что мы еще не импортировали
и не использовали его в нашем коде. Итак, вернемся к файлу main_test.go
и
импортируем драйвер lib/pq
:
import "github.com/lib/pq"
Это особый вид импорта, потому что мы фактически не вызываем никакие функции
lib/pq
непосредственно в коде. Соответствующий код database/sql
делает это за нас.
Поскольку импорт происходит таким образом, Go formatter автоматически удалит его при сохранении файла. Чтобы указать go formatter оставить его, мы должны использовать пустой идентификатор, добавив подчеркивание перед названием импортируемого пакета:
import (
"database/sql"
"log"
"os"
"testing"
_ "github.com/lib/pq"
)
Теперь, если мы снова запустим TestMain()
, ошибок больше не будет.
И если мы откроем терминал и запустим go mod tidy для обновления зависимостей,
то увидим, что библиотека lib/pq
больше не является "indirect" в файле
go.mod
, поскольку мы импортировали её в наш код.
Хорошо, теперь когда предварительная настройка завершена, мы можем начать писать
наш первый unit тест для функции CreateAccount()
.
Сначала мы зададим аргументы: CreateAccountParams
. Допустим,
владельца зовут Том
, баланс счета 100
, а валюта — доллары США (USD
).
Затем мы вызываем testQueries.CreateAccount()
, передаем фоновый контекст и
аргументы. Это тот же объект testQueries
, который мы ранее объявили в файле
main_test.go
.
func TestCreateAccount(t *testing.T) {
arg := CreateAccountParams{
Owner: "tom",
Balance: 100,
Currency: "USD",
}
account, err := testQueries.CreateAccount(context.Background(), arg)
...
}
Функция CreateAccount() возвращает в качестве результата объект Account
или
ошибку (error
).
Для проверки результата работы теста рекомендую использовать пакет testify.
он позволяет писать более короткие тесты, по сравнению с использованием стандартных
операторов if else
. Давайте запустим эту команду go get
в терминале, чтобы
установить пакет:
go get github.com/stretchr/testify
Хорошо, теперь, чтобы использовать этот пакет, нам нужно сначала его
импортировать. Testify
содержит несколько подпакетов, но я буду использовать
только один из них, а именно пакет require
.
import "github.com/stretchr/testify/require"
С помощью этого импорта мы теперь можем вызвать require.NoError()
, передать
объект testing.T
и ошибку, возвращаемую функцией CreateAccount()
.
func TestCreateAccount(t *testing.T) {
...
account, err := testQueries.CreateAccount(context.Background(), arg)
require.NoError(t, err)
require.NotEmpty(t, account)
}
По сути, эта команда проверит, что ошибка должна быть nil
, и автоматически
провалит тест, если это не так.
Затем мы требуем, чтобы возвращаемый Account
не был пустым объектом,
используя функцию require.NotEmpty()
.
После этого мы хотели бы проверить, что владелец счета, баланс и валюта совпадают с входными аргументами.
Итак, мы вызываем require.Equal()
, передаем t
, ожидаемого (поданного в
качестве аргумента) владельца и фактического account.Owner
.
func TestCreateAccount(t *testing.T) {
...
require.Equal(t, arg.Owner, account.Owner)
require.Equal(t, arg.Balance, account.Balance)
require.Equal(t, arg.Currency, account.Currency)
}
Точно так же мы требуем, чтобы arg.Balance
был равен account.Balance
, а
arg.Currency
был равен account.Currency
.
Мы также хотим убедиться, что идентификатор счета автоматически
генерируется Postgres. Итак, здесь мы требуем, чтобы account.ID
был не равен
нулю.
func TestCreateAccount(t *testing.T) {
...
require.NotZero(t, account.ID)
require.NotZero(t, account.CreatedAt)
}
Наконец, столбец created_at
также должен быть заполнен текущей меткой времени.
Функция NotZero()
предполагает, что переданное ей значение не должно быть нулевым
значением своего типа.
Вот и все! Unit тест завершен. Давайте нажмем на кнопку Run test
, чтобы
запустить его.
Мы видим ok, то есть тест пройден. Давайте откроем базу данных simple_bank
с
помощью TablePlus, чтобы убедиться, что запись была вставлена.
Вот она, создался один счёт с id
1. Значения owner
, balance
и currency
такие же, как мы задали в тесте. И поле created_at
также заполняется текущей
меткой времени. Превосходно!
Мы также можем щелкнуть «Run package tests», чтобы запустить все unit тесты в этом пакете. На данный момент у него только один тест, так что это не имеет значения.
Приятно, что также сообщается о покрытии кода. На данный момент наши unit тесты покрывают только 6,5% команд, что очень мало.
Если мы откроем файл account.sql.go
, то увидим, что функция CreateAccount()
теперь отмечена зеленым цветом, что означает, что она покрыта unit тестами.
Все остальные функции по-прежнему выделены красным, что означает, что они не покрыты тестами. Мы напишем больше unit тестов, чтобы покрыть их чуть позже.
Но перед этим я покажу вам более практичный способ генерировать тестовые данные вместо того, чтобы заполнять их вручную, как мы сделали для аргументов создания счёта.
Генерируя случайные данные, мы сэкономим много времени на выяснение того, какие значения использовать, код будет более лаконичным и понятным.
А поскольку данные случайны, это поможет нам избежать конфликтов между несколькими unit тестами. Это особенно важно, например, если у нас есть столбец с уникальным ограничением в базе данных.
Хорошо, давайте создадим новую папку util
и добавим в нее новый файл
random.go
. Имя пакета — util
, такое же, как и у папки, содержащей
его.
Сначала нам нужно написать специальную функцию: init()
. Эта функция
будет вызываться автоматически при первом использовании пакета.
package util
func init() {
rand.Seed(time.Now().UnixNano())
}
В этой функции мы задаём начальное значение для генератора
случайных чисел, вызывая rand.Seed()
. Обычно начальное значение
часто устанавливается равным текущему времени.
Поскольку rand.Seed()
ожидает в качестве входных данных int64
, мы
должны преобразовать время в Unix Nano, прежде чем передавать его в
функцию.
Это гарантирует, что каждый раз, когда мы запускаем код, сгенерированные
значения будут разными. Если мы не вызовем rand.Seed()
, генератор
случайных чисел будет вести себя так, как будто он инициализирован единицей,
поэтому сгенерированные значения будут одинаковыми для каждого запуска.
Теперь напишем функцию для генерации случайного целого числа:
func RandomInt(min, max int64) int64 {
return min + rand.Int63n(max-min+1)
}
Эта функция RandomInt()
принимает 2 числа int64
: min
и max
в
качестве входных данных. И он возвращает случайное число int64
между
min
и max
.
По сути, функция rand.Int63n(n)
возвращает случайное целое число от
0
до n-1
. Итак, rand.Int63n(max - min + 1)
вернет случайное целое
число от 0
до max - min
.
Таким образом, когда мы добавим min
к этому выражению, конечным
результатом будет случайное целое число между min
и max
.
Далее давайте напишем функцию для генерации случайной строки из n
символов. Для этого нам нужно будет объявить алфавит, содержащий все
поддерживаемые символы. Для простоты здесь я использую 26 строчных
английских букв.
const alphabet = "abcdefghijklmnopqrstuvwxyz"
func RandomString(n int) string {
var sb strings.Builder
k := len(alphabet)
for i := 0; i < n; i++ {
c := alphabet[rand.Intn(k)]
sb.WriteByte(c)
}
return sb.String()
}
В функции RandomString()
мы объявляем новый объект strings.Builder
sb
, подсчитываем общее количество символов в алфавите и присваиваем
его в переменную k
.
Затем мы будем использовать простой цикл for
для генерации n
случайных
символов. Мы используем rand.Intn(k)
для получения случайной позиции
от 0
до k-1
и берем соответствующий символ в этой позиции в алфавите,
присваивая его переменной c
.
Мы вызываем sb.WriteByte()
, чтобы записать этот символ c
в strings.Builder
.
Наконец, мы просто возвращаем sb.ToString()
вызывающей стороне.
На этом функция RandomString()
завершена. Теперь мы можем использовать
его для генерации случайного имени владельца.
Для этой цели давайте определим новую функцию RandomOwner()
. А внутри
мы просто возвращаем случайную строку из 6 букв. Я думаю, что этого
достаточно, чтобы избежать дублирования.
func RandomOwner() string {
return RandomString(6)
}
Точно так же я собираюсь определить другую функцию RandomMoney()
для
генерации случайной суммы денег. Допустим, это будет случайное целое
число от 0 до 1000.
func RandomMoney() int64 {
return RandomInt(0, 1000)
}
Нам нужна еще одна функция для генерации случайной валюты.
func RandomCurrency() string {
currencies := []string{"EUR", "USD", "CAD"}
n := len(currencies)
return currencies[rand.Intn(n)]
}
Эта функция RandomCurrency()
вернет одну из валют в списке. Здесь я
просто использую 3 валюты: EUR
, USD
и CAD
. Вы можете добавить больше
значений, если хотите.
Подобно тому, что мы сделали для генерации случайного символа из алфавита,
здесь мы вычисляем длину списка валют и присваиваем ее n
.
Затем мы используем функцию rand.Intn(n)
для генерации случайного индекса
между 0
и n-1
и возвращаем валюту по этому индексу из списка.
Хорошо, теперь вернёмся к файлу account_test.go
. В CreateAccountParams
мы можем заменить конкретное имя владельца на util.RandomOwner()
, баланс
на util.RandomMoney()
и доллар США на util.RandomCurrency()
.
func createRandomAccount(t *testing.T) Account {
arg := CreateAccountParams{
Owner: util.RandomOwner(),
Balance: util.RandomMoney(),
Currency: util.RandomCurrency(),
}
...
}
Вот и все!
Теперь, если мы повторно запустим unit тест и обновим TablePlus, мы
увидим новую запись с id = 3
со случайными значениями.
Первые 2 записи имеют одинаковые значения, потому что мы дважды запускали тест, прежде чем использовать случайные функции.
Итак, всё работает как мы планировали!
Теперь я добавлю новую команду для тестов в Makefile, чтобы мы могли легко запускать unit тесты в терминале.
Команда проста. Мы просто вызываем go test
, используем параметр -v
для
вывода подробной информации и параметр -cover
для измерения покрытия кода.
test:
go test -v -cover ./...
Поскольку в нашем проекте будет несколько пакетов, мы используем аргумент
./...
для запуска unit тестов в каждом из них.
Теперь, если мы запустим make test
в терминале, то увидим, что он
выводит подробную информацию всякий раз, когда тест запускается или
завершается.
Также выводится информация о покрытии кода unit тестами для каждого пакета. Здорово!
Давайте обновим TablePlus, чтобы увидеть новую запись:
Это значение сильно отличается от предыдущей записи. Так что генератор случайных чисел работает хорошо.
Далее я покажу вам, как писать unit тесты для остальных операций
CRUD: Delete
, Get
, List
и Update
.
Начнем с функции GetAccount()
.
Вы знаете, чтобы протестировать все другие CRUD операции, нам всегда нужно сначала создать счёт.
Обратите внимание, что при написании unit тестов мы должны убедиться, что они независимы друг от друга.
Почему? Потому что было бы очень сложно поддерживать, если бы у нас были сотни тестов, которые зависят друг от друга. Поверьте, меньше всего вам хочется, когда простое изменение в одном тесте влияет на результат каких-то других.
По этой причине каждый тест должен создавать свои собственные Account
записи. Чтобы избежать дублирования кода, давайте напишем отдельную
функцию для создания случайной Account
записи. Вставьте код, который
мы написали в функцию TestCreateAccount()
:
func createRandomAccount(t *testing.T) Account {
arg := CreateAccountParams{
Owner: util.RandomOwner(),
Balance: util.RandomMoney(),
Currency: util.RandomCurrency(),
}
account, err := testQueries.CreateAccount(context.Background(), arg)
require.NoError(t, err)
require.NotEmpty(t, account)
require.Equal(t, arg.Owner, account.Owner)
require.Equal(t, arg.Balance, account.Balance)
require.Equal(t, arg.Currency, account.Currency)
require.NotZero(t, account.ID)
require.NotZero(t, account.CreatedAt)
return account
}
Затем для TestCreateAccount()
нам просто нужно вызвать createRandomAccount()
и
передать объект testing.T
следующим образом:
func TestCreateAccount(t *testing.T) {
createRandomAccount(t)
}
Обратите внимание, что функция createRandomAccount()
не имеет префикса
Test
, поэтому она не будет запускаться как unit тест. Вместо этого она
должна возвращать созданную запись Account
, чтобы другие unit тесты
имели достаточно данных для выполнения своих собственных операций.
Теперь, когда у нас есть эта функция, мы можем написать тест для функции
GetAccount()
.
Сначала мы вызываем createRandomAccount()
и сохраняем созданную запись в
account1
. Затем мы вызываем testQueries.GetAccount()
с фоновым
контекстом и ID account1
. Результат: account2
или ошибка.
func TestGetAccount(t *testing.T) {
account1 := createRandomAccount(t)
account2, err := testQueries.GetAccount(context.Background(), account1.ID)
require.NoError(t, err)
require.NotEmpty(t, account2)
...
}
Мы проверяем, что ошибка равна nil
, используя функцию require.NoError()
.
Затем мы требуем, чтобы account2
не был пустым.
Все поля account2
должны быть равны account1
. Мы используем функцию
require.Equal()
для их сравнения. Сначала ID, затем владельца
аккаунта, баланс и валюту.
func TestGetAccount(t *testing.T) {
...
require.Equal(t, account1.ID, account2.ID)
require.Equal(t, account1.Owner, account2.Owner)
require.Equal(t, account1.Balance, account2.Balance)
require.Equal(t, account1.Currency, account2.Currency)
require.WithinDuration(t, account1.CreatedAt, account2.CreatedAt, time.Second)
...
}
Для меток времени, таких как created_at
, помимо require.Equal()
, вы
также можете использовать require.WithinDuration()
, чтобы проверить, что две
метки времени отличаются не более чем на некоторую длительность. Например, в этом
случае я выбираю дельту равной 1 секунде.
Вот и все! Unit тест для операции GetAccount()
готов. Запустим его:
Он успешно пройден!
Теперь давайте напишем тест для функции UpdateAccount()
. Первым шагом
является создание нового account1
.
Затем мы объявляем аргументы, которые используются в объекте
UpdateAccountParams
, где ID
— это идентификатор созданного счёта, а
баланс — это случайная сумма денег.
func TestUpdateAccount(t *testing.T) {
account1 := createRandomAccount(t)
arg := UpdateAccountParams{
ID: account1.ID,
Balance: util.RandomMoney(),
}
...
}
Теперь мы вызываем testQueries.UpdateAccount()
, передаем фоновый контекст и
аргументы для обновления.
Затем мы проверяем, что не возникло ошибок. Обновленный объект account2
не
должен быть пустым.
func TestUpdateAccount(t *testing.T) {
...
account2, err := testQueries.UpdateAccount(context.Background(), arg)
require.NoError(t, err)
require.NotEmpty(t, account2)
}
И мы сравниваем каждое отдельное поле account2
с account1
. Почти все
они должны быть одинаковыми, за исключением баланса, который следует
изменить на arg.Balance
:
func TestUpdateAccount(t *testing.T) {
...
require.Equal(t, account1.ID, account2.ID)
require.Equal(t, account1.Owner, account2.Owner)
require.Equal(t, arg.Balance, account2.Balance)
require.Equal(t, account1.Currency, account2.Currency)
require.WithinDuration(t, account1.CreatedAt, account2.CreatedAt, time.Second)
}
Хорошо, давайте запустим этот тест.
Он успешно пройден!
Аналогичным образом можно легко реализовать TestDeleteAccount()
.
Сначала мы создаем новую запись account1
. Затем мы вызываем
testQueries.DeleteAccount()
и передаем фоновый контекст, а также идентификатор
созданной записи account1
. Мы проверяем, что не возникло ошибок.
func TestDeleteAccount(t *testing.T) {
account1 := createRandomAccount(t)
err := testQueries.DeleteAccount(context.Background(), account1.ID)
require.NoError(t, err)
...
}
Затем, чтобы убедиться, что запись действительно удалена, мы вызываем
testQueries.GetAccount()
, чтобы найти ее в базе данных. В этом случае вызов
должен вернуть ошибку. Поэтому мы используем здесь require.Error()
.
func TestDeleteAccount(t *testing.T) {
...
account2, err := testQueries.GetAccount(context.Background(), account1.ID)
require.Error(t, err)
require.EqualError(t, err, sql.ErrNoRows.Error())
require.Empty(t, account2)
}
Чтобы убедиться в правильности ошибки, мы используем функцию
require.EqualError()
, для проверки, что ошибка равна sql.ErrNoRows
. И,
наконец, проверяем, что объект account2
пустой.
Теперь давайте запустим тест.
Он успешно пройден! Отлично!
Последняя операция, которую мы хотим протестировать, это ListAccount()
. Она
немного отличается от других функций, поскольку выбирает несколько записей.
Итак, чтобы проверить её, нам нужно создать несколько счётов. Здесь
я просто использую простой цикл for
для создания 10 случайных записей.
func TestListAccounts(t *testing.T) {
for i := 0; i < 10; i++ {
createRandomAccount(t)
}
arg := ListAccountsParams{
Limit: 5,
Offset: 5,
}
...
}
Затем мы объявляем параметры ListAccountsParams
. Допустим, limit
равен 5
и
offset
равен 5
, что означает пропустить первые 5
записей и вернуть
следующие 5
.
Когда мы запускаем тесты, в базе данных будет не менее 10 записей, поэтому с этими параметрами мы ожидаем получить 5 записей.
Теперь мы вызываем testQueries.ListAccounts()
с фоновым контекстом и
параметрами.
func TestListAccounts(t *testing.T) {
...
accounts, err := testQueries.ListAccounts(context.Background(), arg)
require.NoError(t, err)
require.Len(t, accounts, 5)
for _, account := range accounts {
require.NotEmpty(t, account)
}
}
Мы проверяем, что не возникло ошибок и длина возвращаемого среза учетных записей равна 5.
Мы также перебираем в цикле список записей и требуем, чтобы каждая из них не была пустой.
Вот и все! Давайте запустим этот тест.
Он успешно пройден! Теперь давайте запустим все unit тесты в этом пакете.
Все они успешно пройдены.
Если мы посмотрим на файл account.sql.go
, то увидим, что все CRUD функции для
Account покрыты (выделены зеленым).
Но почему общее покрытие этого пакета равно всего 33,8%
?
Это связано с тем, что мы не писали никаких тестов для CRUD-операций с
таблицами Entry
и Transfer
. Я оставляю это в качестве упражнения для вас, чтобы
вы могли попрактиковаться.
Я надеюсь, что эта статья будет полезна для вас. Спасибо за время, потраченное на чтение, и увидимся на следующей лекции.