Урок курса

Тестируем HTTP-обработчик с fake-store

Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker

HTTP-контракт 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) верен для каждого запроса.

  1. GET /quota/consume, тело {"bytes":3}
  2. POST /quota/use, тело {"bytes":3}
  3. POST /quota/consume, тело {"bytes":0}
  4. 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}, если не указано иное. Определите правильное описание статуса и счётчиков для каждого из трёх сценариев.

  1. Fake с инжектированной ошибкой в Current (currentErr: errors.New("db unavailable")).
  2. Fake с инжектированной ошибкой в Save (saveErr: errors.New("write failed")).
  3. 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"}
РешениеGo

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Продолжить с сохранением прогресса

Перейдите к уроку в учебном кабинете.

Перейти к интерактивному уроку