Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 21 из 25 ~20 мин +50 XP

Тестирование сгенерированного кода

Тема урока — тесты sqlc: как проверять слой данных, если половину его кода написал генератор. Вопрос звучит парадоксально только до первого настоящего бага. Сгенерированный Go-код проверяется компилятором: сигнатуры, структуры, типы — всё это ловится до запуска. А вот SQL внутри констант компилятор не видит: WHERE может фильтровать не то, JOIN из двенадцатого урока может дублировать строки, NULL-колонка из [девятого урока](/courses/sqlc/lessons/09-null/) может уронить Scan, а транзакция из одиннадцатого — закоммитить половину. Ошибки живут ровно на стыке «ваш SQL против настоящей базы», и потому тестирование sqlc — это не про то, как проверить генератор, а про то, как проверить ваши запросы. Урок отвечает на шесть вопросов. Первый: что вообще тестировать в sqlc-проекте и почему сгенерированному пакету собственные тесты не нужны. Второй: интерфейс DBTX — шов, через который генерат поддаётся подмене. Третий: sqlmock — контракты поверх database/sql, когда он честен, а когда врёт. Четвёртый: интеграционные тесты go на sqlite из девятнадцатого урока — быстрые, но не на продовом диалекте. Пятый: настоящий Postgres через dockertest и разделение go test -short. Шестой: тесты транзакций, где сценарий — это откат. Ключевая развилка урока — моки против реальной базы, и к финалу у вас будет карта, на каком слое какой инструмент уместен. План курса — на [странице курса sqlc](/courses/sqlc/); раскладка репозитория со Store и emit_interface — [в двадцатом уроке](/courses/sqlc/lessons/20-organizaciya/), sqlite-контур — [в девятнадцатом](/courses/sqlc/lessons/19-sqlite/).

Содержание урока · 13 разделов

Что ломается и что тестировать#

Разложим слой данных на части и спросим у каждой: кто её проверяет. Структуры и методы — компилятор: опечатка в имени поля, лишний аргумент, перепутанный порядок параметров не соберутся вовсе; это та самая цена входа 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 — в интеграционном наборе; семантика транзакций у движков близка, но поведенческий тест на обеих базах — дешёвая страховка против диалектных сюрпризов из соседних уроков.

Два сценария одной транзакции

GO
func 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 и что проверяем в конце контракта.

db, err := sql.Open("sqlite", ) // база в памяти процесса if err != nil { t.Fatal(err) } defer db.Close() if _, err := db.ExecContext(ctx, ); err != nil { // схема теста t.Fatal(err) } q := sqlitedb.(db) // конструктор генерата // ... вызовы методов ... if err := mock.(); err != nil { // финальная сверка контракта t.Fatal(err) }

Пирамида тестов 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

Термин

Что тестируем в sqlc-проекте и что проверяет компилятор?

нажмите, чтобы перевернуть
Определение

Компилятор — сигнатуры и типы генерата; sqlc generate — синтаксис против схемы; тесты — семантику ваших запросов: фильтры, джойны, NULL, транзакции. Сам генерат тестов не имеет

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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) и письменно объясните, почему тест починился.

Личные заметкиПусто
Хранятся только в вашем браузере (sqlc · Тестирование сгенерированного кода)

Вопросы и ответы по уроку

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 — это ложный сигнал, лечится фиксом входа, а не сном в тесте.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

ent25 уроков~13 чпрактика25 уроков уже доступны

ent: графовая ORM с кодогенерацией

Курс ent: схемы, поля, связи, запросы, хуки, privacy, миграции и GraphQL. 25 уроков с примерами и проектом микроблога.

  • Проектировать схемы и связи в ent
  • Генерировать код и жить с кодогенерацией
  • Писать запросы builder-стилем с типами
  • Строить хуки, интерсепторы и privacy-правила