09 авг. 2025 г.·6 мин

Тестирование REST-хендлеров на Go: httptest и таблично-ориентированные тесты

Тестирование REST-хендлеров на Go с помощью httptest и таблично-ориентированных случаев даёт повторяемый способ проверить аутентификацию, валидацию, коды статуса и крайние случаи до релиза.

Тестирование REST-хендлеров на Go: httptest и таблично-ориентированные тесты

Что нужно проверить перед релизом

REST-хендлер может скомпилироваться и пройти быструю ручную проверку, но всё равно сломаться в продакшене. Большинство ошибок — не синтаксические. Это проблемы контракта: хендлер принимает то, что должен отклонять, возвращает неправильный статус или «проливает» детали ошибки.

Ручное тестирование полезно, но легко пропустить крайние случаи и регрессии. Вы проверите «счастливый» путь, может быть одну очевидную ошибку, и пойдёте дальше. Затем небольшое изменение в валидации или middleware тихо нарушит поведение, которое вы считали стабильным.

Цель тестов хендлеров проста: сделать обещания хендлера повторяемыми. Сюда входят правила аутентификации, валидация входных данных, предсказуемые коды статуса и тела ошибок, на которые клиенты могут опираться.

Пакет Go httptest отлично подходит, потому что вы можете вызывать хендлер напрямую без запуска реального сервера. Вы собираете HTTP-запрос, передаёте его хендлеру и проверяете тело ответа, заголовки и код статуса. Тесты остаются быстрыми, изолированными и простыми для запуска при каждом коммите.

Перед релизом вы должны знать (не надеяться), что:

  • Поведение аутентификации последовательно для отсутствующих токенов, неверных токенов и неправильных ролей.
  • Входы валидируются: обязательные поля, типы, диапазоны и (если вы требуете) неизвестные поля.
  • Коды статуса соответствуют контракту (например, 401 vs 403, 400 vs 422).
  • Ответы об ошибках безопасны и согласованы (нет стек-трейсов, одна и та же форма каждый раз).
  • Обработаны не-счастливые пути: таймауты, ошибки downstream и пустые результаты.

Эндпоинт «Создать тикет» может работать, когда вы отправляете идеальный JSON как админ. Тесты ловят то, что вы забыли проверить: истёкший токен, дополнительное поле, которое клиент случайно прислал, отрицательный приоритет или разницу между «не найдено» и «внутренняя ошибка», когда зависимость падает.

Опишите контракт для каждого эндпоинта

Запишите, что хендлер обещает делать, прежде чем писать тесты. Ясный контракт держит тесты сфокусированными и не даёт им превращаться в догадки о том, что код "имел в виду". Он также делает рефакторинг безопаснее: вы можете менять реализацию, не меняя поведение.

Начните с входов. Будьте конкретны, откуда берётся каждое значение и что является обязательным. Эндпоинт может брать id из пути, limit из query, заголовок Authorization и JSON- тело. Укажите важные правила: допустимые форматы, min/max значения, обязательные поля и что происходит, если чего-то не хватает.

Затем определите выходы. Не останавливайтесь на «возвращает JSON». Решите, как выглядит успех, какие заголовки важны и как выглядят ошибки. Если клиенты зависят от стабильных кодов ошибок и предсказуемой JSON-формы, рассматривайте это как часть контракта.

Практический чек-лист:

  • Входы: значения из пути/query, обязательные заголовки, поля JSON и правила валидации
  • Выходы: код статуса, заголовки ответа, форма JSON для успеха и ошибки
  • Побочные эффекты: какие данные меняются и что создаётся
  • Зависимости: вызовы БД, внешние сервисы, текущее время, сгенерированные ID

Также решите, где заканчиваются тесты хендлера. Хендлер-тесты сильнее всего на HTTP-границе: аутентификация, парсинг, валидация, коды статуса и тела ошибок. Более глубинные вещи переносите в интеграционные тесты: реальные запросы в БД, сетевые вызовы и полное роутинг-окружение.

Если ваш бэкенд генерируется (например, AppMaster (appmaster.io) производит Go-хендлеры и бизнес-логику), подход «сначала контракт» ещё полезнее. Вы сможете регенерировать код и всё равно проверять, что публичное поведение каждого эндпоинта осталось тем же.

Настройте минимальный httptest-харнес

Хороший тест хендлера должен ощущаться как реальный запрос, но без запуска сервера. В Go это обычно значит: собрать запрос с httptest.NewRequest, поймать ответ с httptest.NewRecorder и вызвать ваш хендлер.

Вызов хендлера напрямую даёт быстрые, сфокусированные тесты. Это идеально, когда вы хотите проверить внутри хендлера: проверки аутентификации, правила валидации, коды статуса и тела ошибок. Использование роутера в тестах полезно, когда контракт зависит от параметров пути, сопоставления маршрутов или порядка middleware. Начните с прямых вызовов и добавляйте роутер только при необходимости.

Заголовки важнее, чем многие думают. Отсутствие Content-Type может изменить то, как хендлер читает тело. Устанавливайте ожидаемые заголовки в каждом случае, чтобы ошибки указывали на логику, а не на настройки теста.

Вот минимальный шаблон, который можно переиспользовать:

req := httptest.NewRequest(http.MethodPost, "/v1/widgets", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
rec := httptest.NewRecorder()

handler.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()

Чтобы утверждения были последовательными, полезно иметь маленький хелпер для чтения и декодирования тела ответа. В большинстве тестов сначала проверяйте код статуса (чтобы ошибки было легко просматривать), потом ключевые заголовки (часто Content-Type), затем тело.

Если ваш бэкенд генерируется (включая Go-бэкенд, созданный AppMaster (appmaster.io)), этот харнес всё равно применим. Вы тестируете HTTP-контракт, на который зависят пользователи, а не стиль кода за ним.

Проектируйте таблично-ориентированные кейсы, которые остаются читабельными

Табличные тесты работают лучше, когда каждый кейс читается как маленькая история: какой запрос вы отправляете и что ожидаете в ответ. Вы должны уметь просканировать таблицу и понять покрытие, не прыгая по файлу.

Хороший кейс обычно содержит: ясное имя, запрос (метод, путь, заголовки, тело), ожидаемый код статуса и проверку ответа. Для JSON-тел указывайте несколько стабильных полей (например, код ошибки), вместо того чтобы сравнивать весь JSON как строку, если только контракт не требует строгого совпадения.

Простая форма кейса, которую можно переиспользовать

Держите структуру кейса фокусированной. Вставляйте одноразовую подготовку в хелперы, чтобы таблица оставалась компактной.

type tc struct {
	name       string
	method     string
	path       string
	headers    map[string]string
	body       string
	wantStatus int
	wantBody   string // substring or compact JSON
}

Для разных входов используйте короткие строки тела, которые сразу показывают отличие: валидная нагрузка, пропущенное поле, неверный тип и пустая строка. Избегайте громоздкого форматирования JSON в таблице — оно быстро становится шумом.

Когда вы видите повторяющуюся подготовку (создание токена, общие заголовки, тело по умолчанию), вынесите это в хелперы вроде newRequest(tc) или baseHeaders().

Если одна таблица начинает смешивать слишком много идей, разделите её. Одна таблица для успешных путей и другая для ошибок часто проще читать и отлаживать.

Проверки аутентификации: кейсы, которые обычно пропускают

Сначала моделируйте данные
Проектируйте схемы PostgreSQL в Data Designer, а затем генерируйте готовые к продакшену сервисы.
Моделировать данные

Тесты аутентификации часто выглядят нормально для счастливого пути, но ломаются в продакшене, потому что один «маленький» кейс не был покрыт. Рассматривайте аутентификацию как контракт: что клиент отправляет, что сервер возвращает и что никогда нельзя раскрывать.

Начните с наличия и валидности токена. Защищённый эндпоинт должен вести себя по-разному, когда заголовок отсутствует и когда он есть, но неверен. Если вы используете короткоживущие токены, протестируйте истечение срока, даже если вы симулируете это, подставляя валидатор, который возвращает «истёк».

Большинство пробелов покрываются этими кейсами:

  • Нет заголовка Authorization -> 401 с устойчивым ответом об ошибке
  • Неверный формат заголовка (неправильный префикс) -> 401
  • Неверный токен (плохая подпись) -> 401
  • Истёкший токен -> 401 (или ваш выбранный код) с предсказуемым сообщением
  • Валидный токен, но неправильная роль/разрешения -> 403

Разделение 401 и 403 важно. Используйте 401, когда вызывающий не аутентифицирован. Используйте 403, когда аутентификация пройдена, но доступ запрещён. Если вы их смешиваете, клиенты будут лишний раз пытаться или показывать неправильный UI.

Проверки ролей также недостаточны для эндпоинтов «принадлежащих пользователю» (например, GET /orders/{id}). Тестируйте владение: пользователь A не должен видеть заказ пользователя B даже при валидном токене. Это должен быть аккуратный 403 (или 404, если вы намеренно скрываете существование), и тело не должно ничего раскрывать. Держите ошибку общей. Не намекайте, что «заказ принадлежит пользователю 42».

Правила для входных данных: валидируйте, отвергайте и объясняйте ясно

Многие баги до релиза связаны с входами: отсутствующие поля, неверные типы, неожиданные форматы или слишком большие полезные нагрузки.

Назовите каждый вход, который принимает хендлер: поля тела JSON, query-параметры и path-параметры. Для каждого решите, что происходит, если он отсутствует, пуст, некорректен или выходит за пределы. Затем напишите кейсы, которые доказывают, что хендлер отклоняет плохие входы на ранней стадии и всегда возвращает один и тот же тип ошибки.

Небольшой набор валидационных кейсов покрывает большинство рисков:

  • Обязательные поля: отсутствуют vs пустая строка vs null (если вы允许 null)
  • Типы и форматы: число vs строка, email/date/UUID-форматы, парсинг boolean
  • Ограничения по размеру: max length, max items, payload слишком большой
  • Неизвестные поля: игнорируются vs отклоняются (если вы требуете строгого декодирования)
  • Query и path-параметры: отсутствуют, не парсятся и поведение по умолчанию

Пример: хендлер POST /users принимает { "email": "...", "age": 0 }. Протестируйте email отсутствует, email как 123, email как "not-an-email", age как -1 и age как "20". Если вы требуете строгий JSON, также протестируйте { "email":"[email protected]", "extra":"x" } и подтвердите, что это падает.

Делайте сбои валидации предсказуемыми. Выберите код статуса для ошибок валидации (кто-то использует 400, кто-то 422) и держите форму тела ошибки согласованной. Тесты должны проверять и статус, и сообщение (или поле details), указывающее, какое поле не прошло проверку.

Коды статуса и тела ошибок: сделайте их предсказуемыми

Стандартизируйте ответы об ошибках
Задайте стандартную JSON-форму ошибок по всем сервисам и сохраняйте её стабильной в процессе итераций.
Создать проект

Тесты хендлеров проще, когда ошибки API скучны и согласованы. Вы хотите, чтобы каждой ошибке соответствовал ясный код статуса и одна и та же JSON-форма, независимо от автора хендлера.

Начните с небольшой, согласованной карты типов ошибок на HTTP-статусы:

  • 400 Bad Request: некорректный JSON, отсутствие обязательных query-параметров
  • 404 Not Found: ресурс с таким ID не найден
  • 409 Conflict: уникальное ограничение или конфликт состояний
  • 422 Unprocessable Entity: валидный JSON, но бизнес-правила не пройдены
  • 500 Internal Server Error: неожиданные сбои (БД упала, nil pointer, внешняя ошибка)

Держите тело ошибки стабильным. Даже если текст сообщения потом поменяется, клиенты должны опираться на предсказуемые поля:

{ "code": "user_not_found", "message": "User was not found", "details": { "id": "123" } }

В тестах проверяйте форму, а не только строку статуса. Частая ошибка — возвращать HTML, plain text или пустое тело при ошибке, что ломает клиентов и скрывает баги.

Также тестируйте заголовки и кодировку ответов об ошибках:

  • Content-Typeapplication/json (и charset, если вы его задаёте)
  • Тело — валидный JSON даже при ошибках
  • Есть поля code, message и details (details может быть пустым, но не случайным)
  • Паники и неожиданные ошибки возвращают безопасный 500 без утечки стек-трейсов

Если вы добавляете middleware для recover, включите тест, который заставляет паниковать, и убедитесь, что вы всё равно получаете чистый JSON-ответ об ошибке.

Краевые случаи: сбои, время и не-счастливые пути

Выпускайте предсказуемые REST-эндпоинты
Генерируйте хендлеры, валидацию и ответы об ошибках, которые можно тестировать с помощью httptest с первого дня.
Создать API

Тесты счастливого пути доказывают, что хендлер работает хотя бы один раз. Тесты крайних случаев доказывают, что он продолжит работать, когда мир станет неприятным.

Принудительно заставьте зависимости падать предсказуемо. Если хендлер вызывает базу данных, кэш или внешний API, вы хотите увидеть, что происходит, когда эти слои возвращают ошибки, которые вы не контролируете.

Эти сценарии стоит симулировать хотя бы раз для каждого эндпоинта:

  • Таймаут от downstream-вызова (context deadline exceeded)
  • Not found от хранилища, когда клиент ожидал данные
  • Нарушение уникального ограничения при создании (duplicate email, duplicate slug)
  • Сетевая или транспортная ошибка (connection refused, broken pipe)
  • Неожиданная внутренняя ошибка (общая «что-то пошло не так»)

Держите тесты стабильными, контролируя всё, что может меняться между запусками. Флаповый тест хуже его отсутствия, потому что обучает людей игнорировать падающие проверки.

Сделайте время и случайности предсказуемыми

Если хендлер использует time.Now(), ID или случайные значения, инжектируйте их. Передайте функцию часов и генератор ID в хендлер или сервис. В тестах возвращайте фиксированные значения, чтобы можно было проверять точные поля JSON и заголовки.

Используйте маленькие фейки и проверяйте «отсутствие побочных эффектов»

Предпочитайте мелкие фейки или стабы вместо громоздких моков. Фейк может записывать вызовы и позволять вам утверждать, что ничего не произошло после ошибки.

Например, в хендлере «создать пользователя», если вставка в БД падает из-за уникального ограничения, проверьте код статуса, стабильность тела ошибки и то, что никому не отправлено приветственное письмо. Ваш фейковый mailer может иметь счётчик (sent=0), чтобы путь ошибки доказал отсутствие побочных эффектов.

Частые ошибки, делающие тесты ненадёжными

Тесты хендлеров часто падают по неправильной причине. Запрос, который вы собираете в тесте, не совпадает с тем, что шлёт реальный клиент. Это приводит к шумным ошибкам и ложной уверенности.

Одна распространённая проблема — отправка JSON без заголовков, которые ожидает хендлер. Если ваш код проверяет Content-Type: application/json, его отсутствие может заставить хендлер пропустить декодирование JSON, вернуть другой код или пойти по ветке, которой в продакшене не бывает. То же и с аутентификацией: отсутствие Authorization — не то же самое, что неверный токен. Это разные кейсы.

Ещё одна ловушка — сравнивать весь JSON-ответ как сырой строкой. Небольшие изменения, вроде порядка полей, пробелов или новых полей, ломают тесты, даже когда API корректен. Декодируйте тело в структуру или map[string]any, затем проверяйте важное: статус, код ошибки, сообщение и пару ключевых полей.

Тесты также становятся ненадёжными, когда кейсы делят изменяемое состояние. Повторное использование одной и той же in-memory-базы, глобальных переменных или синглтон-роутера между строками таблицы может протекать данные между кейсами. Каждый тестовый кейс должен стартовать чистым или сбрасывать состояние в t.Cleanup.

Шаблоны, которые обычно делают тесты хрупкими:

  • Создание запросов без тех же заголовков и кодировки, которые используют реальные клиенты
  • Проверка полного JSON как строки вместо декодирования и проверки полей
  • Повторное использование общей БД/кэша/глобального состояния между кейсами
  • Смешивание проверок аутентификации, валидации и бизнес-логики в одном огромном тесте

Держите каждый тест сфокусированным. Если кейс падает, вы должны понять, это аутентификация, правила входа или формат ошибок, за секунды.

Быстрый предрелизный чек-лист, который можно переиспользовать

Деплой туда, где вы запускаете
Размещайте приложения в AppMaster Cloud, AWS, Azure или Google Cloud, когда будете готовы.
Развернуть приложение

Перед отправкой в продакшен тесты должны доказать два момента: эндпоинт следует контракту и он падает безопасно и предсказуемо.

Прогоните это таблично и заставьте каждый кейс проверять и ответ, и побочные эффекты:

  • Аутентификация: нет токена, плохой токен, неправильная роль, корректная роль (и убедитесь, что кейс с неправильной ролью не раскрывает детали)
  • Входы: отсутствующие обязательные поля, неверные типы, граничные размеры (min/max), неизвестные поля, которые вы хотите отклонять
  • Выходы: код статуса, ключевые заголовки (например, Content-Type), обязательные поля JSON, согласованная форма ошибок
  • Зависимости: принудите одну downstream-ошибку (БД, очередь, платёж, email), проверьте безопасное сообщение и отсутствие частичных записей
  • Идемпотентность: повторите тот же запрос (или повтор после таймаута) и подтвердите, что не создаются дубликаты

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

Если вы строите API с инструментом вроде AppMaster (appmaster.io), этот чек-лист по-прежнему применим. Суть одна: зафиксировать публичное поведение, от которого зависят клиенты.

Пример: один эндпоинт, маленькая таблица и что она ловит

Предположим, у вас простой эндпоинт: POST /login. Он принимает JSON с email и password. Возвращает 200 с токеном при успехе, 400 за неправильный ввод, 401 при неверных учётных данных и 500, если сервис аутентификации недоступен.

Компактная таблица вроде этой покрывает большинство проблем, которые ломают продакшен.

func TestLoginHandler(t *testing.T) {
	// Fake dependency so we can force 200/401/500 without hitting real systems.
	auth := &FakeAuth{ /* configure per test */ }
	h := NewLoginHandler(auth)

	tests := []struct {
		name       string
		body       string
		authHeader string
		setup      func()
		wantStatus int
		wantBody   string
	}{
		{"success", `{"email":"[email protected]","password":"secret"}`, "", func() { auth.Mode = "ok" }, 200, `"token"`},
		{"missing password", `{"email":"[email protected]"}`, "", func() { auth.Mode = "ok" }, 400, "password"},
		{"bad email format", `{"email":"not-an-email","password":"secret"}`, "", func() { auth.Mode = "ok" }, 400, "email"},
		{"invalid JSON", `{`, "", func() { auth.Mode = "ok" }, 400, "invalid JSON"},
		{"unauthorized", `{"email":"[email protected]","password":"wrong"}`, "", func() { auth.Mode = "unauthorized" }, 401, "unauthorized"},
		{"server error", `{"email":"[email protected]","password":"secret"}`, "", func() { auth.Mode = "error" }, 500, "internal"},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			tt.setup()
			req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(tt.body))
			req.Header.Set("Content-Type", "application/json")
			if tt.authHeader != "" {
				req.Header.Set("Authorization", tt.authHeader)
			}

			rr := httptest.NewRecorder()
			h.ServeHTTP(rr, req)

			if rr.Code != tt.wantStatus {
				t.Fatalf("status = %d, want %d, body=%s", rr.Code, tt.wantStatus, rr.Body.String())
			}
			if tt.wantBody != "" && !strings.Contains(rr.Body.String(), tt.wantBody) {
				t.Fatalf("body %q does not contain %q", rr.Body.String(), tt.wantBody)
			}
		})
	}
}

Пройдите один кейс от конца до конца: для «missing password» вы отправляете тело с только email, устанавливаете Content-Type, запускаете через ServeHTTP, затем проверяете 400 и ошибку, которая ясно указывает на password. Этот один кейс доказывает, что декодер, валидатор и формат ответа об ошибке работают вместе.

Если вы хотите быстрее стандартизировать контракты, модули аутентификации и интеграции при сохранении «реального» Go-кода, AppMaster (appmaster.io) создан для этого. Даже тогда эти тесты остаются ценными, потому что они закрепляют поведение, от которого зависят ваши клиенты.

Легко начать
Создай что-то невероятное

Экспериментируйте с AppMaster с бесплатной подпиской.
Как только вы будете готовы, вы сможете выбрать подходящий платный план.

Попробовать AppMaster