MBL
Go / avito / Повторение / net/http, encoding/json, testing
Go сложный

net/http, encoding/json, testing

stdlibtesting

Три кита стандартной библиотеки, которые проверяют почти на каждом бэкенд-собесе: net/http изнутри, encoding/json с его ловушками и идиомы testing.

net/http: Handler, HandlerFunc, ServeMux, middleware

В Go нет ключевого слова implements — интерфейс это просто список требований к методам, и тип автоматически ему удовлетворяет, если у него есть нужные методы (структурная типизация, без явного объявления "я реализую этот интерфейс"). http.Handler требует ровно один метод: ServeHTTP(w, r). Значит, чтобы стать хендлером, типу достаточно иметь такой метод — необязательно писать функцию именно в форме func(w, r).

Отсюда возникает практический вопрос: если у нас уже есть обычная функция func(w, r), а не тип с методом ServeHTTP, как превратить её в Handler? HandlerFunc — это ответ, и его механизм стоит увидеть буквально:

type HandlerFunc func(http.ResponseWriter, *http.Request)

func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	f(w, r) // просто вызывает саму себя как функцию
}

type HandlerFunc func(w, r) объявляет ИМЕНОВАННЫЙ тип-функцию — а у именованных типов, в отличие от голого func(...), можно объявлять методы. Дальше на этом типе объявляется метод ServeHTTP, тело которого — просто вызов f(w, r). В результате любую обычную функцию с подходящей сигнатурой можно превратить в Handler простым приведением типа: HandlerFunc(myFunc). mux.HandleFunc(...) под капотом делает именно это автоматически, поэтому в коде почти никогда не приходится писать HandlerFunc(...) руками.

Middleware — функция, принимающая Handler и возвращающая новый Handler, который оборачивает вызов next.ServeHTTP своей логикой до и после. При нескольких вложенных middleware порядок выполнения работает как матрёшка: код ДО next выполняется в порядке снаружи внутрь, а код ПОСЛЕ next — в обратном порядке, изнутри наружу.

на play.golang.org

Вывод показывает порядок ровно так, как описано: "до next" печатается первым (снаружи), потом сам хендлер (внутри), потом "после next" (снова снаружи). Если бы middleware было два (например, withLogging(withAuth(hello))), порядок был бы: код "до" у withLogging, код "до" у withAuth, сам хендлер, код "после" у withAuth, код "после" у withLogging — как вложенные скобки.

Про сам обмен данными: r *http.Request предназначен только для чтения (r.Method, r.URL.Path, r.Header.Get(...), r.Body), а w http.ResponseWriter — только для записи, причём порядок операций строгий: сначала выставляются заголовки через w.Header().Set(...), потом вызывается w.WriteHeader(status), потом пишется тело через w.Write(...). После вызова WriteHeader заголовки уже отправлены клиенту и изменить их нельзя — попытка вызвать Header().Set после WriteHeader не паникует, но результата не даёт.

ServeMux — диспетчер, который сопоставляет входящий путь запроса с зарегистрированными хендлерами. Начиная с Go 1.22 паттерн регистрации может включать HTTP-метод прямо в строке ("GET /items/{id}"), и если два зарегистрированных паттерна могли бы совпасть с одним и тем же путём — побеждает самый КОНКРЕТНЫЙ маршрут (например, /items/123 предпочтёт /items/{id} перед более общим /items/), а не тот, что был зарегистрирован раньше по порядку кода.

encoding/json и стандартные пакеты

json.Marshal сериализует поля структуры по тегам json:"...". Тег omitempty исключает поле из результата, если его значение равно zero value этого типа (для int — 0, для string — пустая строка, для указателя — nil).

на play.golang.org

Вывод — {"name":"sample"}, и это ловушка, которую стоит проговорить явно: Price: 0 пропал ровно ТАК ЖЕ, как пропала бы пустая строка Notes — omitempty не различает "поле явно установлено в 0" и "поле вообще не задано". Если для бизнес-логики это разные вещи (например, "цена 0 — бесплатный товар" и "цена не указана — ошибка данных"), обычный int не годится: нужен указатель *int, у которого nil однозначно означает "не задано", а &zero — "явно установлено в 0" (указатель сам по себе не является zero-value для omitempty, если он не nil).

Отдельно: json:"-" (дефис вместо имени) исключает поле из JSON всегда, в обе стороны — и при Marshal, и при Unmarshal, независимо от значения.

json.Unmarshal(data, v) требует, чтобы v был указателем — иначе функции физически некуда писать результат разбора, а самая частая опечатка на практике — забыть & перед переменной:

До
var item Itemerr := json.Unmarshal(data, item)
После
var item Itemerr := json.Unmarshal(data, &item)

Забытый & не всегда падает с ошибкой на месте — если item уже интерфейсного типа или указателем является сам тип, компилятор может пропустить это молча, и результат разбора просто не запишется никуда, item останется нулевым значением. Кастомная сериализация под нестандартный формат (например, дата в своём формате вместо RFC3339) делается через реализацию методов MarshalJSON() ([]byte, error) и UnmarshalJSON([]byte) error на своём типе — encoding/json вызывает их автоматически, если они есть.

Смежные стандартные пакеты, которые тоже часто спрашивают: bufio.Scanner — построчное чтение файла/потока без загрузки его целиком в память (важно для больших логов); strconv.Atoi — упрощённый парсинг строки в int по основанию 10, а strconv.ParseInt(s, base, bitSize) — более общий вариант с явным контролем системы счисления и разрядности результата.

В проде вместо log.Println почти всегда используют структурированное логирование — запись в виде JSON с именованными полями ({"level":"error","user_id":42,"msg":"..."}), которую легко фильтровать и агрегировать в системах вроде ELK/Loki по конкретному полю, а не грепать текст. Популярные библиотеки — zap, zerolog, а начиная с Go 1.21 в стандартной библиотеке появился log/slog, закрывающий этот сценарий без сторонних зависимостей.

testing: table-driven, testify, httptest, бенчмарки

Table-driven тест — это срез структур, где каждый элемент описывает один тестовый случай (обычно входные данные + ожидаемый результат), и один цикл, который прогоняет их все через одну и ту же логику проверки. Ключевая деталь, которую часто упускают в примерах, — оборачивать каждый случай в t.Run(name, func(t *testing.T){...}), а не просто перебирать их в плоском цикле без вложенных subtest'ов:

package realtest

import "testing"

func TestAdd(t *testing.T) {
	cases := []struct {
		name     string
		a, b     int
		expected int
	}{
		{"положительные", 2, 3, 5},
		{"с отрицательным", -1, 1, 0},
		{"оба нуля", 0, 0, 0},
	}

	for _, tc := range cases {
		tc := tc // важно для версий Go до 1.22, см. главу про горутины
		t.Run(tc.name, func(t *testing.T) {
			got := add(tc.a, tc.b)
			if got != tc.expected {
				t.Errorf("add(%d,%d) = %d, ожидали %d", tc.a, tc.b, got, tc.expected)
			}
		})
	}
}

Реальный вывод go test -v на этом коде (не запускается в песочнице play.golang.org — там нет go test, но код компилируется и проходит один в один):

=== RUN   TestAdd
=== RUN   TestAdd/положительные
=== RUN   TestAdd/с_отрицательным
=== RUN   TestAdd/оба_нуля
--- PASS: TestAdd (0.00s)
    --- PASS: TestAdd/положительные (0.00s)
    --- PASS: TestAdd/с_отрицательным (0.00s)
    --- PASS: TestAdd/оба_нуля (0.00s)
PASS

t.Run даёт две конкретные практические выгоды, которые стоит проговорить, а не просто знать как факт: каждый случай виден в выводе ОТДЕЛЬНОЙ строкой со своим именем (в примере — TestAdd/положительные, пробелы в имени Go сам заменяет на подчёркивания), и любой отдельный случай можно перезапустить точечно флагом -run TestAdd/положительные, не гоняя остальные. Без t.Run при падении одного случая из десяти видно только "TestAdd упал", а не какой конкретно из case'ов.

testify — сторонняя библиотека для более удобных проверок внутри тестов. Разница между assert.Equal(t, want, got) и require.Equal(t, want, got) — не синтаксис, а поведение при неудаче: assert помечает тест проваленным, но выполнение функции-теста ПРОДОЛЖАЕТСЯ дальше по коду; require немедленно прерывает выполнение этой тестовой функции через t.FailNow(). Практический вывод: require используют для предусловий, без которых дальнейшие проверки бессмысленны или опасны (например, require.NoError(t, err) сразу после вызова, который должен был успешно создать объект — если он не создался, следующие строки, читающие поля этого объекта, попросту паникнут на nil).

httptest.NewRequest/NewRecorder (уже использованы в примере с middleware выше) позволяют тестировать HTTP-хендлеры без реального сетевого сервера и без реальных сокетов: NewRecorder() реализует интерфейс http.ResponseWriter, но вместо отправки байт по сети сохраняет всё записанное в обычные Go-поля (rec.Code — код ответа, rec.Body — тело как *bytes.Buffer), которые потом можно проверить обычными assert.Equal.

Бенчмарки (func BenchmarkX(b *testing.B)) устроены иначе, чем обычные тесты: b.N — не то число, которое задаёт программист, framework сам подбирает и постепенно увеличивает b.N, пока не наберёт достаточно статистически надёжных измерений; код внутри бенчмарка использует b.N только как верхнюю границу цикла (for i := 0; i < b.N; i++), не пытаясь угадать разумное значение самостоятельно.

Из инструментов статического анализа: go vet — встроенный в тулчейн анализатор вероятных ошибок (неверные форматы в Printf, копирование структуры с полем sync.Mutex — см. главу про sync — и другие частые баги), входит в стандартную поставку Go. golangci-lint — сторонний агрегатор из десятков отдельных линтеров под одной командой с единой конфигурацией, фактический индустриальный стандарт для CI большинства Go-проектов.

Ловушка, которую стоит проговорить вслух: писать отдельные функции TestAdd1, TestAdd2, TestAddNegative вместо одной table-driven TestAdd с несколькими случаями — такой код формально работает и проходит go test, но не идиоматичен и плохо масштабируется: добавление 20-го случая означает копирование ещё одной почти идентичной функции вместо одной новой строки в срезе cases.

Самопроверка 0 / 5
Могу объяснить, почему HandlerFunc — именованный тип, а не голая func(...), и зачем это нужно
Понимаю, почему omitempty прячет и валидный 0, а не только "не задано" — и когда нужен *int
Знаю разницу между assert.Equal (тест продолжается) и require.Equal (тест прерывается)
Могу объяснить, в каком порядке выполняется код нескольких вложенных middleware до и после next
Понимаю, зачем t.Run на каждый случай в table-driven тесте, а не один общий цикл без подтестов
Как усвоено?