HTTP-сервер, JSON API и итоговый проект

Тестирование 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 не пройдёт.

О данных в ответах

Используйте учебные данные. Не вставляйте пароли, токены, ключи доступа, паспортные и банковские данные, а также персональные данные других людей. Политика обработки данных.

Проверяется тестами (3)· скрытых: 1
  • Public go test ./...
  • health
    GET /health200
Go-проект · handlers_test.go

Сначала выполняется 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) }
	})
}
РешениеGo · Go 1.27 (Modal)

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