Тестирование HTTP-обработчиков через httptest
Содержание курса
Матрица HTTP-тестов Task API
В каждом сервисе, работающем с задачами, рано или поздно накапливается набор правил: что считать успехом, что — ошибкой, как реагировать на отсутствующий ресурс или запрос с непонятным методом. Эти правила живут в обработчиках, но их настоящая проверка происходит только тогда, когда кто-то целенаправленно пытается сломать систему — подаёт кривой JSON, обращается к несуществующему идентификатору, использует метод, о котором сервер не имеет представления.
Перед тобой готовая реализация Task API: хранилище задач, маршрутизатор, обработчики. Реализация корректна, но лишена доказательств. Твоя задача — написать полный набор HTTP-тестов, которые эти доказательства предъявят: убедятся, что создание задачи возвращает нужный статус, что обновление флага выполнения отражается в ответе, что удаление оставляет пустое тело, а некорректные запросы получают именно тот код, который им и положен.
Каждый сценарий должен жить в собственном, изолированном окружении — со своим хранилищем и своим маршрутизатором, — чтобы побочный эффект одной проверки не мог исказить результат другой. Ответ сервера нужно читать уже после того, как он зафиксирован в записывающем регистраторе, а не в процессе его формирования. Именно такая дисциплина превращает набор вызовов в надёжную страховочную сетку.
Что нужно сделать
Допишите handlers_test.go: создайте таблицу минимум из семи сценариев — GET 200, POST 201, PATCH с переходом true → false 200, DELETE 204 с пустым телом, сломанный JSON 400, отсутствующий ID 404 и неподдерживаемый метод 405. Для каждого элемента таблицы запускайте отдельный t.Run, а внутри него создавайте свежие Store и mux.
Запрос создаётся через httptest.NewRequest, ответ — через httptest.NewRecorder. После ServeHTTP вызовите rr.Result() и проверяйте resp.StatusCode, resp.Header и декодированное resp.Body; не сравнивайте точный текст поля error. Неизменяемый meta-test реально запускает ваш suite сначала с корректным handler, затем с набором целевых мутаций. Поэтому допустима любая разумная структура тестов, но пустой или поверхностный suite не пройдёт.
О данных в ответах
Используйте учебные данные. Не вставляйте пароли, токены, ключи доступа, паспортные и банковские данные, а также персональные данные других людей. Политика обработки данных.
- Public go test ./...
- healthGET /health → 200
Сначала выполняется go test ./..., затем сервис собирается и проверяется HTTP-запросами. Внешняя сеть и скачивание модулей отключены.
model.go
package main
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
type CreateTaskDTO struct {
Title string `json:"title"`
}
type PatchTaskDTO struct {
Title *string `json:"title"`
Done *bool `json:"done"`
}
type errorResponse struct {
Error string `json:"error"`
}
store.go
package main
import "sync"
type Store struct {
mu sync.RWMutex
tasks []Task
nextID int
}
func NewStore() *Store {
return &Store{tasks: make([]Task, 0), nextID: 1}
}
func (s *Store) Add(title string) Task {
s.mu.Lock()
defer s.mu.Unlock()
task := Task{ID: s.nextID, Title: title, Done: false}
s.nextID++
s.tasks = append(s.tasks, task)
return task
}
func (s *Store) GetAll() []Task {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]Task, len(s.tasks))
copy(result, s.tasks)
return result
}
func (s *Store) Update(id int, dto PatchTaskDTO) (Task, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.tasks {
if s.tasks[i].ID != id {
continue
}
if dto.Title != nil {
s.tasks[i].Title = *dto.Title
}
if dto.Done != nil {
s.tasks[i].Done = *dto.Done
}
return s.tasks[i], true
}
return Task{}, false
}
func (s *Store) Delete(id int) bool {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.tasks {
if s.tasks[i].ID == id {
s.tasks = append(s.tasks[:i], s.tasks[i+1:]...)
return true
}
}
return false
}
handlers.go
package main
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
)
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, errorResponse{Error: message})
}
func decodeCreate(r io.Reader) (CreateTaskDTO, error) {
data, err := io.ReadAll(r)
if err != nil {
return CreateTaskDTO{}, err
}
var dto CreateTaskDTO
if err := json.Unmarshal(data, &dto); err != nil {
return CreateTaskDTO{}, err
}
dto.Title = strings.TrimSpace(dto.Title)
if dto.Title == "" {
return CreateTaskDTO{}, errors.New("title is required")
}
return dto, nil
}
func decodePatch(r io.Reader) (PatchTaskDTO, error) {
data, err := io.ReadAll(r)
if err != nil {
return PatchTaskDTO{}, err
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return PatchTaskDTO{}, err
}
titleRaw, hasTitle := raw["title"]
doneRaw, hasDone := raw["done"]
if !hasTitle && !hasDone {
return PatchTaskDTO{}, errors.New("empty patch")
}
if hasTitle && bytes.Equal(bytes.TrimSpace(titleRaw), []byte("null")) {
return PatchTaskDTO{}, errors.New("title must not be null")
}
if hasDone && bytes.Equal(bytes.TrimSpace(doneRaw), []byte("null")) {
return PatchTaskDTO{}, errors.New("done must not be null")
}
var dto PatchTaskDTO
if err := json.Unmarshal(data, &dto); err != nil {
return PatchTaskDTO{}, err
}
if dto.Title != nil {
trimmed := strings.TrimSpace(*dto.Title)
if trimmed == "" {
return PatchTaskDTO{}, errors.New("title must not be empty")
}
dto.Title = &trimmed
}
return dto, nil
}
func taskID(path string) (int, bool) {
raw := strings.TrimPrefix(path, "/tasks/")
if raw == "" || strings.Contains(raw, "/") {
return 0, false
}
id, err := strconv.Atoi(raw)
return id, err == nil && id > 0
}
func newMux(store *Store) *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "ok\n")
})
mux.HandleFunc("/tasks", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, store.GetAll())
case http.MethodPost:
dto, err := decodeCreate(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid request")
return
}
writeJSON(w, http.StatusCreated, store.Add(dto.Title))
default:
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
}
})
mux.HandleFunc("/tasks/", func(w http.ResponseWriter, r *http.Request) {
id, ok := taskID(r.URL.Path)
if !ok {
writeError(w, http.StatusNotFound, "task not found")
return
}
switch r.Method {
case http.MethodPatch:
dto, err := decodePatch(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid patch")
return
}
task, found := store.Update(id, dto)
if !found {
writeError(w, http.StatusNotFound, "task not found")
return
}
writeJSON(w, http.StatusOK, task)
case http.MethodDelete:
if !store.Delete(id) {
writeError(w, http.StatusNotFound, "task not found")
return
}
w.WriteHeader(http.StatusNoContent)
default:
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
}
})
return mux
}
main.go
package main
import (
"log"
"net/http"
)
func main() {
if err := http.ListenAndServe(":18080", newMux(NewStore())); err != nil {
log.Fatal(err)
}
}
meta_test.go
package main
import (
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
type mutation struct {
name string
old string
new string
all bool
}
func runStudentSuite(t *testing.T, handlerSource, storeSource string) (bool, string) {
t.Helper()
dir := t.TempDir()
for _, name := range []string{"model.go", "handlers_test.go"} {
data, err := os.ReadFile(name)
if err != nil { t.Fatalf("read %s: %v", name, err) }
if err := os.WriteFile(filepath.Join(dir, name), data, 0o600); err != nil { t.Fatalf("write %s: %v", name, err) }
}
if err := os.WriteFile(filepath.Join(dir, "handlers.go"), []byte(handlerSource), 0o600); err != nil { t.Fatal(err) }
if err := os.WriteFile(filepath.Join(dir, "store.go"), []byte(storeSource), 0o600); err != nil { t.Fatal(err) }
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module mutation.local/taskapi\n\ngo 1.27\n"), 0o600); err != nil { t.Fatal(err) }
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
binary := filepath.Join(dir, "student-suite")
if runtime.GOOS == "windows" { binary += ".exe" }
compile := exec.CommandContext(ctx, "go", "test", "-c", "-o", binary)
compile.Dir = dir
compile.Env = append(os.Environ(), "GOWORK=off")
compileOutput, err := compile.CombinedOutput()
if ctx.Err() != nil { return false, "student suite compilation timed out" }
if err != nil { return false, string(compileOutput) }
entries, err := os.ReadDir(dir)
if err != nil { t.Fatal(err) }
for _, entry := range entries {
if strings.HasSuffix(entry.Name(), ".go") {
if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil { t.Fatal(err) }
}
}
run := exec.CommandContext(ctx, binary, "-test.count=1")
run.Dir = dir
output, err := run.CombinedOutput()
if ctx.Err() != nil { return false, "student suite timed out" }
return err == nil, string(output)
}
func TestStudentSuiteKillsHandlerMutations(t *testing.T) {
correctBytes, err := os.ReadFile("handlers.go")
if err != nil { t.Fatal(err) }
correct := string(correctBytes)
storeBytes, err := os.ReadFile("store.go")
if err != nil { t.Fatal(err) }
correctStore := string(storeBytes)
if ok, output := runStudentSuite(t, correct, correctStore); !ok { t.Fatalf("tests fail on correct implementation:\n%s", output) }
mutations := []mutation{
{name: "get status", old: "writeJSON(w, http.StatusOK, store.GetAll())", new: "writeJSON(w, http.StatusCreated, store.GetAll())"},
{name: "get body", old: "writeJSON(w, http.StatusOK, store.GetAll())", new: "writeJSON(w, http.StatusOK, []Task{{ID: 99, Title: \"wrong\", Done: true}})"},
{name: "post status", old: "writeJSON(w, http.StatusCreated, store.Add(dto.Title))", new: "writeJSON(w, http.StatusOK, store.Add(dto.Title))"},
{name: "post body", old: "writeJSON(w, http.StatusCreated, store.Add(dto.Title))", new: "writeJSON(w, http.StatusCreated, Task{ID: 99, Title: \"wrong\", Done: true})"},
{name: "patch false", old: "task, found := store.Update(id, dto)", new: "if dto.Done != nil && !*dto.Done { dto.Done = nil }; task, found := store.Update(id, dto)"},
{name: "delete status", old: "w.WriteHeader(http.StatusNoContent)", new: "writeJSON(w, http.StatusOK, map[string]bool{\"deleted\": true})"},
{name: "delete body", old: "w.WriteHeader(http.StatusNoContent)", new: "w.WriteHeader(http.StatusNoContent); _, _ = io.WriteString(w, \"deleted\")"},
{name: "bad json status", old: "writeError(w, http.StatusBadRequest, \"invalid request\")", new: "writeJSON(w, http.StatusCreated, Task{})"},
{name: "missing status", old: "http.StatusNotFound", new: "http.StatusOK", all: true},
{name: "method status", old: "http.StatusMethodNotAllowed", new: "http.StatusOK", all: true},
{name: "json content type", old: "w.Header().Set(\"Content-Type\", \"application/json\")", new: "w.Header().Set(\"Content-Type\", \"text/plain\")", all: true},
{name: "late content type", old: "w.Header().Set(\"Content-Type\", \"application/json\")\n\tw.WriteHeader(status)", new: "w.WriteHeader(status)\n\tw.Header().Set(\"Content-Type\", \"application/json\")"},
{name: "error shape", old: "writeJSON(w, status, errorResponse{Error: message})", new: "writeJSON(w, status, map[string]string{\"message\": message})"},
{name: "fresh mux", old: "func newMux(store *Store) *http.ServeMux {\n\tmux := http.NewServeMux()", new: "var muxCalls int\n\nfunc newMux(store *Store) *http.ServeMux {\n\tmuxCalls++\n\tif muxCalls > 1 { store = &Store{tasks: make([]Task, 0), nextID: 99} }\n\tmux := http.NewServeMux()"},
}
for _, item := range mutations {
t.Run(item.name, func(t *testing.T) {
var changed string
if item.all { changed = strings.ReplaceAll(correct, item.old, item.new) } else { changed = strings.Replace(correct, item.old, item.new, 1) }
if changed == correct { t.Fatalf("fixed mutation target disappeared: %s", item.old) }
if ok, output := runStudentSuite(t, changed, correctStore); ok { t.Fatalf("student tests did not kill %s\n%s", item.name, output) }
})
}
t.Run("fresh store", func(t *testing.T) {
old := "func NewStore() *Store {\n\treturn &Store{tasks: make([]Task, 0), nextID: 1}\n}"
newSource := "var newStoreCalls int\n\nfunc NewStore() *Store {\n\tnewStoreCalls++\n\treturn &Store{tasks: make([]Task, 0), nextID: newStoreCalls}\n}"
changed := strings.Replace(correctStore, old, newSource, 1)
if changed == correctStore { t.Fatal("fixed Store mutation target disappeared") }
if ok, output := runStudentSuite(t, correct, changed); ok { t.Fatalf("student tests did not prove fresh Store per case\n%s", output) }
})
}
Отправьте решение, чтобы увидеть результаты тестов.
