Тестирование - встроено в язык
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
FindByID(id int) (*User, error)
FindByEmail(email string) (*User, error)
}
// 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
}
func (m *MockUserRepository) FindByID(id int) (*User, error) {
if m.FindByIDFunc != nil {
return m.FindByIDFunc(id)
}
return nil, nil
}
func (m *MockUserRepository) FindByEmail(email string) (*User, error) {
if m.FindByEmailFunc != nil {
return m.FindByEmailFunc(email)
}
return nil, nil
}
// Тестирование с моком
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)
}
})
}
Бенчмарки
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
- Table-driven тесты - стандарт
- Интерфейсы облегчают мокирование
- Бенчмарки для оптимизации
- go test - мощный инструмент
В финальном уроке создадим полноценный проект!
Типичная ошибка
Писать тест, который зависит от времени/порядка/случайности без контроля. Он будет «флапать».
Мини-практика
Сделай табличный тест для Grade(score int) string - это функция из задания к уроку про условия: если ещё не решал его, реши там, а здесь покрой тестами.