Тестирование - встроено в язык
Go имеет встроенную поддержку тестирования. Никаких внешних фреймворков не нужно - всё есть в стандартной библиотеке.
Основы тестирования
Первый тест
// math.go
package math
func Add(a, b int) int {
return a + b
}
func Multiply(a, b int) int {
return a * b
}
// math_test.go
package math
import "testing"
func TestAdd(t *testing.T) {
result := Add(2, 3)
expected := 5
if result != expected {
t.Errorf("Add(2, 3) = %d; expected %d", result, expected)
}
}
func TestMultiply(t *testing.T) {
result := Multiply(3, 4)
expected := 12
if result != expected {
t.Errorf("Multiply(3, 4) = %d; expected %d", result, expected)
}
}
Запуск тестов
# Все тесты в пакете
go test
# С подробным выводом
go test -v
# Конкретный тест
go test -run TestAdd
# Все тесты в проекте
go test ./...
# С покрытием
go test -cover
Table-driven тесты
Самый популярный паттерн в Go:
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive numbers", 2, 3, 5},
{"negative numbers", -1, -2, -3},
{"mixed numbers", -5, 10, 5},
{"zeros", 0, 0, 0},
{"large numbers", 1000000, 2000000, 3000000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Add(tt.a, tt.b)
if result != tt.expected {
t.Errorf("Add(%d, %d) = %d; expected %d",
tt.a, tt.b, result, tt.expected)
}
})
}
}
Более сложный пример
func TestDivide(t *testing.T) {
tests := []struct {
name string
dividend float64
divisor float64
want float64
wantError bool
errorMsg string
}{
{
name: "positive division",
dividend: 10,
divisor: 2,
want: 5,
},
{
name: "division by zero",
dividend: 10,
divisor: 0,
wantError: true,
errorMsg: "division by zero",
},
{
name: "negative result",
dividend: -10,
divisor: 2,
want: -5,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Divide(tt.dividend, tt.divisor)
if tt.wantError {
if err == nil {
t.Errorf("expected error, got nil")
return
}
if err.Error() != tt.errorMsg {
t.Errorf("error = %v; want %v", err, tt.errorMsg)
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
if got != tt.want {
t.Errorf("got %f; want %f", got, tt.want)
}
})
}
}
Helper функции
func assertEqual(t *testing.T, got, want interface{}) {
t.Helper() // помечает функцию как helper
if got != want {
t.Errorf("got %v; want %v", got, want)
}
}
func assertError(t *testing.T, err error, want string) {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
if err.Error() != want {
t.Errorf("error = %v; want %v", err, want)
}
}
func TestWithHelpers(t *testing.T) {
result, err := Calculate(10, 2)
assertError(t, err, nil)
assertEqual(t, result, 5)
}
Тестирование HTTP handlers
Если ещё не знаком с http.HandlerFunc - посмотри урок HTTP сервер из коробки.
func TestHealthCheckHandler(t *testing.T) {
// Создаем запрос
req, err := http.NewRequest("GET", "/health", nil)
if err != nil {
t.Fatal(err)
}
// Создаем ResponseRecorder
rr := httptest.NewRecorder()
// Вызываем handler
handler := http.HandlerFunc(HealthCheckHandler)
handler.ServeHTTP(rr, req)
// Проверяем статус
if status := rr.Code; status != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusOK)
}
// Проверяем тело ответа
expected := `{"status":"ok"}`
if rr.Body.String() != expected {
t.Errorf("handler returned unexpected body: got %v want %v",
rr.Body.String(), expected)
}
}
// Тестирование с разными методами и данными
func TestCreateUserHandler(t *testing.T) {
tests := []struct {
name string
method string
body string
wantStatus int
wantBody string
}{
{
name: "valid request",
method: "POST",
body: `{"name":"Alice","email":"alice@example.com"}`,
wantStatus: http.StatusCreated,
wantBody: `{"id":1,"name":"Alice","email":"alice@example.com"}`,
},
{
name: "invalid method",
method: "GET",
body: "",
wantStatus: http.StatusMethodNotAllowed,
wantBody: "Method not allowed\n",
},
{
name: "invalid json",
method: "POST",
body: `{"name":"Alice"`,
wantStatus: http.StatusBadRequest,
wantBody: "Invalid JSON\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, "/users", strings.NewReader(tt.body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
handler := http.HandlerFunc(CreateUserHandler)
handler.ServeHTTP(rr, req)
if rr.Code != tt.wantStatus {
t.Errorf("status = %d; want %d", rr.Code, tt.wantStatus)
}
if rr.Body.String() != tt.wantBody {
t.Errorf("body = %s; want %s", rr.Body.String(), tt.wantBody)
}
})
}
}
Моки и интерфейсы
// Интерфейс для тестирования
type UserRepository interface {
Create(user *User) error
// Оба поиска возвращают ErrUserNotFound, если записи нет.
// Контракт нужен и моку: см. комментарий к MockUserRepository.
FindByID(id int) (*User, error)
FindByEmail(email string) (*User, error)
}
var ErrUserNotFound = errors.New("user not found")
// Mock реализация
type MockUserRepository struct {
CreateFunc func(user *User) error
FindByIDFunc func(id int) (*User, error)
FindByEmailFunc func(email string) (*User, error)
}
func (m *MockUserRepository) Create(user *User) error {
if m.CreateFunc != nil {
return m.CreateFunc(user)
}
return nil
}
// Поведение по умолчанию - «записи нет», то есть ErrUserNotFound.
//
// Раньше здесь стояло `return nil, nil`, и это ломало тесты незаметным
// образом: «пользователь найден, он nil, ошибки нет» - состояние, которого
// настоящий репозиторий вернуть не может. Код `u, err := repo.FindByID(id)`
// с проверкой только `err != nil` проходил тест и падал в проде
// на разыменовании nil. Хуже того, проверка дублей
// `if existing, _ := repo.FindByEmail(e); existing != nil` с таким моком
// всегда шла по ветке «email свободен» - регрессию в этой проверке тест
// поймать не мог.
func (m *MockUserRepository) FindByID(id int) (*User, error) {
if m.FindByIDFunc != nil {
return m.FindByIDFunc(id)
}
return nil, ErrUserNotFound
}
func (m *MockUserRepository) FindByEmail(email string) (*User, error) {
if m.FindByEmailFunc != nil {
return m.FindByEmailFunc(email)
}
return nil, ErrUserNotFound
}
// Тестирование с моком
func TestUserService_Create(t *testing.T) {
mockRepo := &MockUserRepository{
CreateFunc: func(user *User) error {
if user.Email == "exists@example.com" {
return ErrEmailExists
}
user.ID = 123
return nil
},
}
service := NewUserService(mockRepo)
t.Run("successful creation", func(t *testing.T) {
user := &User{
Name: "Alice",
Email: "alice@example.com",
}
err := service.Create(user)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if user.ID != 123 {
t.Errorf("user.ID = %d; want 123", user.ID)
}
})
t.Run("email exists", func(t *testing.T) {
user := &User{
Name: "Bob",
Email: "exists@example.com",
}
err := service.Create(user)
if err != ErrEmailExists {
t.Errorf("error = %v; want ErrEmailExists", err)
}
})
}
Правило простое: посмотрите на постусловие метода в интерфейсе и проверьте,
что заглушка его не нарушает. Если репозиторий возвращает либо *User,
либо ошибку - nil, nil из заглушки запрещён, потому что вызывающий код
такого случая не предусматривает и не должен.
Самая надёжная страховка от расхождений - контрактный тест: один набор проверок, который прогоняется и против заглушки, и против настоящей реализации. Разбор в уроке про LSP.
Бенчмарки
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(2, 3)
}
}
func BenchmarkStringConcat(b *testing.B) {
b.Run("with +", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = "Hello" + " " + "World"
}
})
b.Run("with fmt.Sprintf", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = fmt.Sprintf("%s %s", "Hello", "World")
}
})
b.Run("with strings.Builder", func(b *testing.B) {
for i := 0; i < b.N; i++ {
var sb strings.Builder
sb.WriteString("Hello")
sb.WriteString(" ")
sb.WriteString("World")
_ = sb.String()
}
})
}
// Запуск: go test -bench=.
// Или конкретный: go test -bench=BenchmarkStringConcat
Бенчмарк с подготовкой
func BenchmarkSort(b *testing.B) {
sizes := []int{10, 100, 1000, 10000}
for _, size := range sizes {
b.Run(fmt.Sprintf("size-%d", size), func(b *testing.B) {
// Подготовка данных
data := make([]int, size)
for i := range data {
data[i] = rand.Intn(size)
}
b.ResetTimer() // сброс таймера после подготовки
for i := 0; i < b.N; i++ {
b.StopTimer() // пауза для копирования
copied := make([]int, len(data))
copy(copied, data)
b.StartTimer() // продолжаем замер
sort.Ints(copied)
}
})
}
}
Интеграционные тесты
// +build integration
package main
import (
"database/sql"
"testing"
)
func TestUserRepository_Integration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
// Подключаемся к тестовой БД
db, err := sql.Open("postgres", "postgres://test@localhost/testdb?sslmode=disable")
if err != nil {
t.Fatal(err)
}
defer db.Close()
// Очищаем данные
_, err = db.Exec("TRUNCATE TABLE users")
if err != nil {
t.Fatal(err)
}
repo := NewUserRepository(db)
t.Run("Create and Find", func(t *testing.T) {
user := &User{
Name: "Test User",
Email: "test@example.com",
}
// Create
err := repo.Create(user)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if user.ID == 0 {
t.Error("expected user.ID to be set")
}
// Find
found, err := repo.FindByID(user.ID)
if err != nil {
t.Fatalf("FindByID() error = %v", err)
}
if found.Name != user.Name {
t.Errorf("Name = %v; want %v", found.Name, user.Name)
}
})
}
// Запуск: go test -tags=integration
Test fixtures и cleanup
func setupTest(t *testing.T) (*sql.DB, func()) {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatal(err)
}
// Создаем схему
_, err = db.Exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
)
`)
if err != nil {
t.Fatal(err)
}
// Cleanup функция
cleanup := func() {
db.Close()
}
return db, cleanup
}
func TestWithFixture(t *testing.T) {
db, cleanup := setupTest(t)
defer cleanup()
// Используем db для тестов
repo := NewUserRepository(db)
// тесты...
}
// Или с t.Cleanup (Go 1.14+)
func TestWithCleanup(t *testing.T) {
db := setupDB(t)
t.Cleanup(func() {
db.Close()
})
// тесты...
}
Параллельные тесты
func TestParallel(t *testing.T) {
// Эти тесты будут выполняться параллельно
t.Run("test1", func(t *testing.T) {
t.Parallel()
time.Sleep(1 * time.Second)
// тест...
})
t.Run("test2", func(t *testing.T) {
t.Parallel()
time.Sleep(1 * time.Second)
// тест...
})
t.Run("test3", func(t *testing.T) {
t.Parallel()
time.Sleep(1 * time.Second)
// тест...
})
}
Golden files
func TestGenerateReport(t *testing.T) {
report := GenerateReport(testData)
golden := filepath.Join("testdata", "report.golden")
if *update {
// Обновляем golden файл
err := os.WriteFile(golden, []byte(report), 0644)
if err != nil {
t.Fatal(err)
}
}
expected, err := os.ReadFile(golden)
if err != nil {
t.Fatal(err)
}
if report != string(expected) {
t.Errorf("report does not match golden file")
t.Logf("got:\n%s", report)
t.Logf("want:\n%s", expected)
}
}
// Запуск с обновлением: go test -update
Best practices
<ComparisonTable data={{ headers: ["Практика", "Описание", "Пример"], rows: [ ["Имена тестов", "Описательные имена", "Test_UserService_Create_WithExistingEmail"], ["Table-driven", "Для множества случаев", "tests := []struct{...}"], ["t.Helper()", "Для вспомогательных функций", "Показывает правильную строку ошибки"], ["t.Parallel()", "Для независимых тестов", "Ускоряет выполнение"], ["Моки", "Через интерфейсы", "Легко тестировать зависимости"] ] }} />
Мини-задание
- Прогони тесты с гонками:
go test -race ./.... На однопоточном коде разницы не будет, на горутинах может быть - Посмотри покрытие:
go test -cover ./..., потомgo test -coverprofile=c.out ./... && go tool cover -html=c.out - Запусти один тест по имени:
go test -run TestИмя ./... - Напиши табличный тест с
t.Runна подслучаи и посмотри, как они называются в выводе-v - Добавь бенчмарк и прогони
go test -bench . -benchmem. Колонкаallocs/opполезнее, чем кажется
Итоги
- Тестирование встроено в Go
- Table-driven тесты - стандарт
- Интерфейсы облегчают мокирование
- Бенчмарки для оптимизации
- go test - мощный инструмент
В следующем уроке разберём context - последнюю тему перед финальным проектом!
Типичная ошибка
Писать тест, который зависит от времени/порядка/случайности без контроля. Он будет «флапать».
Мини-практика
Сделай табличный тест для Grade(score int) string - это функция из задания к уроку про условия: если ещё не решал его, реши там, а здесь покрой тестами.
Живые примеры в этом треке
Тесты, написанные не для покрытия, а под конкретные ошибки, можно посмотреть в проектах-вехах:
examples/go-milestones/cli/store_test.go- веха 1: изменение копии элемента слайса вместо самого слайса,len+1в генераторе идентификаторов, отдача внутреннего слайса наружу.examples/go-milestones/api-memory/handler_test.go- веха 2:httptest.NewServerс настоящими запросами, проверка кодов ответов и тест на гонку, который падает без мьютекса.
Каждый из этих тестов отвечает на вопрос «что сломается, если сделать наивно», и это единственный критерий, по которому тест стоит писать.