7. Валидация ответов
За пределами синтаксиса: Семантика данных
В предыдущей главе 6. Проверка JSON структур мы научились защищать наши тесты от нестабильности, вызванной случайным порядком ключей в хэш-таблицах Go, и освоили технику точечного игнорирования динамических полей с помощью пакета go-cmp.
Однако проверка структурной идентичности — это лишь первый рубеж обороны. Представьте, что эндпоинт регистрации пользователей возвращает следующий ответ:
{
"id": "123",
"age": 12,
"status": "active"
}
С точки зрения парсера JSON и статической структуры Go перед нами идеальный документ: поля присутствуют, типы данных совпадают. Но с точки зрения бизнеса и доменных инвариантов — это катастрофический сбой:
- Сервис по регламенту работает только с пользователями старше 18 лет (
age >= 18). - Идентификатор
idобязан быть криптографически стойким UUID v4, а не подозрительно простой строкой"123". - Статус пользователя должен принадлежать строго фиксированному перечислению (Enum).
Интеграционные проверки API — это финальная граница, на которой мы обязаны верифицировать не только форму представления данных, но и их внутреннее смысловое наполнение. Этот процесс называется Семантической валидацией (Semantic Validation).
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
Resp["Сырой ответ сервера (JSON)"]:::warning --> Struct["Синтаксическая проверка<br>(go-cmp / Unmarshal)"]
Struct -->|"Типы корректны"| Sem["Семантическая валидация<br>(Доменные инварианты)"]
Sem -->|"UUIDv4, Email, ISO-8601"| V1["Форматы (Regex)"]
Sem -->|"Age >= 18, Баланс >= 0"| V2["Бизнес-правила"]
Sem -->|"now - delta <= t <= now"| V3["Временные окна"]
Уровни валидации ответов
В зрелой инженерной практике валидация ответов разделяется на три последовательных уровня глубины.
1. Форматы и Регулярные выражения (Regex)
Значения динамических полей (идентификаторы сущностей, токены сессий, хэши транзакций) невозможно проверить на точное посимвольное совпадение через require.Equal(). Однако их внутренний формат строго детерминирован спецификацией.
package api_test
import (
"regexp"
"testing"
"github.com/stretchr/testify/require"
)
// Компилируем конечные автоматы ровно один раз при инициализации тестового пакета
var (
uuidV4Regex = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
)
type UserResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Age int `json:"age"`
}
func assertValidResponseFormat(t *testing.T, resp UserResponse) {
t.Helper()
// Валидируем структурное соответствие контракту
require.Regexp(t, uuidV4Regex, resp.ID, "Идентификатор ID обязан соответствовать стандарту UUID v4")
require.Regexp(t, emailRegex, resp.Email, "Поле Email содержит некорректный адрес электронной почты")
}
2. Бизнес-ограничения (Domain Constraints)
Полезная нагрузка API обязана строго отражать инварианты предметной области. Если ручка возвращает историю платежей, транзакции должны быть упорядочены по времени в нисходящем порядке (DESC). Если сервис отдает банковские реквизиты, дробные числа не должны накапливать погрешности вычислений с плавающей точкой.
Вместо раздувания тела тестов бесконечными конструкциями if принято оформлять доменные проверки в виде специализированных вспомогательных assertions-функций:
type Transaction struct {
ID string `json:"id"`
Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
}
// assertSortedByDateDesc проверяет строгую сортировку по убыванию
func assertSortedByDateDesc(t *testing.T, items []Transaction) {
t.Helper()
for i := 1; i < len(items); i++ {
prev := items[i-1].CreatedAt
curr := items[i].CreatedAt
// Предыдущая запись обязана быть новее или равна текущей
require.Truef(t, prev.After(curr) || prev.Equal(curr),
"Нарушена сортировка: элемент [%d] (%v) старее элемента [%d] (%v)",
i-1, prev, i, curr)
}
}
func assertValidAgeRule(t *testing.T, resp UserResponse) {
t.Helper()
require.GreaterOrEqual(t, resp.Age, 18, "Сервис нарушил бизнес-правило: пользователь моложе 18 лет")
require.Less(t, resp.Age, 130, "Зафиксировано аномальное значение возраста")
}
3. Валидация Времени (Time Validation)
Временные параметры — наиболее распространенный источник ложноположительных падений в CI.
Единственно надежный инженерный подход — валидация попадания метки времени в допустимый интервал (Time Tolerance Window):
func assertRecentTimestamp(t *testing.T, actual time.Time) {
t.Helper()
now := time.Now().UTC() // Все проверки времени выполняются строго в UTC!
// 1. Проверяем, что метка времени не пришла из будущего (с запасом на сетевой рассинхрон)
require.True(t, actual.Before(now.Add(2*time.Second)),
"Временная метка created_at опережает текущее время сервера")
// 2. Проверяем, что событие произошло недавно (в пределах последних 5 секунд)
require.WithinDuration(t, now, actual, 5*time.Second,
"Временная метка created_at устарела относительно момента вызова")
}
Динамическая валидация JSON Schema
Когда ваш микросервис возвращает развесистые иерархические документы с десятками вложенных коллекций и полиморфных объектов, ручное написание ассертов превращает тест в непролазные заросли кода. В таких масштабах на помощь приходит промышленный стандарт JSON Schema (спецификация IETF).
JSON Schema позволяет декларативно описать схему полезной нагрузки: перечень обязательных полей, типы, форматы строк и допустимые диапазоны чисел.
В экосистеме Go стандартом является библиотека github.com/santhosh-tekuri/jsonschema/v5 (а также классическая библиотека github.com/xeipuuv/gojsonschema).
package api_test
import (
"encoding/json"
"testing"
"github.com/santhosh-tekuri/jsonschema/v5"
"github.com/stretchr/testify/require"
)
// Компилируем JSON-схему однократно при старте пакета
var userResponseSchema = jsonschema.MustCompileString("user_schema.json", `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["id", "email", "status", "age"],
"properties": {
"id": {
"type": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
},
"email": {
"type": "string",
"format": "email"
},
"status": {
"type": "string",
"enum": ["active", "pending", "banned"]
},
"age": {
"type": "integer",
"minimum": 18,
"maximum": 120
}
}
}`)
func TestAPI_CreateUser_JSONSchema(t *testing.T) {
t.Parallel()
// Получаем сырые байты ответа от HTTP-рекордера
bodyBytes := []byte(`{
"id": "e4eaaaf2-d142-11e1-b3e4-080027620cdd",
"email": "invalid-email-address",
"status": "active",
"age": 16
}`)
// Десериализуем в нетипизированный интерфейс для анализатора схемы
var parsedResponse any
err := json.Unmarshal(bodyBytes, &parsedResponse)
require.NoError(t, err)
// Валидируем документ против скомпилированной схемы
err = userResponseSchema.Validate(parsedResponse)
// При нарушении контракта валидатор выдаст исчерпывающий отчет:
// - "email" is not a valid email address
// - "age" must be greater than or equal to 18
require.NoError(t, err, "Полезная нагрузка ответа нарушает утвержденную JSON-схему")
}
Итог
- Синтаксис не равен смыслу: структурное совпадение типов
go-cmpне защищает от нарушения бизнес-правил и утечки поврежденных данных. - Форматы под контролем: валидируйте строковые форматы (UUID, хэши, email) через пакетные скомпилированные регулярные выражения
regexp.MustCompile. - Учитывайте природу времени: никогда не сравнивайте метки времени через прямое равенство; используйте функцию
require.WithinDurationс разумным окном погрешности в часовом поясе UTC. - Масштабируйтесь через JSON Schema: для сложных, сильно разветвленных контрактов используйте декларативную валидацию через
github.com/santhosh-tekuri/jsonschema/v5.
Мы научились всесторонне валидировать ответы собственного сервиса. Но как синхронизировать эти требования с внешним миром? Если фронтенд-команда или соседний микросервис рассчитывают на один формат, а бэкенд втайне обновил правила валидации — возникнет катастрофический рассинхрон. В финальной главе нашего подраздела мы разберем высший пилотаж взаимодействия сервисов — контрактное тестирование: 8. API contract testing.