Урок курса
Тестируем HTTP-обработчик с fake-store
Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и DockerHTTP-контракт POST /quota/consume: метод, путь и структура ответа
net/http сам по себе не знает ничего о квотах. Он принимает байты, парсит заголовки и передаёт управление зарегистрированному handler-у. Поэтому граница «правильный запрос» полностью определяется кодом handler-а: метод должен быть POST, путь — /quota/consume, тело — ровно один JSON-объект с полем bytes. Если что-то из этого нарушено, store трогать нельзя.
Для тестирования handler-а без сетевого стека используются два инструмента из net/http/httptest: NewRequest строит *http.Request с нужным методом, путём и телом прямо в памяти, а NewRecorder реализует http.ResponseWriter и накапливает записанный статус, заголовки и тело. После вызова handler.ServeHTTP(rec, req) инспектируем rec.Result().
func TestConsumeWrongMethod(t *testing.T) {
store := &fakeStore{q: Quota{Used: 7, Limit: 10}}
h := NewConsumeHandler(store)
req := httptest.NewRequest(http.MethodGet, "/quota/consume",
strings.NewReader(`{"bytes":3}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
if res.StatusCode != http.StatusMethodNotAllowed {
t.Fatalf("want 405, got %d", res.StatusCode)
}
if store.currentCalls != 0 || store.saveCalls != 0 {
t.Fatalf("store must not be called on wrong method: current=%d save=%d",
store.currentCalls, store.saveCalls)
}
}
Проверка фиксирует два факта: статус 405 и нулевые счётчики store. Только вместе они доказывают, что handler не обратился к хранилищу до проверки метода. Один статус не доказывает порядок: handler мог прочитать store, а потом вернуть 405.
Аналогично для неверного пути:
func TestConsumeWrongPath(t *testing.T) {
store := &fakeStore{q: Quota{Used: 0, Limit: 10}}
h := NewConsumeHandler(store)
req := httptest.NewRequest(http.MethodPost, "/quota/use",
strings.NewReader(`{"bytes":1}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
if res.StatusCode != http.StatusNotFound {
t.Fatalf("want 404, got %d", res.StatusCode)
}
var body struct{ Error string `json:"error"` }
json.NewDecoder(res.Body).Decode(&body)
if body.Error != "not_found" {
t.Fatalf("want not_found, got %q", body.Error)
}
if store.currentCalls != 0 || store.saveCalls != 0 {
t.Fatalf("store must not be called on wrong path: current=%d save=%d",
store.currentCalls, store.saveCalls)
}
}
Здесь дополнительно декодируется тело и проверяется "error": "not_found". Content-Type: application/json должен быть выставлен handler-ом на все ответы, включая ошибочные, — это часть контракта, не опциональная деталь. Если rec.Header().Get("Content-Type") не содержит application/json, тест должен упасть.
Важно понимать, что httptest.NewRecorder — не сетевой сервер. Он не проверяет HTTP-версию, не разрывает соединение и не буферизует ответ так, как это делает реальный net/http. Тест запускает handler синхронно в одном горутине и читает результат после возврата — никакого гонок здесь нет и никакой сети тоже.
Граница 404/405 должна срабатывать до любого обращения к store. Следующий барьер — валидация тела запроса.
Строгий JSON до вызова store.Current: DisallowUnknownFields и второй Decode
Декодер encoding/json по умолчанию игнорирует поля, которых нет в целевой структуре. Это удобно при версионировании API, но в нашем контракте — ошибка: неизвестное поле должно вернуть 400, а не быть молча проглочено. Поэтому используется dec.DisallowUnknownFields() перед первым Decode.
Второй вопрос — что идёт после единственного JSON-объекта. Если тело содержит {"bytes":3} {"bytes":2}, первый Decode успешно прочитает первый объект и остановится. Handler обязан убедиться, что дальше в теле ничего нет, кроме пробельных символов. Стандартный способ — второй Decode в io.EOF: если он вернул nil или что угодно кроме io.EOF, тело содержит лишнее, и запрос отклоняется со статусом 400.
Поле bytes должно быть строго положительным целым числом типа int64. Дробное значение 3.5 JSON-декодер с int64-целью отклоняет сам — он не округляет. Нулевое и отрицательное значения технически декодируются, но нарушают бизнес-условие и отклоняются явной проверкой после Decode.
var cases = []struct {
name string
body string
wantStatus int
}{
{"malformed", `{"bytes":`, 400},
{"unknown_field", `{"bytes":3,"x":1}`, 400},
{"trailing_value", `{"bytes":3}{"bytes":2}`, 400},
{"zero", `{"bytes":0}`, 400},
{"negative", `{"bytes":-1}`, 400},
{"fractional", `{"bytes":3.5}`, 400},
{"absent_bytes", `{}`, 400},
}
func TestConsumeValidation(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
store := &fakeStore{q: Quota{Used: 7, Limit: 10}}
h := NewConsumeHandler(store)
req := httptest.NewRequest(http.MethodPost, "/quota/consume",
strings.NewReader(tc.body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
if res.StatusCode != tc.wantStatus {
t.Fatalf("%s: want %d, got %d", tc.name, tc.wantStatus, res.StatusCode)
}
if store.currentCalls != 0 {
t.Fatalf("%s: store.Current called before validation passed", tc.name)
}
if store.saveCalls != 0 {
t.Fatalf("%s: store.Save called on invalid request", tc.name)
}
})
}
}
После каждого неверного запроса проверяются оба счётчика. Нулевой currentCalls — это и есть доказательство, что валидация завершилась до обращения к store. Без этой проверки тест мог бы пройти даже при реализации, которая сначала читает Current, а потом откатывается на 400.
// Дополнительная проверка тела ответа для одного кейса
func TestConsumeUnknownFieldBody(t *testing.T) {
store := &fakeStore{q: Quota{Used: 7, Limit: 10}}
h := NewConsumeHandler(store)
req := httptest.NewRequest(http.MethodPost, "/quota/consume",
strings.NewReader(`{"bytes":3,"extra":1}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
if ct := res.Header.Get("Content-Type"); !strings.Contains(ct, "application/json") {
t.Fatalf("want Content-Type application/json, got %q", ct)
}
var body struct{ Error string `json:"error"` }
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Error != "invalid_request" {
t.Fatalf("want invalid_request, got %q", body.Error)
}
}
Пробельные символы после единственного валидного объекта ({"bytes":3} \n) допустимы: второй Decode вернёт io.EOF через пробелы, и это корректный случай. Дублирующиеся ключи ({"bytes":3,"bytes":4}) и ограничение размера тела находятся за границей текущего контракта.
Важно: тест проверяет поведение конкретного входа на конкретном fake. Если реализация использует json.Unmarshal вместо потокового декодера — поведение при trailing value может отличаться. Контракт требует отклонять trailing value, и именно это проверяет кейс trailing_value выше.
Fake-store: счётчики, аргумент Save и состояние после успешного и отклонённого запроса
Минимальный fake для этого контракта содержит хранимую квоту, два счётчика и поле для последнего аргумента Save. Никакой мьютекс здесь не нужен: тест однопоточный, ServeHTTP возвращает управление синхронно.
type fakeStore struct {
q Quota
currentCalls int
saveCalls int
savedArg Quota
currentErr error
saveErr error
}
func (f *fakeStore) Current(_ context.Context) (Quota, error) {
f.currentCalls++
return f.q, f.currentErr
}
func (f *fakeStore) Save(_ context.Context, q Quota) error {
f.saveCalls++
f.savedArg = q
if f.saveErr != nil {
return f.saveErr
}
f.q = q
return nil
}
Когда Save получает инжектированную ошибку, он всё равно инкрементирует saveCalls и записывает savedArg, но не меняет f.q. Это позволяет в тесте проверить: был ли вызов сделан с правильным аргументом, даже если он завершился ошибкой.
Случай превышения лимита: {Used:7, Limit:10} плюс 4 байта. AllowUpload вернёт allowed=false. Handler должен ответить 409 и не вызывать Save.
func TestConsumeOverLimit(t *testing.T) {
store := &fakeStore{q: Quota{Used: 7, Limit: 10}}
h := NewConsumeHandler(store)
req := httptest.NewRequest(http.MethodPost, "/quota/consume",
strings.NewReader(`{"bytes":4}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
if res.StatusCode != http.StatusConflict {
t.Fatalf("want 409, got %d", res.StatusCode)
}
if store.currentCalls != 1 {
t.Fatalf("want currentCalls=1, got %d", store.currentCalls)
}
if store.saveCalls != 0 {
t.Fatalf("want saveCalls=0, got %d", store.saveCalls)
}
// состояние квоты не изменилось
if store.q != (Quota{Used: 7, Limit: 10}) {
t.Fatalf("quota state must not change on rejection: %+v", store.q)
}
}
currentCalls=1 подтверждает, что handler обратился к store ровно один раз. saveCalls=0 и неизменённое store.q вместе доказывают, что quota не была модифицирована.
Случай точного попадания в лимит: {Used:7, Limit:10} плюс 3 байта. Ожидаем статус 200, Save с аргументом {Used:10, Limit:10} и финальное состояние 10/10.
func TestConsumeExactLimit(t *testing.T) {
store := &fakeStore{q: Quota{Used: 7, Limit: 10}}
h := NewConsumeHandler(store)
req := httptest.NewRequest(http.MethodPost, "/quota/consume",
strings.NewReader(`{"bytes":3}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("want 200, got %d", res.StatusCode)
}
if ct := res.Header.Get("Content-Type"); !strings.Contains(ct, "application/json") {
t.Fatalf("Content-Type: got %q", ct)
}
var body struct {
Used int64 `json:"used"`
Limit int64 `json:"limit"`
}
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Used != 10 || body.Limit != 10 {
t.Fatalf("want {10,10}, got %+v", body)
}
if store.currentCalls != 1 || store.saveCalls != 1 {
t.Fatalf("want current=1 save=1, got current=%d save=%d",
store.currentCalls, store.saveCalls)
}
want := Quota{Used: 10, Limit: 10}
if store.savedArg != want {
t.Fatalf("Save got %+v, want %+v", store.savedArg, want)
}
if store.q != want {
t.Fatalf("final state %+v, want %+v", store.q, want)
}
}
Здесь пять независимых утверждений: статус, Content-Type, декодированный JSON, аргумент Save и финальное состояние fake. Каждое из них можно нарушить независимо: handler мог вернуть 200 без вызова Save, или вызвать Save с неверным аргументом, или вернуть в теле старое значение {7,10}. Тест ловит все эти варианты.
rec.Result() читается после того, как ServeHTTP вернулся — только тогда гарантированно записаны и статус, и заголовки, и тело. Body закрывается через defer сразу после получения res.
Ошибки store и граница доказательства fake-теста
Три случая с ошибками store проверяют разные позиции в цепочке операций. Каждый даёт статус 500, но счётчики и состояние различаются, и именно это делает тесты содержательными.
Ошибка Current: store возвращает ошибку на первом же вызове. Handler не имеет квоты, не вызывает AllowUpload и не вызывает Save.
func TestConsumeCurrentError(t *testing.T) {
store := &fakeStore{
q: Quota{Used: 7, Limit: 10},
currentErr: errors.New("db unavailable"),
}
h := NewConsumeHandler(store)
req := httptest.NewRequest(http.MethodPost, "/quota/consume",
strings.NewReader(`{"bytes":3}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
if res.StatusCode != http.StatusInternalServerError {
t.Fatalf("want 500, got %d", res.StatusCode)
}
if store.currentCalls != 1 {
t.Fatalf("want currentCalls=1, got %d", store.currentCalls)
}
if store.saveCalls != 0 {
t.Fatalf("want saveCalls=0, got %d", store.saveCalls)
}
}
Ошибка Save: Current прошёл, AllowUpload разрешил запись, но Save вернул ошибку. Handler должен ответить 500. saveCalls=1 и правильный savedArg доказывают, что вызов был сделан с корректным аргументом, но не завершился успешно. Финальное состояние fake остаётся 7/10 — Save в нашем fake не меняет f.q при ошибке.
func TestConsumeSaveError(t *testing.T) {
store := &fakeStore{
q: Quota{Used: 7, Limit: 10},
saveErr: errors.New("write failed"),
}
h := NewConsumeHandler(store)
req := httptest.NewRequest(http.MethodPost, "/quota/consume",
strings.NewReader(`{"bytes":3}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
if res.StatusCode != http.StatusInternalServerError {
t.Fatalf("want 500, got %d", res.StatusCode)
}
if store.currentCalls != 1 || store.saveCalls != 1 {
t.Fatalf("want 1/1, got %d/%d", store.currentCalls, store.saveCalls)
}
if store.savedArg != (Quota{Used: 10, Limit: 10}) {
t.Fatalf("Save arg: %+v", store.savedArg)
}
// Состояние не должно смениться на 10/10
if store.q != (Quota{Used: 7, Limit: 10}) {
t.Fatalf("state changed despite Save error: %+v", store.q)
}
}
Некорректная сохранённая квота (Used > Limit): Current вернул {11, 10}. AllowUpload возвращает исходную квоту (Quota{Used: 11, Limit: 10}, false) для такого состояния — но handler не должен интерпретировать это как отказ клиента. Инвариант хранилища нарушен, и правильный ответ — 500, currentCalls=1, saveCalls=0.
func TestConsumeInvalidStoredQuota(t *testing.T) {
store := &fakeStore{q: Quota{Used: 11, Limit: 10}}
h := NewConsumeHandler(store)
req := httptest.NewRequest(http.MethodPost, "/quota/consume",
strings.NewReader(`{"bytes":1}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
if res.StatusCode != http.StatusInternalServerError {
t.Fatalf("want 500, got %d", res.StatusCode)
}
if store.currentCalls != 1 || store.saveCalls != 0 {
t.Fatalf("want 1/0, got %d/%d", store.currentCalls, store.saveCalls)
}
}
Теперь о границах того, что эти тесты доказывают. Fake работает в одном процессе, детерминированно и без параллелизма. Это значит:
- Тест проверяет число вызовов
CurrentиSaveдля конкретного входа; одни счётчики не устанавливают их хронологический порядок. Для проверкиCurrent → Saveнужен журнал вызовов fake и отдельное сравнение этой последовательности. - Тест доказывает аргумент переданный в
Saveи финальное состояние fake после вызова. - Тест не доказывает долговечность: реальный store может записать данные и упасть до подтверждения.
- Тест не доказывает атомарность: между
CurrentиSaveв реальной системе возможен конкурентный запрос. - Тест не доказывает корректность при гонках:
fakeStoreне потокобезопасен, и запускать дваServeHTTPпараллельно на одном fake — некорректно.
Только статус 500 не доказывает отсутствие записи. Handler мог вызвать Save успешно, а затем вернуть 500 из-за ошибки сериализации ответа. Именно поэтому saveCalls проверяется явно для каждого случая: вместе с аргументом и состоянием fake он показывает, была ли попытка записи и как она завершилась в этой модели. Полный порядок вызовов требует отдельного журнала; приведённые счётчики его не доказывают.
Задача 1
Handler получает четыре запроса один за другим. Начальное состояние fake во всех случаях {Used:7, Limit:10}. Определите, какой набор итоговых (статус, currentCalls, saveCalls) верен для каждого запроса.
GET /quota/consume, тело{"bytes":3}POST /quota/use, тело{"bytes":3}POST /quota/consume, тело{"bytes":0}POST /quota/consume, тело{"bytes":3,"extra":1}
Задача 2
Fake инициализирован как {Used:7, Limit:10}. Handler получает два последовательных запроса (каждый раз с новым handler-ом и новым fake, сбрасывая состояние до начального).
- Запрос A:
POST /quota/consume, тело{"bytes":4} - Запрос B:
POST /quota/consume, тело{"bytes":3}
Какой вариант правильно описывает результаты обоих запросов, включая аргумент Save и финальное состояние fake?
Задача 3
Три теста используют один и тот же запрос POST /quota/consume {"bytes":3}, но разные конфигурации fake. Исходное состояние всегда {Used:7, Limit:10}, если не указано иное. Определите правильное описание статуса и счётчиков для каждого из трёх сценариев.
- Fake с инжектированной ошибкой в
Current(currentErr: errors.New("db unavailable")). - Fake с инжектированной ошибкой в
Save(saveErr: errors.New("write failed")). - Fake с начальным состоянием
{Used:11, Limit:10}и без инжектированных ошибок.
Задача 4
Что нужно реализовать
Напишите тело функции NewConsumeHandler(store QuotaStore) http.Handler. Обработчик принимает HTTP-запросы и управляет квотой через переданный store. Вспомогательная функция AllowUpload из урока 9.1 уже предоставлена в заголовке задания — повторно реализовывать арифметику не нужно.
Предоставленные типы
type Quota struct { Used int64; Limit int64 }
type QuotaStore interface {
Current(context.Context) (Quota, error)
Save(context.Context, Quota) error
}Метод Current возвращает текущую квоту по значению или ошибку, не изменяя состояние хранилища. Метод Save сохраняет переданную квоту; при nil-ошибке заменяет сохранённое значение, при ошибке оставляет состояние нетронутым. Оба метода принимают context.Context из запроса.
Маршрут и метод
Обработчик обслуживает только POST /quota/consume. Неверный путь возвращает 404 с {"error":"not_found"} до обращения к store. Неверный метод возвращает 405 с {"error":"method_not_allowed"} до обращения к store.
Тело запроса
Ожидается ровно один JSON-объект с единственным полем:
{"bytes": 3}Поле bytes имеет тип int64 и должно быть строго положительным. Все перечисленные ниже случаи возвращают 400 с {"error":"invalid_request"} до вызова Current:
- отсутствует поле
bytes - значение
bytesравно нулю или отрицательно - неверный JSON-тип поля (например, строка)
- неизвестные поля в объекте
- второе значение после объекта (кроме пробельных символов)
- некорректный JSON в целом
null вместо всего тела или значения bytes, дробные числа и целые числа вне диапазона int64 также дают 400 с {"error":"invalid_request"} до вызова Current или Save.
Завершающие пробельные символы после объекта допустимы и не считаются ошибкой.
Порядок операций
- Проверить путь и метод.
- Декодировать и полностью провалидировать тело запроса.
- Вызвать
Currentровно один раз. - Если
Currentвернул ошибку или невалидную квоту (Used < 0либоUsed > Limit) — вернуть 500. - Вызвать
AllowUploadровно один раз. - Если квота превышена (
allowed == false) — вернуть 409,Saveне вызывать. - Вызвать
Saveровно один раз с новой квотой. - Если
Saveвернул ошибку — вернуть 500. - После успешного
Save— вернуть 200 с JSON новой квоты.
Формат ответов
Все ответы — Content-Type: application/json. Успешный ответ:
{"used": 10, "limit": 10}Ошибочные ответы: {"error": "..."} — коды и значения строго фиксированы контрактом.
Каждая ветвь со статусом 500 — ошибка Current, некорректная сохранённая квота или ошибка Save — возвращает {"error":"internal"} с Content-Type: application/json.
Примеры
Пример 1. Хранилище содержит Used=7, Limit=10; запрос {"bytes":3}. Ожидается 200:
{"used": 10, "limit": 10}Пример 2. Хранилище содержит Used=7, Limit=10; запрос {"bytes":4}. Ожидается 409:
{"error": "quota_exceeded"}Пример 3. Тело запроса {"bytes":"три"} — строка вместо числа. Ожидается 400, Current не вызывается:
{"error": "invalid_request"}Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.
Отправьте решение, чтобы увидеть результаты тестов.
Продолжить с сохранением прогресса
Перейдите к уроку в учебном кабинете.
