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

Табличные тесты Go: Автоматическое тестирование

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

Можно писать отдельный if для каждого случая, но это быстро превращается в дублирование. Чтобы сделать тесты компактнее, Go-разработчики используют приём табличных тестов.

Пример без таблицы

Сначала посмотрим, как будет выглядеть тест:

func TestMax_NoTable(t *testing.T) {
    // проверка 1: второе число больше
    if got := Max(2, 3); got != 3 {
        t.Errorf("Max(2, 3) = %d, хотели 3", got)
    }

    // проверка 2: числа равны
    if got := Max(5, 5); got != 5 {
        t.Errorf("Max(5, 5) = %d, хотели 5", got)
    }

    // проверка 3: первое число больше
    if got := Max(10, 3); got != 10 {
        t.Errorf("Max(10, 3) = %d, хотели 10", got)
    }
}

Вроде всё ок, но три раза повторяется один и тот же код: вызвали функцию, сравнили результат, вывели сообщение. Если кейсов станет 10 или 20 — тест будет раздутым.

Табличный подход

Чтобы не повторяться, сделаем «таблицу кейсов» — срез структур, где каждая структура хранит входные данные и ожидаемый результат.

func TestMax_Table(t *testing.T) {
    // Таблица кейсов: три строки = три сценария
    cases := []struct {
        a, b int // входные данные
        want int // ожидаемый результат
    }{
        {2, 3, 3},   // второй больше
        {5, 5, 5},   // равные
        {10, 3, 10}, // первый больше
    }

    // Циклом пробегаем по всем сценариям
    for _, c := range cases {
        got := Max(c.a, c.b)
        if got != c.want {
            // Если результат не совпал — ошибка
            t.Errorf("Max(%d, %d) = %d, хотели %d", c.a, c.b, got, c.want)
        }
    }
}

Плюсы такого подхода:

  • Код теста короткий и не повторяется.
  • Легко добавить новые кейсы: просто ещё одна строка в таблице.
  • Читается как список условий: удобно глазами пробегать, что проверяется.

Ещё один пример: проверка строк

Представим функцию, которая делает первую букву строки заглавной:

func Capitalize(s string) string {
    if s == "" {
        return ""
    }
    return strings.ToUpper(s[:1]) + s[1:]
}

Для неё тоже удобно написать табличный тест:

func TestCapitalize(t *testing.T) {
    cases := []struct {
        in   string
        want string
    }{
        {"hello", "Hello"},
        {"go", "Go"},
        {"", ""}, // пустая строка — отдельный сценарий
    }

    for _, c := range cases {
        got := Capitalize(c.in)
        if got != c.want {
            t.Errorf("Capitalize(%q) = %q, хотели %q", c.in, got, c.want)
        }
    }
}

Здесь сразу видно, какие варианты проверяются: обычное слово, короткая строка, пустая строка.

Подтесты (t.Run)

Чтобы ещё удобнее видеть, какой именно кейс сломался, можно запускать каждый сценарий как отдельный подтест. Для этого используется метод t.Run.

func TestMax_Subtests(t *testing.T) {
    cases := []struct {
        a, b int
        want int
    }{
        {2, 3, 3},
        {5, 5, 5},
        {10, 3, 10},
    }

    for _, c := range cases {
        // имя подтеста формируем из входных данных
        name := fmt.Sprintf("%d_%d", c.a, c.b)

        t.Run(name, func(t *testing.T) {
            got := Max(c.a, c.b)
            if got != c.want {
                t.Errorf("Max(%d, %d) = %d, хотели %d", c.a, c.b, got, c.want)
            }
        })
    }
}

Теперь вывод тестов будет выглядеть так:

=== RUN   TestMax_Subtests
=== RUN   TestMax_Subtests/2_3
=== RUN   TestMax_Subtests/5_5
=== RUN   TestMax_Subtests/10_3
--- FAIL: TestMax_Subtests (0.00s)
    --- FAIL: TestMax_Subtests/2_3 (0.00s)

Очень удобно, когда кейсов много: сразу видно, какой именно вход сломался.

Табличные тесты и ошибки

Обычно вместе с входными данными и результатом в таблицу добавляют и ожидаемую ошибку.

Функция:

func Divide(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

Табличный тест:

func TestDivide(t *testing.T) {
    cases := []struct {
        a, b int
        want int
        err  error
    }{
        {10, 2, 5, nil}, // обычное деление
        {10, 0, 0, errors.New("division by zero")}, // ошибка деления на ноль
    }

    for _, c := range cases {
        got, err := Divide(c.a, c.b)

        if c.err != nil {
            // если ожидалась ошибка — проверяем её наличие
            if err == nil || err.Error() != c.err.Error() {
                t.Errorf("Divide(%d, %d) ожидали ошибку %q, получили %v",
                    c.a, c.b, c.err, err)
            }
            continue
        }

        // если ошибки не ожидали — сравниваем результат
        if got != c.want {
            t.Errorf("Divide(%d, %d) = %d, хотели %d", c.a, c.b, got, c.want)
        }
    }
}

Таким образом можно в одной таблице описать и успешные сценарии, и сценарии с ошибкой.

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


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

Напишите функцию, которая нормализует строки по простым правилам:

  • Удаляет ведущие и замыкающие пробелы.
  • Последовательности пробелов и табов внутри заменяет на один пробел.
  • Приводит ASCII-буквы к нижнему регистру.
package normalize

import "strings"

// Clean trims spaces, collapses internal spaces to one
// and lowercases ASCII letters.
func Clean(s string) string {
    // Убираем крайние пробелы/табы, схлопываем внутренние пробелы/табы,
    // приводим к нижнему регистру.
    tokens := strings.Fields(s)
    if len(tokens) == 0 {
        return ""
    }
    return strings.ToLower(strings.Join(tokens, " "))
}

Сделайте табличный тест, который покрывает разные сценарии:

  • Пустая строка.
  • Одна и несколько внутренних групп пробелов/табов.
  • Разный регистр вхождения (миксы верх/низ).
  • Уже нормализованная строка.
  • Строка, содержащая небуквенные символы и несколько слов.
Показать решение
package normalize

import "testing"

func TestClean(t *testing.T) {
    tests := []struct {
        name string
        in   string
        want string
    }{
        {name: "empty", in: "", want: ""},
        {name: "spaces", in: "  hello   world  ", want: "hello world"},
        {name: "tabs", in: "\thexlet\t  go\t", want: "hexlet go"},
        {name: "case", in: "HeXLet", want: "hexlet"},
        {name: "mixed", in: "  A  b\tC  ", want: "a b c"},
        {name: "punct", in: " Hello,   world! ", want: "hello, world!"},
    }

    for _, tc := range tests {
        got := Clean(tc.in)
        if got != tc.want {
            t.Errorf("%s: got %q, want %q", tc.name, got, tc.want)
        }
    }
}

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

  1. Table-driven tests (Go Wiki)
  2. Subtests в Go (статья)
  3. testing.T.Run — документация

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

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

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

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

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

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

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

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