Зарегистрируйтесь, чтобы продолжить обучение

Best practices и организация читаемых тестов Go: Автоматическое тестирование

Когда тестов становится несколько десятков, они ещё кажутся управляемыми. Но как только их число идёт на сотни, всё начинает зависеть от того, насколько аккуратно они написаны и организованы. Читаемость и структура становятся критичнее самой логики. Плохо организованные тесты превращаются в хаос: трудно понять, что именно проверяется, почему упал тест и как его починить.

Хорошо написанный тест читается как маленькая история. В нём ясно, какие входные данные использованы, какой результат ожидается и почему это важно. Такой тест не просто проверяет программу, но и документирует её поведение.

Названия тестов

Название теста — это первое, что видит разработчик при падении. Оно должно сразу объяснять, что проверяется. Вместо Test1 или TestFunc лучше писать конкретно:

func TestDivide_ByZero_ReturnsError(t *testing.T) {
    _, err := Divide(10, 0)
    if err == nil {
        t.Fatal("ожидали ошибку при делении на ноль")
    }
}

Название сразу говорит: проверяется деление на ноль, ожидается ошибка. При падении будет очевидно, о чём речь.

Структура тестов

Полезно придерживаться одинакового ритма: подготовка → действие → проверка.

Пример:

func TestMax(t *testing.T) {
    // подготовка входных данных
    a, b := 2, 3

    // действие
    got := Max(a, b)

    // проверка
    want := 3
    if got != want {
        t.Errorf("ожидали %d, получили %d", want, got)
    }
}

Такой порядок облегчает чтение: даже не глядя в детали, понятно, что вначале создаются данные, потом вызывается функция, потом идёт проверка.

Использование табличных тестов

Если вариантов входных данных много, отдельные тесты быстро превращаются в дубли. Табличные тесты позволяют перечислить сценарии в срезе и пройтись по ним в цикле.

func TestIsEven(t *testing.T) {
    cases := []struct {
        n    int
        want bool
    }{
        {2, true},
        {3, false},
        {10, true},
    }

    for _, c := range cases {
        got := IsEven(c.n)
        if got != c.want {
            t.Errorf("IsEven(%d) = %v, хотели %v", c.n, got, c.want)
        }
    }
}

Один тест проверяет сразу несколько сценариев, и читать его легче.

Хелперы для повторяющихся проверок

Если в тестах часто повторяется одна и та же проверка, лучше вынести её в функцию-хелпер.

func assertEqual(t testing.TB, got, want int) {
    t.Helper()
    if got != want {
        t.Errorf("получили %d, хотели %d", got, want)
    }
}

Теперь любой тест можно упростить:

func TestMax_Helper(t *testing.T) {
    assertEqual(t, Max(2, 3), 3)
    assertEqual(t, Max(10, 5), 10)
}

Независимость тестов

Тесты должны быть изолированы друг от друга. Один тест не должен полагаться на результаты другого. Если тесты используют файлы, лучше создавать временные с помощью t.TempDir(). Если обращаются к базе или сетевым ресурсам — поднимать для каждого свой изолированный стенд или подменять зависимости моками.

Параллельные тесты

t.Parallel() ускоряет выполнение большого набора тестов. Но чтобы избежать гонок данных, нужно помнить: каждый тест должен работать только со своими данными или синхронизировать доступ к общим ресурсам через мьютексы.

Использование сторонних библиотек

Библиотеки вроде testify делают тесты компактнее и читаемее. Ассерты помогают выразить проверки в одной строке, а моки позволяют быстро подменить зависимости. Но важно помнить: библиотека — лишь инструмент. Главная цель остаётся прежней — читаемый тест, который ясно показывает намерение.

Итоги

Лучшие тесты — это те, которые легко читать. Названия функций должны быть понятными, структура предсказуемой, данные — простыми, проверки — очевидными. Табличные тесты, хелперы и моки помогают избавиться от дублирования и лишнего шума. Независимость и параллельность делают тесты быстрыми и надёжными.

Хорошо организованный набор тестов становится не только защитой от ошибок, но и живой документацией: он показывает, как должна вести себя программа в разных ситуациях.


Самостоятельная работа

Потренируемся писать «чистые» тесты на двух небольших функциях.

package textutil

import (
    "bufio"
    "io"
    "strings"
)

// NormalizeSpaces схлопывает последовательности пробельных символов в один пробел
// и обрезает крайние пробелы.
func NormalizeSpaces(s string) string {
    tokens := strings.Fields(s)
    return strings.Join(tokens, " ")
}

// CountLines считает количество строк в ридере (по разделителю \n).
func CountLines(r io.Reader) (int, error) {
    var n int
    scanner := bufio.NewScanner(r)
    for scanner.Scan() {
        n++
    }
    return n, scanner.Err()
}

Напишите тесты к функциям.

Что покрыть в тестах

  • NormalizeSpaces:
    • пустая строка;
    • только пробелы/табы;
    • смешанные пробелы и текст, уже нормализованный текст;
    • юникод и пунктуация сохраняются как есть.
  • CountLines:
    • пустой ввод (0 строк);
    • одна строка без завершающего перевода;
    • несколько строк, включая последнюю с \n и без него.

Подсказка

  • Вынесите сравнение строк в хелпер с t.Helper().
  • Используйте t.Run + табличные тесты, а для независимых сценариев — t.Parallel().
  • Для файлов вместо диска используйте strings.NewReader или bytes.NewBuffer.
Показать решение
package textutil

import (
    "strings"
    "testing"
)

func assertEq(t testing.TB, got, want string) {
    t.Helper()
    if got != want {
        t.Fatalf("got %q, want %q", got, want)
    }
}

func TestNormalizeSpaces(t *testing.T) {
    t.Parallel()
    tests := []struct {
        name string
        in   string
        want string
    }{
        {name: "empty", in: "", want: ""},
        {name: "spaces_tabs", in: "  a\tb  ", want: "a b"},
        {name: "already_clean", in: "a b c", want: "a b c"},
        {name: "unicode_punct", in: " Привет,   мир! ", want: "Привет, мир!"},
    }
    for _, tc := range tests {
        tc := tc
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()
            got := NormalizeSpaces(tc.in)
            assertEq(t, got, tc.want)
        })
    }
}

func TestCountLines(t *testing.T) {
    t.Parallel()
    tests := []struct {
        name string
        in   string
        want int
    }{
        {name: "empty", in: "", want: 0},
        {name: "single_no_nl", in: "line", want: 1},
        {name: "two_with_nl", in: "a\n\n", want: 2},
        {name: "three_mixed", in: "a\nb\nc", want: 3},
    }
    for _, tc := range tests {
        tc := tc
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()
            got, err := CountLines(strings.NewReader(tc.in))
            if err != nil {
                t.Fatalf("unexpected error: %v", err)
            }
            if got != tc.want {
                t.Fatalf("%s: got %d, want %d", tc.name, got, tc.want)
            }
        })
    }
}

Дополнительные материалы

  1. Table-driven tests (Go Wiki)
  2. Uber Go Style Guide — Testing
  3. Code Review Comments — общие рекомендации

Для полного доступа к курсу нужен базовый план

Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.

Получить доступ
1000
упражнений
2000+
часов теории
3200
тестов

Открыть доступ

Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно

  • 130 курсов, 2000+ часов теории
  • 1000 практических заданий в браузере
  • 360 000 студентов
Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»

Наши выпускники работают в компаниях:

Логотип компании Альфа Банк
Логотип компании Aviasales
Логотип компании Yandex
Логотип компании Tinkoff