Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 20 из 25 ~18 мин +50 XP

Тестирование команд CLI

Тестирование Cobra устроено проще, чем кажется: SetArgs подменяет аргументы командной строки, SetOut и SetErr направляют вывод в bytes.Buffer, и весь Execute прогоняется в памяти без единого процесса. Разберём каркас теста, табличные тесты для CLI, проверку ошибок RunE и кодов выхода и упомянем golden-файлы для сложного вывода.

Содержание урока · 9 разделов

У команды notes list баг, который проявляется раз в месяц: кто-то поменял формат даты, руками запустил утилиту, увидел примерно то же и закрыл терминал. Через три месяца пользователь приносит скриншот. Против этой истории есть только одно средство — unit тесты cli go, которые прогоняют команды при каждом go test. Страх «как тестировать то, что читает os.Args и пишет в stdout», напрасен: Cobra построена так, что os.Args, stdout и stderr в ней — подменяемые точки. rootCmd.SetArgs задаёт аргументы из теста, rootCmd.SetOut и SetErr суют буферы вместо потоков, Execute отрабатывает целиком — с парсингом флагов, хуками и валидацией — и тест делает assertions по буферу и ошибке. Никакого exec.Command, никаких бинарников в темпах, никаких sleep: один тест команды — миллисекунды. В этом уроке соберём каркас, который доедет до итогового проекта вместе с остальными помощниками.

Тестирование Cobra: SetArgs и SetOut#

Execute без подготовки читает os.Args — это поведение продакшена. rootCmd.SetArgs([]string{"list", "--limit", "5"}) подменяет его: Cobra разберёт этот срез, как будто пришёл из шелла. Вывод уходит туда, куда указывает rootCmd.SetOut(writer): передайте &bytes.Buffer{}, и всё, что команды печатают через cmd.OutOrStdout(), окажется в буфере. Аналогично SetErr ловит сообщения об ошибках и usage при неудачном парсинге — это важно, потому что при ошибке Cobra пишет в stderr, и без SetErr она бы попала в вывод самого go test. Читается буфер после Execute: got := buf.String(), дальше strings.Contains или точное сравнение через strings.TrimSpace. Комбинация SetArgs плюс SetOut плюс Execute плюс assertion — весь каркас тестирования cobra; остальное — дисциплина: тест должен видеть свежую команду, а не глобальную, измученную предыдущими тестами. Про это — следующий блок, это самая частая ошибка в тестах CLI.

Первый тест команды

GO
func TestListOutput(t *testing.T) {
        var buf bytes.Buffer
        rootCmd.SetOut(&buf)
        rootCmd.SetErr(&buf)
        rootCmd.SetArgs([]string{"list", "--limit", "5"})
        err := rootCmd.Execute()

        if err != nil {
                t.Fatalf("execute: %v", err)
        }
        if !strings.Contains(buf.String(), "3 заметки") {
                t.Errorf("нет счётчика в выводе:\n%s", buf.String())
        }
}

Буфер один на stdout и stderr: неудачный парсинг напишет usage туда же, и тест увидит полную картину. Проверка через Contains устойчивее точного сравнения — вывод живёт и меняется, важен смысл. Сообщение ошибки печатает сам буфер: падающий тест сразу показывает, что утилита вывела на самом деле, без запуска руками.

newRootCmd: свежая команда на каждый тест#

Глобальная rootCmd — минное поле для тестов: значения флагов в pflag персистентны между вызовами Execute, и тест, поставивший --limit 5, оставит его следующему; в списке команд и переменных пакета то же самое. Решение стандартное для CLI на Cobra — фабрика: newRootCmd() создаёт корень, добавляет подкоманды и возвращает всё целиком. main вызывает её один раз, каждый тест — заново, и тесты перестают влиять друг на друга. Вместе с фабрикой заводится хелпер runCmd(args ...string), который собирает команду, подставляет буферы, исполняет и возвращает вывод с ошибкой: тело теста сжимается до трёх строк. На больших проектах фабрика принимает структуру зависимостей — путь к хранилищу, тайминг — и тесты подсовывают временный каталог вместо реального хранилища; к тому, как раскладывать такие зависимости по пакетам, вернёмся в уроке про структуру проекта.

Фабрика и хелпер runCmd

GO
func newRootCmd() *cobra.Command {
        root := &cobra.Command{Use: "notes"}
        root.AddCommand(newListCmd(), newAddCmd())
        return root
}

func runCmd(args ...string) (string, error) {
        buf := new(bytes.Buffer)
        cmd := newRootCmd()
        cmd.SetOut(buf)
        cmd.SetErr(buf)
        cmd.SetArgs(args)
        err := cmd.Execute()
        return buf.String(), err
}

Фабрика возвращает свежую команду без внешнего состояния: флаги объявлены внутри newListCmd и живут на экземпляре. Хелпер возвращает вывод и ошибку пары Execute — тест сам решает, что с ними делать. Обратите внимание на порядок: Execute вызывается до чтения буфера, иначе в got попадёт пустая строка.

Проверка: тест без SetArgs

В тесте вызвали rootCmd.Execute(), забыв SetArgs. Что разберёт Cobra?

Что вернёт Execute

Прочитайте код и выберите, что он выведет:

// у команды add: Args: cobra.MinimumNArgs(1)
err := runCmd("add") // без обязательного аргумента
fmt.Println(err != nil)

Табличные тесты для CLI#

У CLI набор сценариев повторяется: корректные аргументы, нехватка аргументов, неизвестный флаг, отказ пользователя — и на каждый хочется одинаковых проверок. Табличные тесты складывают сценарии в срез структур и гоняют один цикл: имя кейса, args, wantErr, wantOut. Отдельное поле wantErr — не только bool, но и подстрока ошибки: текст ошибки — часть контракта команды, потому что его видит пользователь. t.Run даёт имена кейсам в выводе и запускает только нужный через -run с именем кейса. Табличная форма дисциплинирует и другое: когда сценарий виден списком, легко заметить, что непокрытых веток стало больше, чем кажется, — скажем, --limit 0 и --limit -1 живут в одной таблице рядом. Внутри кейса используются тот же runCmd и те же assertions из первого теста — каркас не меняется, растёт только таблица.

Таблица сценариев команды add

GO
func TestAdd_Table(t *testing.T) {
        tests := []struct {
                name    string
                args    []string
                wantErr string
                wantOut string
        }{
                {"пустой текст", []string{"add", ""}, "", "пустая заметка"},
                {"есть текст", []string{"add", "купить кофе"}, "", "сохранено"},
                {"лишний флаг", []string{"add", "--nope", "x"}, "unknown flag", ""},
        }
        for _, tt := range tests {
                t.Run(tt.name, func(t *testing.T) {
                        out, err := runCmd(tt.args...)
                        if tt.wantErr != "" && (err == nil ||
                                !strings.Contains(err.Error(), tt.wantErr)) {
                                t.Fatalf("err = %v, хотим %q", err, tt.wantErr)
                        }
                        if !strings.Contains(out, tt.wantOut) {
                                t.Errorf("out = %q", out)
                        }
                })
        }
}

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

Коды выхода: ошибка RunE в тесте#

Код выхода CLI рождается из ошибки RunE: main получает её от Execute и вызывает os.Exit(1) — схема из урока про ошибки и коды выхода. В тесте процесс не выходит, зато возвращается та самая ошибка, и это удобнее, чем код: ошибка типизируется. Сентинелы вроде errDeclined сравниваются через errors.Is — тест отличает честный отказ пользователя от сбоя хранилища, хотя оба дают код 1. Для многослойных ошибок с fmt.Errorf и %w проверяется подстрока через err.Error() или errors.Is по корневому сентинелу. Отдельно протестируйте ветку, где команда падает на валидации данных, а не на парсинге: непарсируемый JSON в хранилище должен дать внятную ошибку, а не панику. Помните про SilenceUsage: с ним usage не печатается на каждую ошибку RunE, и буфер stderr в тестах остаётся для реальных сообщений.

Проверка сентинел-ошибки

GO
func TestResetDeclined(t *testing.T) {
        // stdin не терминал и --yes не передан: путь из урока про интерактивный ввод
        _, err := runCmd("reset")
        if err == nil {
                t.Fatal("ожидаем отказ: stdin пуст, подтверждения нет")
        }
        if !errors.Is(err, errDeclined) {
                t.Fatalf("err = %v, хотим errDeclined", err)
        }
}

errors.Is работает через %w в обёртках: где-то на пути RunE вернул fmt.Errorf("...: %w", errDeclined), и тест видит сентинел сквозь слои. Различать ошибки важно: отказ пользователя — код 1 без алерта, сбой хранилища — код 2 и алерт. Сентинелы из RunE — то, что делает CLI проверяемым без хрупкого парсинга строк.

Проверка: код выхода из main

RunE вернула ошибку, SilenceUsage включён, main честно обрабатывает Execute. Какой код выхода получит скрипт?

Golden-файлы: когда вывод слишком длинный#

Точный вывод большой таблицы или JSON в Contains не вставишь: строк много, формат живой. Решение — golden-файлы: эталонный вывод лежит в testdata/*.golden, тест сравнивает буфер с файлом побайтно, а при запуске с флагом -update записывает свежий вывод на место эталона. Это быстро строится поверх того же каркаса: runCmd вернул строку — сравнили с golden. Ловушки две: нестабильные данные — даты, абсолютные пути, порядок ключей карт — попадают в эталон и ломают тест на другой машине; вычищайте их перед сравнением или подставляйте фиксированные значения. И дисциплина обновления: -update запускается осознанно, а дифф golden-файла читается в ревью как настоящая правка пользовательского вывода. Хелперы для сравнения уже написаны: библиотека github.com/sebdah/goldie сама кладёт эталон в testdata/golden и поддерживает тот же флаг -update; в маленьких проектах хватает и ручных пяти строк поверх os.WriteFile. Полный конвейер прогоняется локально: go test ./... — песочница тестовый раннер не эмулирует, здесь нужен живой Go с модулями проекта.

Соберите каркас теста

Впишите буфер, подмену аргументов и приём ошибки.

var buf bytes. rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) rootCmd.Set([]string{"list"}) := rootCmd.Execute()

Соберите каркас runCmd по строкам

Расставьте: буфер, подмену stdout и stderr, аргументы, Execute и чтение буфера.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: тестирование CLI

Термин

SetArgs

нажмите, чтобы перевернуть
Определение

Подмена os.Args срезом строк: rootCmd.SetArgs([]string{"list"}). Обязательна в каждом тесте — иначе Execute разберёт флаги go test.

1 / 5

Что дальше#

Тесты — последний навык до финала курса: в итоговом проекте менеджер задач снабжён табличными тестами на add и list, а структура большого проекта покажет, где лежат testdata и фабрики команд в реальном репозитории. Приёмы тестирования переносятся и на веб: табличные тесты и буферы в тестировании Gin выглядят почти так же. Обзор курса — на странице Cobra. Практика: переведите свою утилиту на фабрику newRootCmd, напишите runCmd и табличный тест на три сценария одной команды, затем проверьте сентинел отказа через errors.Is.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Каркас теста: SetArgs подменяет os.Args, SetOut и SetErr суют bytes.Buffer, Execute отрабатывает в памяти.
  • Без SetArgs тест разбирает аргументы go test — первый признак сломанного каркаса.
  • Фабрика newRootCmd создаёт свежее дерево на тест: значения флагов pflag переживают Execute.
  • Табличные тесты: срез кейсов с args, wantErr и wantOut, цикл с t.Run.
  • Коды выхода рождаются из ошибки RunE: тест проверяет err через errors.Is и сентинелы.
  • Golden-файлы в testdata закрывают точные сравнения длинного вывода; дифф эталона читают в ревью.
  • Ошибку печатает буфер в сообщении теста — падение сразу показывает, что увидел бы пользователь.

Домашнее задание

Переведите свою утилиту на каркас из урока: фабрика newRootCmd, хелпер runCmd с буфером и SetArgs. Напишите табличный тест на три сценария одной команды — успех, ошибка валидации, неизвестный флаг — и отдельный тест на сентинел-ошибку через errors.Is. Прогоните go test ./... локально и убедитесь, что тесты не зависят друг от друга при запуске по одному и вместе. Отдельный кейс добавьте в таблицу на --limit 0: пограничные значения флагов любят ломаться тихо, и табличный тест ловит их дешевле, чем ручной прогон.

Личные заметкиПусто
Хранятся только в вашем браузере (Cobra · Тестирование команд CLI)

Вопросы и ответы по уроку

Как протестировать команду Cobra без запуска бинарника?

Внутри процесса: rootCmd.SetArgs([]string{"list"}) подменяет аргументы, rootCmd.SetOut(&buf) и SetErr(&buf) направляют вывод в bytes.Buffer, вызов rootCmd.Execute() прогоняет парсинг, валидацию и RunE целиком. Тест проверяет buf.String() и возвращённую ошибку — без exec.Command и сборки бинарника.

Почему тесты CLI на Cobra влияют друг на друга?

Обычно из-за глобальной rootCmd: значения флагов в pflag сохраняются после Execute, и --limit 5 из прошлого теста достаётся следующему. Решение — фабрика newRootCmd(), которая строит свежее дерево команд на каждый вызов: main зовёт её один раз, каждый тест — заново.

Как проверить код выхода команды в тесте?

Код выхода формирует main из ошибки Execute: os.Exit(1) при err != nil. В тесте процесс не умирает — проверяется сама ошибка: err != nil для факта отказа, errors.Is(err, errDeclined) для конкретного сентинела и err.Error() для текста. Так тест отличает отказ пользователя от сбоя хранилища при одном и том же коде.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Bubble Tea25 уроков~10 чпрактика25 уроков уже доступны

Bubble Tea: TUI-приложения в терминале

Интерактивный курс Bubble Tea — TUI-фреймворка на Go: Elm-архитектура, bubbles, Lip Gloss, мышь и клавиатура. 25 уроков с примерами и итоговым приложением.

  • Понимать Elm-архитектуру: Model, Update, View, Init
  • Обрабатывать клавиатуру и мышь в терминале
  • Писать асинхронные операции через tea.Cmd, Batch и Sequence
  • Использовать bubbles: list, textinput, textarea, table, viewport, progress, spinner