Содержание урока · 13 разделов
- Что ломается и что тестировать
- DBTX: шов, на котором держится любая подмена
- sqlmock: контракт-тест поверх database/sql
- Пределы sqlmock: почему семантика живёт не здесь
- Интеграционные тесты go на sqlite в памяти
- Настоящий Postgres: dockertest и go test -short
- Тесты транзакций: откат как сценарий
- Пирамида тестов sqlc-проекта
- Моки против реальной базы: вердикт
- Чеклист тестов sqlc-проекта
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Что ломается и что тестировать#
Разложим слой данных на части и спросим у каждой: кто её проверяет. Структуры и методы — компилятор: опечатка в имени поля, лишний аргумент, перепутанный порядок параметров не соберутся вовсе; это та самая цена входа sqlc, ради которой вы и в этот лагерь зашли. Синтаксис SQL — sqlc generate: запрос, который не парсится, валит генерацию, и шестнадцатый урок вешал на это миграции, чтобы парсер всегда видел актуальную схему. Что остаётся? Семантика: запрос компилируется и выполняется, но делает не то. Фильтр done = false пропускает задачи без owner-а, которых должен был исключить; LEFT JOIN вместо INNER приносит фантомные строки; count(*) без HAVING из тринадцатого урока молчит вместо того, чтобы отсеять редкие группы; UPDATE без WHERE обновляет всю таблицу — и sqlc не моргнёт, потому что синтаксически всё верно.
Отсюда правило, на котором стоит весь урок: тестируются запросы, а не генерат. Сгенерированному пакету не нужны собственные тесты — это артефакт сборки, как объектные файлы; его проверяет компиляция и sqlc diff из двадцать второго урока. Ваши запросы проверяются тремя слоями: домен — юнит-тестами на фейке репозитория, репозиторий — контракт-тестами на моках и интеграционными тестами на настоящих базах. Какой слой чем кормить — вопрос баланса, и sqlc тесты строятся как пирамида: внизу быстрые и дешёвые, наверху дорогие и редкие. Пирамиду соберём таблицей в конце, а пока разберём шов, на котором держится любая подмена — интерфейс DBTX.
Проверка: кто что ловит
Разработчик поменял в запросе WHERE done = false на WHERE done = true. Какая проверка поймает эту ошибку, если запрос синтаксически верен?
DBTX: шов, на котором держится любая подмена#
Вспомните каркас пакета из двадцатого урока: sqlc строит Queries не над sql.DB и не над pgxpool.Pool, а над интерфейсом DBTX. Для движков на database/sql он выглядит так: ExecContext, PrepareContext, QueryContext, QueryRowContext — четыре метода, и всё. Конструктор New(db DBTX) принимает всё, что их реализует: живой пул, транзакцию, тестовый мок — генерату безразлично. Одиннадцатый урок уже пользовался этим равнодушием: WithTx(tx) просто оборачивал *sql.Tx, потому что она реализует тот же DBTX. Для тестов это дар: вы можете скормить Queries любой объект, который вернёт нужные строки, — и это не хак, а штатная дверь, спроектированная генератором.
У шва есть и вторая настройка. Если в sqlc.yaml включён emit_methods_with_db_argument: true, sqlc вообще не хранит соединение в Queries: методы принимают DBTX первым аргументом, и один экземпляр Queries обслуживает и базу, и транзакции, и тесты — вы просто передаёте разный аргумент. Для pgx-пакета интерфейс свой — сигнатуры с pgx.Rows и pgconn.CommandTag, — а значит, и мок-библиотека нужна своя: для database/sql это sqlmock, для pgx — pgxmock, зеркальный API на том же интерфейсе. Правило простое: подмена держится на DBTX, а какой драйвер за ним — вопрос контура. sqlite из девятнадцатого урока и sqlmock в этом уроке используют один и тот же DBTX — потому и сменяются одной строкой конструктора.
Сгенерированный каркас: интерфейс и конструктор
GO// internal/store/sqlitedb/db.go — фрагмент генерата (движок sqlite)
type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{db: tx}
}Весь механизм подмены — вот он, шесть строк. Queries хранит не тип, а контракт: New примет sql.DB из main, sql.Tx из WithTx, мок sqlmock из теста — без единой правки генерата. Если ваш пакет сгенерирован с emit_methods_with_db_argument, картина та же, только db лежит в сигнатуре каждого метода. Восемнадцатый и девятнадцатый уроки меняли движки через этот интерфейс — сейчас меняется источник строк для теста.
Проверка: метод для одной строки
Сгенерированный метод ListOpenTodo из аннотации :one читает ровно одну строку. Какой метод DBTX он вызывает под капотом?
sqlmock: контракт-тест поверх database/sql#
Библиотека DATA-DOG/go-sqlmock подменяет драйвер database/sql: sqlmock.New() возвращает *sql.DB, у которой под капотом не sqlite и не postgres, а фейковый драйвер, помнящий ваши ожидания. Вы программируете разговор: ExpectExec — «дальше будет Exec с таким-то SQL», ExpectQuery — «будет SELECT», WithArgs перечисляет аргументы, WillReturnRows скармливает заранее собранные строки. Затем строите Queries на этом фейковом соединении и вызываете настоящий метод генерата. В конце теста ExpectationsWereMet проверяет, что разговор состоялся ровно так, как записано.
Что такой тест покупает. Он фиксирует контракт: метод CreateTodo должен исполнить INSERT INTO todos с вот этими тремя аргументами — и упадёт, если кто-то передал nil в приоритет или вызвал другой запрос. Он быстрый и не требует ничего, кроме Go: ни докера, ни файлов, ни праг. Он умеет возвращать ошибки — WillReturnError заставит проверить ветку обработки сбоя без смерти базы. Для доменной логики поверх репозитория это стабильная скорость: сотня контрактов исполняется за миллисекунды.
И что не покупает — сейчас главное. sqlmock не исполняет SQL: он матчит строку запроса регуляркой и возвращает вам ваши же фейковые строки. Ошибка «WHERE не тот» внутри sqlmock невидима — вы вернёте себе то, что положили. Даже хуже: сопоставление по строке хрупко к безобидным правкам — sqlc переставил пробел или переупорядочил RETURNING, и всё тесты красные, хотя SQL правильный. Поэтому роль sqlmock в тестировании sqlc узкая и честная: контракты вызовов, ветки ошибок, регрессии аргументов. Семантика — не его работа.
Контракт CreateTodo на sqlmock
GO// internal/store/sqlitedb/contract_test.go
package sqlitedb_test
import (
"context"
"database/sql"
"regexp"
"testing"
sqlmock "github.com/DATA-DOG/go-sqlmock"
"tracker/internal/store/sqlitedb"
)
func TestCreateTodoContract(t *testing.T) {
ctx := context.Background()
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
q := sqlitedb.New(db)
rows := sqlmock.NewRows([]string{"id", "title", "done"}).AddRow(42, "написать урок", false)
mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO todos")).
WithArgs("написать урок", 2).
WillReturnRows(rows)
todo, err := q.CreateTodo(ctx, sqlitedb.CreateTodoParams{
Title: "написать урок", Priority: 2,
})
if err != nil {
t.Fatal(err)
}
if todo.ID != 42 {
t.Fatalf("ожидали id 42, получили %d", todo.ID)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err) // вызов не совпал с ожиданиями: другой SQL или аргументы
}
}Четыре действия контракта. ExpectQuery матчит начало INSERT INTO todos — sqlmock по умолчанию применяет регулярку, и QuoteMeta прячет служебные символы; WithArgs закрепляет порядок аргументов; WillReturnRows подсовывает строку, которую метод честно отсканирует в Todo. ExpectationsWereMet закрывает сделку: если метод вызвал другой запрос или не вызвал ничего, тест красный. Заметьте: реальный SQL не исполнялся — проверен факт вызова, а не смысл запроса.
Предскажите исход контракта
Прочитайте код и выберите, что он выведет:
db, mock, _ := sqlmock.New()
q := sqlitedb.New(db)
mock.ExpectQuery(regexp.QuoteMeta("SELECT")).
WithArgs(1).
WillReturnRows(sqlmock.NewRows([]string{"id", "title"}).AddRow(1, "x"))
_, err := q.GetTodo(ctx, 7) // GetTodo :one — SELECT ... WHERE id = ?Пределы sqlmock: почему семантика живёт не здесь#
Нарисуем границу жирно, чтобы потом не перебирать инструменты наугад. sqlmock не знает диалектов: под одной регуляркой с одинаковой долей успеха пройдёт и SQLite, и PostgreSQL-запрос; значит, различия из восемнадцатого и девятнадцатого уроков он не проверит никогда. Он не исполняет индексов: запрос, который на проде соберёт full scan, здесь зелёный. Он не проверяет сканирование типов: вернёте TEXT в колонку int64 — тест упадёт на вашей же фейковой строке, а не на данных базы, и это ложная цена. И он синхронизируется со строкой запроса, а не с её смыслом: безобидное «sqlc переставил пробелы» роняет десяток тестов, а поменянный знак сравнения проходит мимо.
Из этого не следует «sqlmock плохой» — следует «у него узкая должность». Контракты вызовов, ветки ошибок, проверка, что слой данных вообще не ходит в базу там, где не должен (ExpectationsWereMet упадёт на лишнем запросе), — это его жанр, и стоит он дёшево. Как только вопрос «что вернёт этот SELECT на настоящих данных» — место sqlmock сразу занимает исполняющая база. Для pgx-пакета та же логика действует с pgxmock: API зеркальный, интерфейс — pgx-вариант DBTX, ограничения те же. А вопрос семантики — тема двух следующих блоков: сначала быстрая база в памяти, потом настоящий Postgres.
Интеграционные тесты go на sqlite в памяти#
Девятнадцатый урок собрал контур, который здесь становится фундаментом: sql.Open("sqlite", ":memory:"), один ExecContext со схемой, sqlitedb.New(db) — и весь сгенерированный пакет исполняет настоящие запросы на настоящем движке за миллисекунды. Это интеграционные тесты go в их самой быстрой форме: каждый тест получает собственную базу, изоляция идеальна без фикстур и очистки, CI не ждёт докера. Здесь ловятся всё, что живёт в самом SQL: WHERE-фильтры, джойны, GROUP BY, RETURNING, поведение NULL через sql.Null-типы, откат транзакций — движок-то настоящий.
Две оговорки, без которых тесты sqlc превращаются в самообман. Первая — техническая: :memory: привязана к соединению, а пул database/sql держит их несколько; тест, который открывает пул и надеется на одну базу, иногда «теряет» таблицы между запросами. Лечение из девятнадцатого: база на тест — своё соединение (SetMaxOpenConns(1) или отдельный *sql.DB на тест), и никаких общих in-memory-инстансов между параллельными тестами. Вторая оговорка честнее: sqlite — это не продовая база. Диалектные различия никто не отменял: ANY($1::int[]) не существует, batch-аннотации только у Postgres, типы вроде NUMERIC разворачиваются по другой карте. Интеграционный набор на sqlite отвечает за логику запросов, но не за диалект — для этого существует четвёртый слой, настоящий Postgres, о нём ниже.
Хелпер: Queries на своей базе за три строки
GO// internal/store/sqlitedb/integration_test.go
package sqlitedb_test
//go:embed testdata/schema.sql
var testSchema string // тот же DDL, что читает sqlc из db/migrations
func newTestQueries(t *testing.T) *sqlitedb.Queries {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
if _, err := db.ExecContext(context.Background(), testSchema); err != nil {
t.Fatal(err)
}
return sqlitedb.New(db)
}
func TestListOpenTodosFiltersDone(t *testing.T) {
ctx := context.Background()
q := newTestQueries(t)
created, err := q.CreateTodo(ctx, sqlitedb.CreateTodoParams{Title: "открытая"})
if err != nil {
t.Fatal(err)
}
_, _ = q.CreateTodo(ctx, sqlitedb.CreateTodoParams{Title: "закрытая"})
_, _ = q.CompleteTodo(ctx, sqlitedb.CompleteTodoParams{Done: true, ID: 2})
open, err := q.ListOpenTodos(ctx)
if err != nil {
t.Fatal(err)
}
if len(open) != 1 || open[0].ID != created.ID {
t.Fatalf("фильтр done = false пропустил лишнее: %+v", open)
}
}Хелпер t.Helper() и t.Cleanup — вся магия фикстур: база умирает вместе с тестом, схема накатывается из того же DDL, что читает sqlc, расхождений схема-против-запросов нет. Сам тест спрашивает у движка то, что компилятор спросить не может: действительно ли ListOpenTodos возвращает только незакрытые задачи. Строка с CompleteTodo намеренно использует второй id — две вставки дают id 1 и 2, и фильтр обязан оставить первую.
Проверка: ловушка пула
Тест открывает sql.Open("sqlite", ":memory:") и не ограничивает пул. Первая вставка прошла, второй запрос говорит «no such table: todos». Почему?
Настоящий Postgres: dockertest и go test -short#
Быстрая база не отвечает на главный вопрос продакшена: «а как это поведёт себя на PostgreSQL?» Диалектные фичи — массивы из десятого урока, RETURNING с особенностями, pgx-специфика — требуют настоящего движка. Стандартный ход — поднимать Postgres в докере прямо из теста: библиотека ory/dockertest (или testcontainers-go, механика та же) стартует контейнер в TestMain, ждёт готовности через Retry, отдаёт DSN, а после прогона контейнер purge-ится. Тесты получаются медленнее — секунды вместо миллисекунд, — но отвечают за диалект целиком: миграции из шестнадцатого урока накатываются на настоящую базу, и sqlc тесты проверяют то, что поедет на прод.
Разделение труда оформляется флагом -short: интеграционные тесты в начале вызывают testing.Short() и делают t.Skip — go test ./... гоняет всё, go test -short ./... только быстрые. Дешёвый набор (домен на фейках, контракты на sqlmock, sqlite в памяти) живёт в основном прогоне и локальной разработке, дорогой Postgres-набор — в отдельном job-е CI, где докер и так есть; двадцать второй урок соберёт этот пайплайн. DSN контейнера передаётся через переменную окружения TEST_DATABASE_URL — тест читает её и скипается, если переменной нет: локальная машина без докера не ломается, CI получает полный прогон.
TestMain: контейнер Postgres под тестами
GO// internal/store/pgdb/integration_test.go
var testDB *sql.DB // заполняется в TestMain
func TestMain(m *testing.M) {
if testing.Short() {
os.Exit(m.Run()) // -short: без докера, только быстрые тесты
}
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
fmt.Println("TEST_DATABASE_URL не задан — интеграционные тесты пропущены")
os.Exit(m.Run())
}
pool, err := dockertest.NewPool("")
if err != nil {
log.Fatalf("нет связи с докером: %v", err)
}
resource, err := pool.Run("postgres", "16", []string{
"POSTGRES_USER=tracker", "POSTGRES_PASSWORD=secret", "POSTGRES_DB=tracker_test",
})
if err != nil {
log.Fatalf("контейнер не поднялся: %v", err)
}
pool.MaxWait = time.Minute
if err := pool.Retry(func() error {
var err error
testDB, err = sql.Open("pgx", dsnFor(resource))
if err != nil {
return err
}
return testDB.Ping()
}); err != nil {
log.Fatalf("база не ответила: %v", err)
}
code := m.Run()
if err := pool.Purge(resource); err != nil {
log.Printf("purge: %v", err)
}
os.Exit(code)
}Каркас читается сверху вниз: short-режим и отсутствие DSN — честные ранние выходы, контейнер поднимается один раз на весь пакет, Retry ждёт готовности базы вместо sleep-магии, m.Run() исполняет все тесты пакета, Purge убирает контейнер даже после падения тестов. testDB достаётся тестам глобально, а тест-хелпер накатывает миграции и строит pgdb.New(testDB) — тот же интерфейс DBTX, что у sqlite и мока.
Предскажите исход транзакции
Прочитайте код и выберите, что он выведет:
// Store.ExecTx из двадцатого урока: Begin, defer Rollback, fn, Commit
err := store.ExecTx(ctx, func(q *pgdb.Queries) error {
if _, err := q.CreateTodo(ctx, pgdb.CreateTodoParams{Title: "первая"}); err != nil {
return err
}
if _, err := q.CreateTodo(ctx, pgdb.CreateTodoParams{Title: "вторая"}); err != nil {
return err
}
return errors.New("валидация не прошла")
})
// затем: q.CountTodos(ctx) на том же соединенииТесты транзакций: откат как сценарий#
Транзакции — единственная часть слоя данных, где поведенческий тест важнее любых контрактов: мелкий баг в WHERE портит выборку, мелкий баг в транзакции портит деньги. Контракт, который вы оборачиваете в ExecTx, сформулировал одиннадцатый урок, и теперь его надо сертифицировать. Тестирование sqlc-транзакций строится на двух сценариях и одном приёме. Сценарий успеха: ExecTx выполняет две-три операции, функция возвращает nil, после чего все изменения видны снаружи — count вырос на два, обе записи с одинаковым маркером пакета на месте. Сценарий сбоя: операция в середине возвращает ошибку (или вы возвращаете её сами после вставок), ExecTx выходит с ошибкой, и ни одна строка не сохранена. Приём — маркер: вставляйте записи с уникальным значением (id пакета, приставка в title), чтобы между тестами не было пересечений, даже если кто-то когда-то запустит их на общей базе.
Отдельная работа — проверка «полузавершённого состояния» изнутри: если бизнес-правило требует, чтобы в середине транзакции данные были видны её же соединению (self-read), это проверяется запросом через тот же q, что делает вставки; а вот соседнее соединение этих строк не увидит до Commit — и это тоже тест: второй Queries на другой базе (или даже sqlite-соединении) подтвердит изоляцию. На sqlite-контуре всё это гоняется за миллисекунды, на Postgres — в интеграционном наборе; семантика транзакций у движков близка, но поведенческий тест на обеих базах — дешёвая страховка против диалектных сюрпризов из соседних уроков.
Два сценария одной транзакции
GOfunc TestExecTxRollbackOnFailure(t *testing.T) {
ctx := context.Background()
q := newTestQueries(t)
err := liteStore{q}.ExecTx(ctx, func(tx *sqlitedb.Queries) error {
if _, err := tx.CreateTodo(ctx, sqlitedb.CreateTodoParams{
Title: "пакет-42: первая",
}); err != nil {
return err
}
return errors.New("вторая операция не прошла")
})
if err == nil {
t.Fatal("ожидали ошибку из транзакции")
}
n, err := q.CountTodos(ctx)
if err != nil {
t.Fatal(err)
}
if n != 0 { // вставка не должна была дожить до базы
t.Fatalf("откат не сработал: в базе %d задач", n)
}
}Сценарий сбоя в живом виде: ошибка после первой вставки, errors.New из fn, ExecTx откатывает — count обязан быть нулём. Партнёрский тест на успех пишется зеркально: без ошибки count обязан быть единицей. Заметьте разделение ролей: tx внутри функции — для операций пакета, q снаружи — для проверки результата; через tx вы бы увидели незакоммиченное, через q — финальное состояние. Именно эта пара утверждений и есть минимальная сертификация вашего ExecTx.
Соберите контур интеграционного теста
Заполните пропуски: DSN базы в памяти, что накатываем, каким конструктором строим Queries и что проверяем в конце контракта.
Пирамида тестов sqlc-проекта#
| Слой | Инструмент | Что ловит | Цена |
|---|---|---|---|
| Домен: сервис поверх Querier | фейк интерфейса в тесте | логику, ветвления, ошибки домена | миллисекунды, без базы |
| Репозиторий: контракт | sqlmock / pgxmock | вызов нужного запроса, аргументы, ветки ошибок | быстро, но SQL не исполняется |
| Репозиторий: интеграция быстрая | sqlite :memory: + sqlitedb.New | семантику SELECT/JOIN/транзакций на реальном движке | миллисекунды, диалект не продовый |
| Репозиторий: интеграция полная | dockertest + Postgres 16 | диалект, массивы, RETURNING, миграции | секунды, нужен докер в CI |
| Конвейер: дрейф генерата | sqlc diff в CI (урок 22) | несоответствие коммита и конфига | секунды раз в push |
Моки против реальной базы: вердикт#
Сведите карту в правило и не переусложняйте. Фейк интерфейса Querier — для домена: сервисные тесты не должны знать о существовании SQL, и их сотни, потому что они бесплатны. sqlmock — для контрактов: он дешёвый и точный, но проверяет разговор, а не результат; держите их десяток на пакет, не сотню. SQLite в памяти — рабочая лошадь семантики: здесь живут тесты на фильтры, джойны, агрегации, откаты; их много, они быстрые, они исполняют настоящий SQL. Настоящий Postgres — арбитр диалекта: набор на десяток-другой тестов, который гоняется в CI против докер-контейнера и ловит всё, что sqlite не видит. Никто из них не заменяет соседа — у каждого своя полоса.
Две ошибки распределения, которые видны из практики. Первая — построить весь набор на sqlmock: тесты зелёные, а запрос с инвертированным условием едет на прод; это цена, которую вы платите за игнорирование исполняющей базы, и она несопоставима с минутой докера в CI. Вторая — гонять всё на Postgres-контейнере: пакет тестов растягивается на минуты, разработчики перестают их запускать локально, и скорость выигранной честности проигрывает дисциплине. Пирамида существует именно чтобы держать баланс: быстрые внизу в большом количестве, дорогие наверху в малом. Двадцать второй урок прикрутит эту пирамиду к конвейеру: sqlc diff на каждый push, быстрые тесты на каждый PR, полный набор — перед мержем.
Чеклист тестов sqlc-проекта#
- emit_interface включён: домен зависит от Querier, а не от *Queries — фейк пишется без докера.
- Тесты запросов живут рядом с пакетом: sqlitedb_test и pgdb_test, а не в папке «tests» на весь мир.
- Схема для тестов — тот же DDL из db/migrations, что читает sqlc: никаких самодельных CREATE TABLE.
- База :memory: — на каждый тест, соединение ограничено: пул не раздаёт пустые базы по ошибке.
- Постгрес-набор читает TEST_DATABASE_URL и скипается под testing.Short: локальный прогон без докера не ломается.
- Транзакции покрыты парой сценариев: успех коммитит всё, ошибка в середине не оставляет следов.
- ExpectationsWereMet в конце каждого sqlmock-теста: незаявленный вызов — красный тест.
Соберите тестовый слой репозитория
Расставьте шаги внедрения sqlc тестов в проект в рабочем порядке.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: тесты sqlc
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- В sqlc-проекте тестируются запросы, а не генерат: компилятор проверяет сигнатуры, sqlc generate — синтаксис против схемы, семантику SQL ловят только тесты против исполняющей базы.
- Шов для подмен — интерфейс DBTX: New(db DBTX) принимает пул, *sql.Tx через WithTx, мок; pgx-вариант — свои сигнатуры и pgxmock.
- sqlmock (DATA-DOG/go-sqlmock) подменяет драйвер database/sql: ExpectQuery/ExpectExec, WithArgs, WillReturnRows, ExpectationsWereMet — контракты и ветки ошибок, но SQL не исполняется.
- Интеграционные тесты go на sqlite :memory: — миллисекунды и настоящая семантика; база на каждый тест, соединение ограничить, диалект Postgres это не заменяет.
- Полная интеграция — dockertest в TestMain: контейнер Postgres, Retry, Purge, DSN из TEST_DATABASE_URL, скип под testing.Short для локального прогона.
- Транзакции проверяются парой сценариев — коммит и откат с ошибкой в середине; пирамида: фейки для домена, контракты на моках, sqlite для семантики, Postgres для диалекта.
Домашнее задание
Первое: включите emit_interface и напишите фейк Querier в памяти — юнит-тест домена, который вызывает CreateTodo и CompleteTodo через интерфейс, без базы. Второе: соберите хелпер newTestQueries из урока и накройте ListOpenTodos тестом, который вставляет открытую и закрытую задачи и проверяет, что вернулась одна. Третье: напишите контракт-тест CreateTodo на sqlmock с QuoteMeta, WithArgs и ExpectationsWereMet, затем поменяйте один аргумент и убедитесь, что тест упал с ошибкой несоответствия — зафиксируйте текст ошибки в шпаргалке. Четвёртое: поднимите dockertest-контур из TestMain, накатите миграции из шестнадцатого урока и прогоните sqlite-набор тестов против настоящего Postgres — запишите, какие тесты сломались и почему. Пятое: проверьте транзакцию Store.ExecTx двумя сценариями с маркерами в title: ошибка в середине оставляет count равным нулю, успех — двум. Шестое: добавьте go test -short в локальный алиас и убедитесь, что без TEST_DATABASE_URL пакет пропускает только интеграционные тесты. Седьмое: почините ловушку пула — откройте :memory: без ограничений, добейтесь «no such table» в тесте, затем добавьте SetMaxOpenConns(1) и письменно объясните, почему тест починился.
Личные заметкиПусто
Вопросы и ответы по уроку
sqlmock или интеграционные тесты: что выбрать, если нужно одно?
Интеграционные на настоящей базе. sqlmock проверяет, что вы вызвали правильную строку с правильными аргументами, но не исполняет SQL: инвертированный WHERE, потерянный JOIN и ошибка сканирования типов пройдут мимо. Начните с sqlite-контура (он за миллисекунды), добавьте Postgres-набор в CI, а sqlmock оставьте для контрактов вызовов и веток ошибок, которые на живой базе дорого воспроизводить. Мок-набор без исполняющей базы — это шлюпка на корабле: полезна, но не для дальнего плавания.
Тесты на sqlite зелёные, на Postgres падают. Это нормально?
Да, это ожидаемое свойство, а не провал: движки разные, и различия задокументированы в восемнадцатом и девятнадцатом уроках — массивы ANY, batch-аннотации, карты типов DECIMAL/NUMERIC, поведение BOOLEAN, RETURNING-особенности. sqlite-набор отвечает за логику запросов, Postgres-набор — за диалект; когда второй ловит первый на вранье, он работает именно так, как задуман. Держите обе базы в пирамиде и не пытайтесь выровнять их хаками вроде единого диалекта — это дорога к обоим багам одновременно.
Как тестировать запросы с датами и временем, если :memory: не хранит таймзоны как Postgres?
Договоритесь о формате: пишите и читайте time.Time в UTC — девятнадцатый урок фиксировал, что таймзону в sqlite несёт строка, а договор «все даты в UTC» держит приложение. Тесты при этом сравнивают не «настоящее время», а фикс: передавайте в тесте конкретные значения (time.Date(2026, 7, 15, 10, 30, 0, 0, time.UTC)) и проверяйте их же на выходе; для диапазонов используйте границы плюс-минус секунда, а не time.Now(). Тесты с Now в утверждениях мигают под нагрузкой CI — это ложный сигнал, лечится фиксом входа, а не сном в тесте.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
ent: графовая ORM с кодогенерацией
Курс ent: схемы, поля, связи, запросы, хуки, privacy, миграции и GraphQL. 25 уроков с примерами и проектом микроблога.
- Проектировать схемы и связи в ent
- Генерировать код и жить с кодогенерацией
- Писать запросы builder-стилем с типами
- Строить хуки, интерсепторы и privacy-правила