Перейти к содержанию
Educora
Продвинутый25 мин10 / 10

Модули, тесты и проект REST API

Создавай модули и пакеты, познакомься со стандартной библиотекой, пиши табличные тесты и собери небольшой REST API для задач на `net/http`.

Проверь себя
В этом уроке ты узнаешь
  • Создавать модуль через go mod init и разбивать код на пакеты
  • Пользоваться стандартными пакетами strings, strconv, os, time и другими
  • Писать и запускать табличные тесты через go test
  • Строить HTTP-сервер, работающий с JSON, на net/http и encoding/json

Теперь ты знаешь почти все основные возможности Go. Соберём их вместе в реальном проекте: создадим собственный модуль, разобьём код на пакеты, напишем тесты и построим небольшой REST API сервер со списком задач. Причём для всего этого хватит одной стандартной библиотеки Go.

Модули и пакеты

Модуль — это проект с файлом go.mod. Команда go mod init example.com/tasks создаёт модуль, а example.com/tasks — это путь модуля, обычно адрес, где хранится код, например github.com/aysel/tasks. Внутри модуля каждая папка — это пакет, который импортируют как путь модуля + имя папки: example.com/tasks/store. Из другого пакета видны только имена, начинающиеся с заглавной буквы.

Text
tasks/
├── go.mod
├── main.go
└── store/
    ├── store.go
    └── store_test.go
Структура проекта
Text
module example.com/tasks

go 1.22
Файл go.mod. Строка go указывает минимальную требуемую версию; у тебя может стоять более новая.
КомандаЧто делает
go mod init example.com/tasksсоздаёт новый модуль
go get golang.org/x/textдобавляет сторонний пакет и записывает его в go.mod
go mod tidyдобавляет недостающие зависимости и удаляет лишние
go build ./...собирает все пакеты модуля (./... — эта папка и все вложенные)
go vet ./...ищет подозрительный код: неверные спецификаторы, копии мьютексов

Стандартная библиотека

Стандартная библиотека Go очень богата: текст, числа, файлы, время, JSON, HTTP-сервер и клиент, криптография, тестирование — всё это поставляется вместе с языком. Поэтому многие небольшие и средние проекты пишутся вообще без сторонних зависимостей.

ПакетДля чего
fmtформатированный вывод и сборка строк
strings, strconvработа со строками, преобразование строка ↔ число
osфайлы, переменные окружения, аргументы
timeдаты, время и длительности
encoding/jsonкодирование значений в JSON и декодирование JSON
net/httpHTTP-сервер и клиент
testingавтоматические тесты
Go
package main

import (
	"fmt"
	"strconv"
	"strings"
	"time"
)

func main() {
	fmt.Println(strings.Split("go,java,sql", ","))
	fmt.Println(strings.Repeat("ab", 3), strings.HasPrefix("golang", "go"))
	n, err := strconv.ParseFloat("2.5", 64)
	fmt.Println(n*2, err)
	d := 90 * time.Minute
	fmt.Println(d, d.Hours())
	day := time.Date(2026, time.September, 27, 0, 0, 0, 0, time.UTC)
	fmt.Println(day.Weekday(), day.Format("02.01.2006"))
}
Ожидаемый результат
[go java sql]
ababab true
5 <nil>
1h30m0s 1.5
Sunday 27.09.2026

Проект: пакет store и тесты

Логику хранения задач выносим в отдельный пакет store. Теги вроде json:"id" у полей Task сообщают пакету encoding/json, какие имена использовать в JSON. Нулевое значение Store сразу готово к работе — конструктор не нужен. Это любимый принцип Go: «сделай нулевое значение полезным».

Go
package store

import (
	"errors"
	"strings"
	"sync"
)

var ErrEmptyTitle = errors.New("empty title")

type Task struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
}

type Store struct {
	mu    sync.Mutex
	tasks []Task
}

func (s *Store) Add(title string) (Task, error) {
	title = strings.TrimSpace(title)
	if title == "" {
		return Task{}, ErrEmptyTitle
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	t := Task{ID: len(s.tasks) + 1, Title: title}
	s.tasks = append(s.tasks, t)
	return t, nil
}

func (s *Store) All() []Task {
	s.mu.Lock()
	defer s.mu.Unlock()
	out := make([]Task, len(s.tasks))
	copy(out, s.tasks)
	return out
}
store/store.go

All возвращает не сам внутренний срез, а его копию, поэтому вызывающий код не сможет менять список в обход мьютекса. Имя тестового файла оканчивается на _test.go, и он лежит в той же папке, что и тестируемый пакет. Тестовая функция начинается со слова Test и принимает *testing.T: t.Errorf фиксирует ошибку и продолжает работу, а t.Fatalf сразу останавливает тест. В Go чаще всего пишут табличные тесты (table-driven tests): проверки перечисляются в срезе структур, а один цикл запускает каждую из них как отдельный подтест через t.Run.

Go
package store

import (
	"errors"
	"testing"
)

func TestAdd(t *testing.T) {
	tests := []struct {
		name    string
		title   string
		want    string
		wantErr error
	}{
		{"simple", "Buy bread", "Buy bread", nil},
		{"trims spaces", "  Learn Go  ", "Learn Go", nil},
		{"empty", "   ", "", ErrEmptyTitle},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			var s Store
			got, err := s.Add(tt.title)
			if !errors.Is(err, tt.wantErr) {
				t.Fatalf("Add(%q) error = %v, want %v", tt.title, err, tt.wantErr)
			}
			if got.Title != tt.want {
				t.Errorf("Add(%q).Title = %q, want %q", tt.title, got.Title, tt.want)
			}
		})
	}
}
store/store_test.go
Text
$ go test ./...
?   	example.com/tasks	[no test files]
ok  	example.com/tasks/store	0.004s
Время в конце при каждом запуске будет другим. go test -v ./store показывает каждый подтест (TestAdd/trims_spaces — пробелы заменяются подчёркиваниями), а go test -run TestAdd/empty ./store запускает только один случай.
Непонятно, что не так
if got.Title != "Learn Go" {
	t.Error("wrong title")
}
Видно, что ожидалось и что получено
if got.Title != tt.want {
	t.Errorf("Add(%q).Title = %q, want %q",
		tt.title, got.Title, tt.want)
}
В Go сообщение теста обычно строится по схеме got X, want Y: когда тест падает, причина видна без открытия кода.

HTTP-сервер: net/http

В main.go функция http.NewServeMux() создаёт маршрутизатор. Начиная с Go 1.22 HTTP-метод можно указать прямо в маршруте: "GET /tasks" и "POST /tasks". Каждый обработчик (handler) принимает два параметра: http.ResponseWriter для записи ответа и входящий запрос *http.Request. json.NewEncoder(w).Encode(...) превращает значение в JSON и пишет его в ответ, а json.NewDecoder(r.Body).Decode(&in) читает тело запроса. Порт берётся из переменной окружения PORT (os.Getenv), а если её нет, используется 8080.

Go
package main

import (
	"encoding/json"
	"log"
	"net/http"
	"os"

	"example.com/tasks/store"
)

func main() {
	var tasks store.Store
	mux := http.NewServeMux()

	mux.HandleFunc("GET /tasks", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(tasks.All())
	})

	mux.HandleFunc("POST /tasks", func(w http.ResponseWriter, r *http.Request) {
		var in struct {
			Title string `json:"title"`
		}
		if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
			http.Error(w, "invalid JSON", http.StatusBadRequest)
			return
		}
		task, err := tasks.Add(in.Title)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusCreated)
		json.NewEncoder(w).Encode(task)
	})

	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}
	log.Println("listening on :" + port)
	log.Fatal(http.ListenAndServe(":"+port, mux))
}
main.go

Запусти сервер командой go run . и отправляй запросы из другого терминала с помощью curl. POST создаёт новую задачу и возвращает JSON со статусом 201 Created, пустой заголовок даёт 400 Bad Request, а GET возвращает весь список. Если отправить запрос с методом, для которого нет маршрута (например, DELETE /tasks), ServeMux сам ответит 405 Method Not Allowed.

Text
$ curl -X POST localhost:8080/tasks -d '{"title":"Learn Go"}'
{"id":1,"title":"Learn Go"}
$ curl -X POST localhost:8080/tasks -d '{"title":"   "}'
empty title
$ curl localhost:8080/tasks
[{"id":1,"title":"Learn Go"}]
Запросы и ответы сервера в терминале Linux, macOS или Git Bash

Главное

  • go mod init создаёт модуль; каждая папка — пакет, который импортируют как путь модуля/папка.
  • Стандартная библиотека (fmt, strings, strconv, os, time, encoding/json, net/http) закрывает большинство задач без сторонних зависимостей.
  • Тесты — это функции TestXxx(t *testing.T) в файлах _test.go; табличные тесты с t.Run — стиль Go.
  • Начиная с Go 1.22 ServeMux поддерживает маршруты с методами ("GET /tasks") и параметры пути ({id}).
  • Обработчики работают одновременно, поэтому защищай общее состояние мьютексом.

Проверь себя

Вопросов: 10. Каждый правильный ответ приносит XP.

1 / 10
Путь модуля — example.com/tasks, а пакет находится в папке store. Как его импортировать?