Опирается на правила:
GOTEST-15…GOTEST-18из Go Test Strategy → раздел 5. Структура одного теста.
Важно знать
- Имена тестов:
Test<Action>_<Condition>_<Expected>в PascalCase; BR-ссылка — комментарием над функцией.require.*— для критичных шагов setup и вызова (fatal on fail);assert.*— для проверок результата.- HTTP-запрос — через
http.DefaultClientк адресуsrv.URL; не черезhttptest.NewRecorderв интеграционном тесте.- AAA-структура с пустой строкой между блоками и комментариями
// Arrange,// Act,// Assert.t.Parallel()— только при транзакционной изоляции черезtx.Rollback; приTRUNCATE CASCADE— убирается.io.ReadAllвместо устаревшегоioutil.ReadAll; отладка —t.Logf, неfmt.Println.defer resp.Body.Close()— всегда послеhttp.DefaultClient.Do.- Один тест — один сценарий. Мега-тест на весь lifecycle — запрещён.
Полный пример одного теста. Это точка отсчёта для всех интеграционных тестов в Go-сервисе.
Полный пример
// TestCancelOrder_WhenAlreadyCancelled_Returns422 проверяет BR-ORDER-7:
// нельзя отменить заказ в статусе CANCELLED.
func TestCancelOrder_WhenAlreadyCancelled_Returns422(t *testing.T) {
t.Parallel()
srv, prep := newTestServer(t)
// Arrange
prep.Clear(t).CreateCustomer(t, "c1").CreateOrder(t, "o1", "c1", "CANCELLED")
// Act
req, err := http.NewRequest(http.MethodDelete, srv.URL+"/orders/o1", nil)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
// Assert
require.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode)
var body map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
assert.Equal(t, "ORDER_ALREADY_CANCELLED", body["code"])
assert.Equal(t, float64(422), body["status"])
}
Что есть в примере:
- Имя
TestCancelOrder_WhenAlreadyCancelled_Returns422— три части через_: действие, условие, ожидаемый результат. - Комментарий над функцией ссылается на конкретное бизнес-правило
BR-ORDER-7. newTestServer(t)из раздела «Инфраструктура теста» — собирает chi-роутер + sqlc +httptest.NewServer.prep.Clear(t)—TRUNCATE ... CASCADEв начале теста, не вTestMain.http.DefaultClient.Do— реальный HTTP-вызов кhttptest.Server, полный стек без обходов.require.Equalна статус — fatal: если статус неверный, продолжать нет смысла.assert.Equalна поля тела — продолжают собирать все несоответствия.
Имена тестов
GOTEST-15: Test<Action>_<Condition>_<Expected>, PascalCase.
// Хорошо
func TestCreateOrder_WhenCustomerNotFound_Returns404(t *testing.T) { ... }
func TestConfirmOrder_WhenDraft_Returns200(t *testing.T) { ... }
func TestGetProduct_WhenOutOfStock_Returns200WithZeroQty(t *testing.T) { ... }
func TestCreateCustomer_WhenEmailDuplicate_Returns409(t *testing.T) { ... }
// Плохо — не описывает условие и ожидание
func TestCancel(t *testing.T) { ... }
func TestOrder1(t *testing.T) { ... }
func TestCase3(t *testing.T) { ... }
BR-ссылка идёт комментарием над функцией — не в названии, не внутри тела:
// TestCreateOrder_WhenDraftConfirmed_Returns200 проверяет BR-ORDER-1:
// подтверждение заказа переводит его из DRAFT в CONFIRMED.
func TestCreateOrder_WhenDraftConfirmed_Returns200(t *testing.T) {
...
}
Это создаёт связь spec → test → код. Изменилось BR-ORDER-1 — grep по BR-ORDER-1 в _test.go показывает все затронутые тесты.
require и assert — когда что
GOTEST-16: require для критичных шагов, assert для проверок.
require.* завершает тест при первой же неудаче (t.FailNow()). Это правильно для шагов, без которых проверять дальше бессмысленно:
// Arrange — setup должен пройти, иначе тест не имеет смысла
prep.Clear(t)
prep.CreateCustomer(t, "cust-1")
// Act — ошибка HTTP-вызова делает все дальнейшие assert бессмысленными
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
// Статус — fatal: если 500 вместо 201, остальные проверки вводят в заблуждение
require.Equal(t, http.StatusCreated, resp.StatusCode)
// Assert — продолжают даже если одна не прошла, собирают все несоответствия
var got OrderResponse
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
assert.Equal(t, "cust-1", got.CustomerID)
assert.Equal(t, "DRAFT", got.Status)
assert.NotEmpty(t, got.ID)
t.Fatal напрямую — только в TestMain. В тестах и хелперах используем require.*/assert.* из testify.
HTTP через http.DefaultClient
GOTEST-17: запрос к srv.URL через http.DefaultClient; тело — строгий разбор.
// Хорошо — реальный HTTP-цикл
req, err := http.NewRequestWithContext(
context.Background(),
http.MethodPost,
srv.URL+"/orders",
strings.NewReader(`{"customerId":"cust-1","amount":150}`),
)
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusCreated, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var got CreateOrderResponse
require.NoError(t, json.Unmarshal(body, &got))
assert.Equal(t, "cust-1", got.CustomerID)
assert.Equal(t, "DRAFT", got.Status)
httptest.NewRecorder — не для интеграционных тестов. Он обходит реальный HTTP-стек и не проверяет chi-middleware (авторизацию, логирование, сжатие), сериализацию заголовков, retry-поведение клиента.
httptest.NewRecorder vs http.DefaultClient
| Инструмент | Когда |
|---|---|
http.DefaultClient → httptest.Server | Интеграционный тест: полный стек — middleware, сериализация, заголовки |
httptest.NewRecorder | Unit-тест хендлера без БД — тестируем только сериализацию и базовую логику |
В интеграционном тесте — http.DefaultClient. В unit-тесте хендлера (раздел «Пирамида тестов») — httptest.NewRecorder.
Параллельность и изоляция
GOTEST-18: t.Parallel() требует транзакционной изоляции.
Параллельный TRUNCATE CASCADE из двух тестов образует гонку данных: оба чистят таблицу одновременно, данные соседнего теста пропадают до его завершения. Правильная изоляция — транзакция на тест:
func TestCreateProduct_WhenValidRequest_Returns201(t *testing.T) {
t.Parallel()
pool := testPool // из TestMain
ctx := context.Background()
tx, err := pool.Begin(ctx)
require.NoError(t, err)
t.Cleanup(func() { _ = tx.Rollback(ctx) })
// Arrange — работаем внутри tx
_, err = tx.Exec(ctx,
"INSERT INTO customers(id, name) VALUES($1, $2)", "cust-1", "Sber")
require.NoError(t, err)
// Act — newTestServer с tx-пулом
srv, _ := newTestServerWithTx(t, tx)
resp, err := http.Post(srv.URL+"/products", "application/json",
strings.NewReader(`{"name":"Карта","customerId":"cust-1"}`))
require.NoError(t, err)
defer resp.Body.Close()
// Assert
require.Equal(t, http.StatusCreated, resp.StatusCode)
}
t.Cleanup(func() { tx.Rollback(ctx) }) откатывает транзакцию по завершении теста — соседние тесты не видят данных этого теста.
Если транзакционная изоляция неприменима (DDL, TRUNCATE внутри теста) — убираем t.Parallel(), тесты идут последовательно:
// Тест с TRUNCATE внутри — не параллельный
func TestCreateOrder_WithCleanState_Returns201(t *testing.T) {
// без t.Parallel()
srv, prep := newTestServer(t)
prep.Clear(t).CreateCustomer(t, "cust-1")
...
}
io.ReadAll и t.Logf
GOTEST-X6: ioutil.ReadAll устарел с Go 1.16 — только io.ReadAll. Отладка — через t.Logf, не fmt.Println:
// Хорошо
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
t.Logf("response body: %s", body)
// Плохо
body, _ := ioutil.ReadAll(resp.Body) // ioutil устарел
fmt.Println(string(body)) // не виден при go test без -v
t.Logf виден только при go test -v или при провале теста — не засоряет обычный вывод.
AAA с пустыми строками
GOTEST-3: три блока, пустая строка между ними.
func TestGetOrder_WhenNotFound_Returns404(t *testing.T) {
srv, prep := newTestServer(t)
// Arrange
prep.Clear(t)
// Act
resp, err := http.Get(srv.URL + "/orders/nonexistent-id")
require.NoError(t, err)
defer resp.Body.Close()
// Assert
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}
Комментарии // Arrange, // Act, // Assert — не обязательны, но пустая строка между блоками обязательна. Это визуальный сигнал «новая фаза теста».
Один тест — один сценарий. Мега-тест на lifecycle запрещён:
// Плохо — пять сценариев в одном тесте
func TestOrderLifecycle(t *testing.T) {
// создать
// подтвердить
// оплатить
// отгрузить
// отменить
}
При провале шага 3 непонятно, что с шагами 4-5. Имя не описывает ни один конкретный сценарий. Нельзя запустить отдельный случай.
Правильно — отдельные тесты:
func TestConfirmOrder_WhenDraft_Returns200(t *testing.T) { ... }
func TestPayOrder_WhenConfirmed_Returns200(t *testing.T) { ... }
func TestShipOrder_WhenPaid_Returns200(t *testing.T) { ... }
func TestCancelOrder_WhenConfirmed_Returns200(t *testing.T) { ... }
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Имя TestCancel, Test1, TestCase3 | GOTEST-15 | Test<Action>_<Condition>_<Expected> |
| BR-ссылка внутри тела теста или в переменной | GOTEST-15 | комментарий над func |
t.Fatal в теле теста (не в TestMain) | GOTEST-16 | require.NoError / require.Equal |
assert.* на шагах setup и HTTP-вызове | GOTEST-16 | require.* — fatal on fail |
httptest.NewRecorder в интеграционном тесте | GOTEST-17 | http.DefaultClient → httptest.NewServer |
ioutil.ReadAll | GOTEST-X6 | io.ReadAll |
fmt.Println для отладки в тесте | GOTEST-X6 | t.Logf |
t.Parallel() без транзакционной изоляции при TRUNCATE | GOTEST-18 | убрать t.Parallel() или перейти на tx.Rollback |
| Один гигантский тест на весь lifecycle | GOTEST-3 | один тест = один сценарий |
defer resp.Body.Close() после require.NoError пропущен | GOTEST-17 | всегда закрывать тело ответа |
Куда дальше
- Базовые правила — GOTEST-1..GOTEST-3, детерминизм, AAA.
- Инфраструктура теста —
TestMain,newTestServer,pgxpool. - DatabasePreparer — fluent setup:
Clear,CreateCustomer,CreateOrder. - Детерминизм — Clock и IDGenerator —
fixedClock,seqIDGenerator, запретtime.Now(). - Kafka, Redis, async — по умолчанию НЕТ — Outbox-проверка через
prep.FindOutboxEvents. - Пирамида тестов — unit на агрегат,
httptest.NewRecorderдля unit-теста хендлера, E2E. - Use Case Pattern → спецификация — BR-коды и их источник.