4. Тестирование REST API
Сборка пазла: Тестирование REST-ресурсов
В предыдущих главах 2. Тестирование handler функций и 3. Middleware тестирование мы исследовали архитектуру веб-приложения под микроскопом: изолированно препарировали отдельные функции-обработчики и цепочки промежуточного ПО. Однако архитектурный стиль REST (Representational State Transfer) мыслит не разрозненными функциями, а целостными Ресурсами, доступ к которым организуется через пересечение канонических URL-путей и семантических глаголов HTTP (GET, POST, PUT, DELETE).
Главная опасность модульного тестирования хэндлеров в вакууме заключается в том, что оно полностью исключает из уравнения маршрутизатор (Router / Multiplexer).
Представьте типичную драму: ваш модульный тест напрямую вызывает функцию GetUserHandler. Тест триумфально зеленеет. Но в конфигурационном коде роутера разработчик допустил банальную опечатку:
mux.HandleFunc("GET /user/{id}", ...) // Написали "user" в единственном числе вместо "users"
В результате клиент, отправляющий запрос на канонический путь /users/{id}, наталкивается в продакшене на холодную стену статуса 404 Not Found. Модульный тест был безупречен, но система сломана.
Чтобы исключить подобные аварии, мы обязаны тестировать весь REST-ресурс в связке с маршрутизатором, направляя синтетические запросы непосредственно в сетевой мультиплексор.
Mechanical Sympathy: Как работает роутер
На протяжении многих лет стандартный мультиплексор http.ServeMux в Go обладал спартанской функциональностью: он умел сопоставлять лишь префиксы путей. Из-за этого индустрия была вынуждена повсеместно прибегать к сторонним библиотекам маршрутизации — chi или gorilla/mux.
Начиная с версии Go 1.22, стандартная библиотека совершила долгожданный технологический скачок: http.ServeMux получил нативную поддержку HTTP-глаголов (GET, POST) и подстановочных параметров пути (Wildcards).
flowchart TD
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
subgraph ClientRequest["Входящий запрос"]
Req["GET /users/42"]:::process
end
subgraph Router["http.ServeMux (Radix Tree Matcher)"]
Tree["Корень префиксного дерева"]:::process
UsersNode["Узел: /users/"]:::process
Wildcard["Параметр: {id} -> '42'"]
Tree --> UsersNode --> Wildcard
end
subgraph Target["Маршрутизация"]
Match["Метод: GET -> Вызов UsersAPI.getUser"]:::process
MismatchMethod["Метод: POST -> 405 Method Not Allowed"]:::process
MismatchPath["Путь: /posts -> 404 Not Found"]:::error
end
Req --> Tree
Wildcard --> Match
UsersNode -.->|"Не тот метод"| MismatchMethod
Tree -.->|"Путь отсутствует"| MismatchPath
Паттерн "Resource Controller"
Для поддержания высокой читаемости и удобства тестирования обработчики ресурсов объединяют в структуры-контроллеры (часто именуемые Resource API), инкапсулирующие регистрацию связанных маршрутов:
package api
import (
"encoding/json"
"net/http"
)
// UserService изолирует бизнес-логику работы с пользователями
type UserService interface {
GetByID(id string) (string, error)
Delete(id string) error
}
// UsersAPI группирует эндпоинты ресурса пользователей
type UsersAPI struct {
svc UserService
}
func NewUsersAPI(svc UserService) *UsersAPI {
return &UsersAPI{svc: svc}
}
// RegisterRoutes регистрирует маршруты ресурса в мультиплексоре (синтаксис Go 1.22+)
func (api *UsersAPI) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /users/{id}", api.getUser)
mux.HandleFunc("DELETE /users/{id}", api.deleteUser)
}
func (api *UsersAPI) getUser(w http.ResponseWriter, r *http.Request) {
// Извлекаем wildcard-параметр из контекста маршрута
id := r.PathValue("id")
if id == "" {
http.Error(w, "missing id", http.StatusBadRequest)
return
}
name, err := api.svc.GetByID(id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{
"id": id,
"name": name,
})
}
func (api *UsersAPI) deleteUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := api.svc.Delete(id); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// 204 No Content по стандарту не содержит тела ответа
w.WriteHeader(http.StatusNoContent)
}
Тестирование маршрутизатора через ResponseRecorder
Напишем исчерпывающий компонентный тест для REST-ресурса. Мы инициализируем реальный экземпляр http.ServeMux, регистрируем в нем наш UsersAPI с моком сервисного слоя и направляем запросы httptest.NewRequest напрямую в мультиплексор.
package api_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"yourproject/internal/api"
"yourproject/internal/mocks"
)
// setupRouter конструирует изолированный роутер для теста
func setupRouter(t *testing.T) (*http.ServeMux, *mocks.MockUserService) {
t.Helper()
ctrl := gomock.NewController(t)
mockSvc := mocks.NewMockUserService(ctrl)
mux := http.NewServeMux()
usersAPI := api.NewUsersAPI(mockSvc)
usersAPI.RegisterRoutes(mux) // Регистрируем маршруты в реальном роутере!
return mux, mockSvc
}
func TestUsersAPI_REST(t *testing.T) {
t.Parallel()
type testCase struct {
name string
method string
targetURL string
setupMock func(m *mocks.MockUserService)
expectedStatus int
expectedJSON string
}
testCases := []testCase{
{
name: "GET /users/{id} - Успешное получение",
method: http.MethodGet,
targetURL: "/users/42", // Проверяем реальный URL с подстановкой параметра
setupMock: func(m *mocks.MockUserService) {
// Убеждаемся, что роутер корректно передал параметр "42" в PathValue
m.EXPECT().GetByID("42").Return("Gopher", nil).Times(1)
},
expectedStatus: http.StatusOK,
expectedJSON: `{"id": "42", "name": "Gopher"}`,
},
{
name: "DELETE /users/{id} - Успешное удаление",
method: http.MethodDelete,
targetURL: "/users/99",
setupMock: func(m *mocks.MockUserService) {
m.EXPECT().Delete("99").Return(nil).Times(1)
},
expectedStatus: http.StatusNoContent,
expectedJSON: "", // Для 204 No Content тело отсутствует
},
{
name: "GET /users/ - Запрос с завершающим слэшем без ID",
method: http.MethodGet,
targetURL: "/users/",
setupMock: func(m *mocks.MockUserService) {}, // Мок не должен вызываться
expectedStatus: http.StatusNotFound, // Маршрутизатор не найдет шаблон
expectedJSON: "",
},
{
name: "POST /users/42 - Неподдерживаемый HTTP-метод",
method: http.MethodPost,
targetURL: "/users/42",
setupMock: func(m *mocks.MockUserService) {},
expectedStatus: http.StatusMethodNotAllowed, // 405 Method Not Allowed генерируется роутером
expectedJSON: "",
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
mux, mockSvc := setupRouter(t)
tc.setupMock(mockSvc)
// Создаем запрос, адресованный РОУТЕРУ, а не изолированной функции
req := httptest.NewRequest(tc.method, tc.targetURL, nil)
rec := httptest.NewRecorder()
// Передаем управление в ServeMux
mux.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
require.Equal(t, tc.expectedStatus, res.StatusCode)
if tc.expectedJSON != "" {
require.JSONEq(t, tc.expectedJSON, rec.Body.String())
}
})
}
}
Сравнение с E2E HTTP тестированием
Может возникнуть закономерный вопрос: чем предложенный подход отличается от интеграционных проверок, рассмотренных нами в 8. HTTP integration тесты?
Различие кроется в границах изоляции и скорости обратной связи:
- REST API компонентные тесты (текущая глава):
Мы применяемhttptest.NewRecorderи мокируем слой предметной логики (UserService). Тест выполняется за микросекунды в оперативной памяти без системных вызовов к сети и базам данных. Это дает возможность протестировать все комбинации HTTP-заголовков, статусы405, сбои сериализации и граничные URL-пути. - Сквозные E2E тесты:
Мы разворачиваем реальный серверhttptest.NewServer, подключаем контейнеры с PostgreSQL через Testcontainers и проверяем сквозной маршрут сквозь все слои до дисковых транзакций.
Зрелый проект обязан сочетать оба подхода: компонентные тесты роутера закрывают вариативность сетевого протокола, а сквозные тесты подтверждают физическую работоспособность хранилищ данных.
Итог
Тестирование REST-ресурсов должно производиться на уровне маршрутизатора, а не изолированных функций-обработчиков:
- Это исключает опечатки в шаблонах путей и гарантирует корректную работу алгоритмов сопоставления Radix Tree.
- Проверяется извлечение параметров пути через
r.PathValue("id"). - Валидируется протокольное поведение мультиплексора: возврат
405 Method Not Allowedс заголовкомAllow: GET, HEADи обработка завершающих слэшей (/usersvs/users/).
Однако современная микросервисная разработка давно вышла за рамки одного лишь REST API. В распределенных highload-системах стандартом де-факто межсервисного обмена стал бинарный протокол gRPC. В следующей лекции мы детально разберем организацию тестирования контрактов gRPC: 5. Тестирование gRPC.