Опирается на правила: GOTEST-15GOTEST-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.DefaultClienthttptest.ServerИнтеграционный тест: полный стек — middleware, сериализация, заголовки
httptest.NewRecorderUnit-тест хендлера без БД — тестируем только сериализацию и базовую логику

В интеграционном тесте — 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, TestCase3GOTEST-15Test<Action>_<Condition>_<Expected>
BR-ссылка внутри тела теста или в переменнойGOTEST-15комментарий над func
t.Fatal в теле теста (не в TestMain)GOTEST-16require.NoError / require.Equal
assert.* на шагах setup и HTTP-вызовеGOTEST-16require.* — fatal on fail
httptest.NewRecorder в интеграционном тестеGOTEST-17http.DefaultClienthttptest.NewServer
ioutil.ReadAllGOTEST-X6io.ReadAll
fmt.Println для отладки в тестеGOTEST-X6t.Logf
t.Parallel() без транзакционной изоляции при TRUNCATEGOTEST-18убрать t.Parallel() или перейти на tx.Rollback
Один гигантский тест на весь lifecycleGOTEST-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-коды и их источник.