3-2. CRUD とトランザクション - database/sql 実戦編
所要時間: 45-60分 コミット内容:
~/learn/go/level3/day02/に repository パターンの実装
このレッスンのゴール
-
QueryContext/QueryRowContext/ExecContext/PrepareContextを使い分けられる -
defer rows.Close()+rows.Err()チェックで接続リーク防止 - プレースホルダで SQL injection を完全防御
-
BeginTx+ 名前付き戻り値 + defer の Rollback パターンが書ける - N+1 問題を JOIN または IN クエリで回避できる
-
sql.ErrNoRowsをドメイン層のErrNotFoundに変換
なぜ学ぶか
「CRUD は書けるが、5日後に DB が詰まる」「N+1で本番が落ちる」「SQL injection に脆弱」 - すべて本章で防げる。CRUD コードを書くたびに3つ問う癖: (1) SQL injection 大丈夫? (2) rows.Close() 書いた? (3) Tx 境界正しい?。これが脳内チェックリストに入ると「事故らない CRUD」が書ける。
前章とのつながり
3-1_database_sql で *sql.DB を作った。本章でそれを 実際に使って CRUD を書く。2-6_パッケージ分割 の Repository インターフェースの 本物の実装(PostgresRepo/MySQLRepo)。
これができると何が嬉しいか
- SQL injection を1ミリも書かない コードがレビューで指摘されない
- N+1 が一目で分かる - ループ内 DB クエリを見たら警戒
- トランザクション境界が脳内モデル - お金の移動でも安心
- Repository インターフェース で本番 DB なしに service テストできる
ストーリー導入: rows.Close() を忘れた1行が5日後に DB を殺す
for rows.Next() でループ。ある条件で early return。defer rows.Close() を書き忘れた。1リクエストで1接続漏れ。100req/s で1分=6000接続。5分後、MaxOpenConns 食い潰し → 全クエリ待ち行列 → 全リクエスト 30秒タイムアウト → サービス停止。接続リークは「ローカルで発覚しない、本番で5日後に出る」事故。本章で身につけるのは「defer rows.Close() を err チェック直後に書く」反射神経。
大前提: SQL を書く時に常に問う3つの問い
CRUD コードを書くたびに、自分にこう問うクセを付けるとレベルが一段上がります。
- 入力は SQL injection 対策できているか? — 文字列連結は1箇所もないか
rowsを必ずCloseしているか? — リソースリークは即発見されにくく、本番で5日後に DB が詰まる- トランザクションの境界は正しいか? — エラー時に Rollback、成功時に Commit、これが defer で漏れていないか
このレッスンは「書ける」ようになるのが目的ではなく、書けるようになった上で事故らないことが目的です。
セッション①: 基本の4関数とプレースホルダ(25-30分)
0. テーブル準備
-- MySQL の例
CREATE TABLE users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
bio TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE posts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
title VARCHAR(200) NOT NULL,
body TEXT NOT NULL,
published BOOLEAN NOT NULL DEFAULT FALSE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id)
);1. 4つの実行関数を使い分ける
// 単一行の取得
var u User
err := db.QueryRowContext(ctx,
"SELECT id, email, name FROM users WHERE id = ?", id).
Scan(&u.ID, &u.Email, &u.Name)
// 複数行の取得
rows, err := db.QueryContext(ctx,
"SELECT id, email, name FROM users WHERE created_at > ?", since)
if err != nil {
return nil, err
}
defer rows.Close() // 必須
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Email, &u.Name); err != nil {
return nil, err
}
users = append(users, u)
}
if err := rows.Err(); err != nil { // ループ後に必ずチェック
return nil, err
}
// INSERT / UPDATE / DELETE
res, err := db.ExecContext(ctx,
"INSERT INTO users (email, name) VALUES (?, ?)", email, name)
if err != nil {
return 0, err
}
id, _ := res.LastInsertId()
affected, _ := res.RowsAffected()4つの関数とその意図
関数 いつ使う 戻り値 QueryContext複数行を SELECT *sql.RowsQueryRowContext1行だけ SELECT(PK・UNIQUE 引きなど) *sql.RowExecContextINSERT / UPDATE / DELETE sql.Result(LastInsertId, RowsAffected)PrepareContext同じクエリを大量に繰り返す時 *sql.Stmt全部
Context付きを使うこと。context無し版(Query/QueryRow/Exec)はキャンセル不能で、スローSQL を踏むとリクエストがタイムアウトしても DB側で動き続ける。
rows.Close()を忘れると本番が詰む
*sql.Rowsは内部で 接続プールから1つの接続を握ったまま イテレーションする。rows.Close()を呼ばずにループを抜けると、その接続は永久にプールに戻らない。100リクエスト/秒のアプリで
Closeを漏らすと、5分後にMaxOpenConnsを食い潰して全クエリが待ち行列に積まれる。defer を最初に書く。rows, err := db.QueryContext(ctx, q, args...) if err != nil { return err } defer rows.Close() // ← err チェックの直後に必ず書くただし、ループを最後まで回せば
rows.Next()が false を返したタイミングで内部的に Close される。defer rows.Close()は「途中で return / error した時の保険」として効く。ループ完走と defer の二重防御 が定石。
rows.Err()を忘れないfor rows.Next() { // Scan処理 } if err := rows.Err(); err != nil { return err // ← これを書かないと、イテレーション中のエラーを見逃す }
rows.Next()がfalseを返した時、それは「全行読み終わった」のか「途中でエラーが起きた」のか区別できない。rows.Err()で必ず確認 する。
2. プレースホルダ(Prepared Statement の入口)
// 正しい: プレースホルダを使う
db.QueryRowContext(ctx, "SELECT id FROM users WHERE email = ?", email)
// MySQL/SQLite は ?、PostgreSQL は $1, $2 ...
db.QueryRowContext(ctx, "SELECT id FROM users WHERE email = $1", email)プレースホルダ(
?や$1)の本質プレースホルダは「ここに値を入れる」というマーカー。SQL構文と値が完全に分離される ので、値部分に
'; DROP TABLE users; --のような攻撃文字列が含まれていてもデータとして扱われる。内部的には:
- ドライバが SQL を DB サーバーに送って コンパイル(パース・実行計画作成)
- 値だけを別途送って バインド
- DB はコンパイル済みクエリを実行
値は構文の一部にならない。つまり、プレースホルダ = SQL injection 完全防御(プレースホルダで渡せる部分について)。
文字列連結は SQL injection の入口
// 絶対NG q := "SELECT id FROM users WHERE email = '" + email + "'" db.QueryRowContext(ctx, q)攻撃者が
email = "x' OR '1'='1"を送ると:SELECT id FROM users WHERE email = 'x' OR '1'='1'全ユーザーが引かれる。これは「2000年代のWebセキュリティ事故の最頻出パターン」で、いまだに新規プロダクトで起きている。
「自分は信用できる入力源からしか取らない」と思ってはいけない。コードが寿命を迎えるまでに、必ずどこかから不審な入力が流れ込む。プレースホルダ以外は受け付けない、と決め打ちする。
例外: テーブル名・カラム名はプレースホルダで渡せない。動的にカラム名を組み立てる場合は ホワイトリスト検証 が必要。
allowedColumns := map[string]bool{"name": true, "created_at": true} if !allowedColumns[sortBy] { return errors.New("不正なソートキー") } q := fmt.Sprintf("SELECT * FROM users ORDER BY %s", sortBy)
3. NULL 対応
// NULL を含む可能性のあるカラム
var bio sql.NullString
err := db.QueryRowContext(ctx,
"SELECT bio FROM users WHERE id = ?", id).Scan(&bio)
if bio.Valid {
fmt.Println(bio.String)
}
// もう一つの選択肢: ポインタ
var bioPtr *string
err = db.QueryRowContext(ctx, "...").Scan(&bioPtr)
if bioPtr != nil {
fmt.Println(*bioPtr)
}NULL を Go の型に受ける2つの方法
方法 例 特徴 sql.NullXxxsql.NullString、sql.NullInt64、sql.NullTimeValid boolで判定。標準パッケージ。ポインタ型 *string、*int64、*time.Timenil判定で判別。JSONエンコードと相性が良いJSON API のレスポンスにそのまま流したいなら ポインタ型 が便利。
sql.NullStringはMarshalJSONを独自実装してくれないので{"Valid":true,"String":"..."}のような変な形になる(Go 1.18 以降はdatabase/sql側で改善されたが、それでも素直なフィールドにはならない)。Postgres + pgx ならネイティブAPIで
pgtype.Textなどより表現力のある型が使える。
アンチパターン: 単純な string で受けて NULL を踏む
var bio string err := db.QueryRowContext(ctx, "SELECT bio FROM users WHERE id = ?", id).Scan(&bio) // bio が NULL の行で sql: Scan error on column index 0NULL を非ポインタ型に Scan しようとするとエラー。
bioカラムがNULL許容なら必ずsql.NullStringか*string。
セッション②: トランザクション・バルク・N+1(25-30分)
4. トランザクション
func TransferFunds(ctx context.Context, db *sql.DB, from, to int64, amount int64) (err error) {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin: %w", err)
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
if _, err = tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, from); err != nil {
return err
}
if _, err = tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, to); err != nil {
return err
}
return tx.Commit()
}トランザクションは「全部成功か、全部なかったことか」を保証する仕組み
銀行振込の例: 「from の残高を減らす」+「to の残高を増やす」の 両方が成功したら永続化、片方失敗なら両方なかったことに。
Go では以下の3ステップ:
db.BeginTx(ctx, nil)でトランザクション開始 →*sql.Txを取得- その
*sql.Tx経由でクエリ実行(tx.ExecContext,tx.QueryContext…)- 成功なら
tx.Commit()、失敗ならtx.Rollback()ポイント:
*sql.DBのメソッドを使ったクエリは別のコネクションで実行されるので、トランザクションに含まれない。必ずtx経由。
名前付き戻り値 + defer の rollback パターン
上のコード例は Go の名前付き戻り値とdeferの組み合わせ の代表例。
- 関数シグネチャの
(err error)でerrを名前付き戻り値にするdeferの中でそのerrを参照- エラーで return された時だけ Rollback する
明示的に
if err != nil { tx.Rollback(); return err }を書いてもいいが、忘れやすい。defer に集約する ことで漏れを防ぐ。Go コミュニティの定石。
アンチパターン: エラー時に Commit してしまう
// NG _, err := tx.Exec("UPDATE ...") tx.Commit() // err が non-nil でも commit してる if err != nil { return err }部分成功でデータが壊れる。Commit 前に必ず err チェック、もしくは defer + 名前付き戻り値で集約。
トランザクションの分離レベル
BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})で分離レベルを指定できる。
- READ UNCOMMITTED: 他の未コミットを読める。事実上使わない
- READ COMMITTED: コミット済みを読む。PostgreSQL のデフォルト
- REPEATABLE READ: トランザクション中は同じ読み結果。MySQL InnoDB のデフォルト
- SERIALIZABLE: 直列化。最強だが競合多発で遅い
ほとんどの場合デフォルトでよい。お金や在庫の更新では SERIALIZABLE か、SELECT … FOR UPDATE で行ロック。
5. Prepared Statement(再利用と性能)
stmt, err := db.PrepareContext(ctx,
"INSERT INTO logs (user_id, action, created_at) VALUES (?, ?, NOW())")
if err != nil {
return err
}
defer stmt.Close()
for _, log := range logs {
if _, err := stmt.ExecContext(ctx, log.UserID, log.Action); err != nil {
return err
}
}
Prepareの意義は2つ
- SQL injection 防止: プレースホルダ使うので
QueryContextと同じ防御効果- 性能: 同じクエリを繰り返し実行する時、DB側でパース・最適化のキャッシュが効く
ただし、
db.QueryContext("...", args)を毎回呼んでも内部で自動的に Prepare/Execute されるので、1回しか使わないクエリで明示的に Prepare する必要は無い。明示
Prepareのメリット: 同じステートメントを 数千〜数万回回す時(バルク INSERT、ETL処理)。サーバー側キャッシュが効くため、パース回数が劇的に減る。
6. バルク INSERT
// 悪い例: 1行ずつ INSERT
for _, u := range users {
db.ExecContext(ctx, "INSERT INTO users (email, name) VALUES (?, ?)", u.Email, u.Name)
}
// 良い例: 1回でバルク INSERT
func bulkInsert(ctx context.Context, db *sql.DB, users []User) error {
if len(users) == 0 {
return nil
}
placeholders := make([]string, 0, len(users))
args := make([]any, 0, len(users)*2)
for _, u := range users {
placeholders = append(placeholders, "(?, ?)")
args = append(args, u.Email, u.Name)
}
q := "INSERT INTO users (email, name) VALUES " + strings.Join(placeholders, ",")
_, err := db.ExecContext(ctx, q, args...)
return err
}バルク INSERT のオーダーが違う
1000 行を 1行ずつ INSERT する場合:
- パケット往復 1000回 × ネットワーク RTT
- WAL/binlog 書き出し 1000回
バルク INSERT:
- パケット往復 1回
- WAL/binlog 書き出し 1回(場合により)
実測で 20-100倍の差が出る。ETL や初期データ投入で1行ずつ INSERT すると半日かかるところが、バルクなら数分で終わる。
プレースホルダの数の上限
MySQL は1ステートメントあたり 65535 個のプレースホルダ上限がある。PostgreSQL も同様の制約。
カラム N 個 × 行数 M で、N × M がこの上限を超えるとエラー。バッチを 1000行 ずつに区切る のが定石。
PostgreSQL + pgx なら
COPYプロトコルが使える。これはさらに桁違いに速く、数百万行を秒で投入できる。
7. N+1 問題
// 悪い例: N+1
posts, _ := getPosts(ctx, db)
for i, p := range posts {
var u User
db.QueryRowContext(ctx, "SELECT name FROM users WHERE id = ?", p.UserID).
Scan(&u.Name)
posts[i].AuthorName = u.Name
}
// posts 100件なら 1 (posts取得) + 100 (各 user取得) = 101 クエリN+1 はバックエンド障害の頂点
1回のリクエストで「1 + N」回 DB クエリを投げてしまうパターン。N が1万件のページで起きると、1リクエストで1万回のクエリになる。
開発環境(データ少ない)では気付かず、本番投入後にユーザーが増えるとレイテンシが線形悪化する。Go の database/sql + ループ + 別クエリ という素朴な書き方で簡単に発生する。
ORM だと「Eager Loading(事前ロード)」を1行指定するだけで防げるが、書き手が知らないと N+1 のまま出荷される。
// 良い例1: JOIN で1クエリにまとめる
rows, err := db.QueryContext(ctx, `
SELECT p.id, p.title, p.body, u.id, u.name
FROM posts p
INNER JOIN users u ON u.id = p.user_id
WHERE p.published = TRUE
`)
// 良い例2: 2クエリ + in_memory join
posts, _ := getPosts(ctx, db)
userIDs := make([]int64, 0, len(posts))
for _, p := range posts {
userIDs = append(userIDs, p.UserID)
}
users, _ := getUsersByIDs(ctx, db, userIDs) // IN クエリで一括取得
userByID := make(map[int64]User)
for _, u := range users {
userByID[u.ID] = u
}
for i, p := range posts {
posts[i].AuthorName = userByID[p.UserID].Name
}N+1 の3つの回避戦略
戦略 適している場面 落とし穴 JOIN 関連データが小さく、SELECT に簡単に乗る 1対多 JOIN は行が膨らむ(直積になる) IN クエリ + メモリ join 1対多、データ量が中規模 IN の値が多すぎるとプランナが壊れる(数千個まで) DataLoader パターン GraphQL や複雑な解決 実装複雑化、Go では graph-gophers/dataloader が使える
8. IN クエリの動的バインド
func getUsersByIDs(ctx context.Context, db *sql.DB, ids []int64) ([]User, error) {
if len(ids) == 0 {
return nil, nil
}
placeholders := make([]string, len(ids))
args := make([]any, len(ids))
for i, id := range ids {
placeholders[i] = "?"
args[i] = id
}
q := fmt.Sprintf("SELECT id, name FROM users WHERE id IN (%s)", strings.Join(placeholders, ","))
rows, err := db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
// ... 通常の Scan
}IN クエリは sqlx の
Inヘルパーが便利q, args, err := sqlx.In("SELECT * FROM users WHERE id IN (?)", ids) q = db.Rebind(q) // ? → $1, $2 ... に変換
database/sqlだけだと毎回手作りになるので、ここからsqlxを使う動機が生まれる。
9. Repository パターン
package repository
import (
"context"
"database/sql"
)
type User struct {
ID int64
Email string
Name string
}
// UserRepository インターフェース。テストでモック差し替え可能
type UserRepository interface {
GetByID(ctx context.Context, id int64) (*User, error)
Create(ctx context.Context, u *User) (int64, error)
}
// SQLImpl: database/sql 実装
type UserSQLRepo struct {
DB *sql.DB
}
func (r *UserSQLRepo) GetByID(ctx context.Context, id int64) (*User, error) {
var u User
err := r.DB.QueryRowContext(ctx,
"SELECT id, email, name FROM users WHERE id = ?", id).
Scan(&u.ID, &u.Email, &u.Name)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &u, nil
}
func (r *UserSQLRepo) Create(ctx context.Context, u *User) (int64, error) {
res, err := r.DB.ExecContext(ctx,
"INSERT INTO users (email, name) VALUES (?, ?)", u.Email, u.Name)
if err != nil {
return 0, err
}
return res.LastInsertId()
}Repository パターンの意義
「ビジネスロジックを DB の都合から分離する」設計パターン。
- 上位レイヤー(ハンドラ、ユースケース)は interface に依存
- 実装(SQL書く、Redis書く、メモリ実装)は 下位 で差し替え可能
- テスト時に メモリ実装 や モック を注入できる
Go では「インターフェースを定義する場所は使う側」というイディオムがあり、
UserRepositoryインターフェースをdomainパッケージに置き、repositoryパッケージに具象実装を置くのがよくある構成。
3-3_テスト基礎.md/3-4_テーブル駆動テスト.mdで、このインターフェースをモックして単体テストする方法をやる。
sql.ErrNoRowsを業務エラーに変換する
QueryRowContext().Scan()は「0件」だった場合sql.ErrNoRowsを返す。これを そのまま上位に伝播させない。var ErrNotFound = errors.New("user not found") if err == sql.ErrNoRows { return nil, ErrNotFound }理由: ハンドラ側が
sql.ErrNoRowsをハンドリングすると、DB実装に依存する。Redis 実装に切り替えた瞬間にエラー判定が壊れる。業務ドメインのエラー型 に変換するのが綺麗。Go 1.13+ なら
errors.Is(err, sql.ErrNoRows)で判定するのが推奨。
練習課題
mkdir -p ~/learn/go/level3/day02
cd ~/learn/go/level3/day02
go mod init example.com/crud- SQLite で users テーブルを作成(modernc.org/sqlite を使うと CGO 不要)
UserRepositoryインターフェースを定義Create,GetByID,ListByCreatedAfter,UpdateName,Deleteを実装- 各メソッドに
context.Contextを必ず第一引数で渡す - トランザクションで「ユーザー作成 + ログ書き込み」を実装し、ログ書き込みエラー時にユーザー作成も Rollback されることを確認
締め: git で証跡を残す
cd ~/learn/go/level3/day02
git add .
git commit -m "feat(go): repository パターンとトランザクションを実装"アンチパターン集 - やらかし事例
CRUD 実装のやらかし
1. 文字列連結で SQL を組み立て
q := "SELECT * FROM users WHERE email = '" + email + "'" // ← SQLi 直行便プレースホルダ
?か$1必須。2.
rows.Close()忘れ 接続リークで5日後に本番停止。defer をエラーチェック直後に書く。3.
rows.Err()忘れ イテレーション中エラーを見逃す。rows.Next()ループ後に必ずチェック。4. ループ内で個別クエリ(N+1)
for _, p := range posts { db.QueryRow("SELECT name FROM users WHERE id=?", p.UserID) // ← 100件で101クエリ }JOIN か IN クエリ + メモリ join に置き換え。
5. Tx エラー時に Commit してしまう
_, err := tx.Exec(...) tx.Commit() // ← err 確認前にコミット名前付き戻り値 + defer Rollback パターンで集約。
6. NULL を非ポインタ型で受ける
var bio string // NULL 行で Scan エラー
sql.NullStringか*stringで。7.
sql.ErrNoRowsを上位に漏らす ハンドラが DB 実装に依存。ErrNotFoundに変換。
対比表で違いを明確化
4つの実行関数の選び分け
関数 用途 戻り値 QueryContext複数行 SELECT *sql.RowsQueryRowContext1行 SELECT(PK/UNIQUE) *sql.RowExecContextINSERT/UPDATE/DELETE sql.ResultPrepareContext大量繰り返し *sql.Stmt
NULL の受け方
方法 例 用途 sql.NullStringValid boolで判定標準パッケージで完結 ポインタ *stringnil判定JSON との相性◎ pgtype(pgx) pgtype.TextPostgreSQL ネイティブ
N+1 回避の3戦略
戦略 適用 落とし穴 JOIN 関連データ小、1対1 1対多で行膨らみ IN クエリ + メモリ join 1対多、中規模 IN 値が数千で遅化 DataLoader GraphQL 実装複雑
自己評価チェックリスト
手を動かせた
-
QueryRowContext().Scan()で1行取得 -
QueryContext+defer rows.Close()+rows.Err() -
ExecContext+LastInsertId()/RowsAffected() -
BeginTx+ 名前付き戻り値 + defer Rollback - バルク INSERT(プレースホルダ動的展開)
- IN クエリで N+1 を解消
-
sql.NullStringで NULL を受けた -
errors.Is(err, sql.ErrNoRows)で 0 件判定
説明できる
- プレースホルダが SQL injection を防ぐ仕組み
-
rows.Close()を書かないと本番がどう壊れるか - トランザクション分離レベル4種(READ COMMITTED 等)
- N+1 の3戦略の使い分け
やらかし回避
- 文字列連結で SQL 組み立てない
-
defer rows.Close()を err チェック直後に - エラー時に Commit しない
- DB 実装エラー (
sql.ErrNoRows) を上位に漏らさない
詰まった時のチートシート
| やりたいこと | 書き方 |
|---|---|
| 1行 SELECT | db.QueryRowContext(ctx, q, args...).Scan(&dest) |
| 複数行 SELECT | db.QueryContext(ctx, q, args...) + for rows.Next() + defer rows.Close() |
| INSERT/UPDATE/DELETE | db.ExecContext(ctx, q, args...) |
| LastInsertId | res.LastInsertId() |
| TX 開始 | tx, _ := db.BeginTx(ctx, nil) |
| Commit/Rollback | tx.Commit() / tx.Rollback() |
| NULL対応 | sql.NullString か *string |
| 0件チェック | errors.Is(err, sql.ErrNoRows) |
「実務OK」基準
- SQL injection を1ミリも書かない: 文字列連結を見たら即指摘できる
- N+1 を見抜ける: ループ内で DB クエリを見たら警戒する
- トランザクション境界を語れる: なぜここで Begin、ここで Commit/Rollback か
- 接続リークしない:
rows.Close+tx.Rollbackを反射的に書ける - Repository をモックして単体テストできる構造を組める
さらに深掘りするなら
- pkg.go.dev/database/sql - 公式リファレンス
- sqlc - Compile SQL to type-safe Go - 次に試す価値あり
- jmoiron/sqlx - 構造体マッピング + In ヘルパー
- 書籍: 『SQLアンチパターン』 - DB設計の落とし穴全般
- 記事: Avoiding N+1 queries in Go のシリーズ
次のレッスン
3-3 testingパッケージ で testing パッケージの基礎、t.Run、ベンチマーク、t.Parallel の落とし穴を扱う。
つながりの予告
- 本章の
UserRepositoryインターフェースを モック実装 で次章テスト errors.Is(err, ErrNotFound)の判定が次章のアサーションで再活用- トランザクションは 3-4_テーブル駆動テスト で「副作用ありテスト」設計に発展