У команды 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.
Первый тест команды
GOfunc 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
GOfunc 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
GOfunc 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 в тестах остаётся для реальных сообщений.
Проверка сентинел-ошибки
GOfunc 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. Какой код выхода получит скрипт?
Тонкий кейс: тест вызывает Execute на глобальном rootCmd без SetArgs — и почему-то зелёный. Разбор: os.Args тестового бинарника начинается с go test -run ..., Cobra нашла флаг --test.run? Нет — она упала, но на глобальной rootCmd уже висел SilenceErrors из другого теста, ошибка съелась, и assertion на пустой вывод прошёл. Здесь сходятся две ошибки урока: отсутствие SetArgs и общее глобальное состояние. Лечение каркасное: runCmd с фабрикой и SetArgs на каждый вызов, глобального rootCmd в пакете нет. И правило ревью: любой тест CLI, где не видно SetArgs, читается как сломанный, пока не доказано обратное. Проверяйте каркас глазами: утилита, которая в тестах не подменяет аргументы, тестирует go test, а не себя.
Golden-файлы: когда вывод слишком длинный#
Точный вывод большой таблицы или JSON в Contains не вставишь: строк много, формат живой. Решение — golden-файлы: эталонный вывод лежит в testdata/*.golden, тест сравнивает буфер с файлом побайтно, а при запуске с флагом -update записывает свежий вывод на место эталона. Это быстро строится поверх того же каркаса: runCmd вернул строку — сравнили с golden. Ловушки две: нестабильные данные — даты, абсолютные пути, порядок ключей карт — попадают в эталон и ломают тест на другой машине; вычищайте их перед сравнением или подставляйте фиксированные значения. И дисциплина обновления: -update запускается осознанно, а дифф golden-файла читается в ревью как настоящая правка пользовательского вывода. Хелперы для сравнения уже написаны: библиотека github.com/sebdah/goldie сама кладёт эталон в testdata/golden и поддерживает тот же флаг -update; в маленьких проектах хватает и ручных пяти строк поверх os.WriteFile. Полный конвейер прогоняется локально: go test ./... — песочница тестовый раннер не эмулирует, здесь нужен живой Go с модулями проекта.
Соберите каркас теста
Впишите буфер, подмену аргументов и приём ошибки.
Соберите каркас runCmd по строкам
Расставьте: буфер, подмену stdout и stderr, аргументы, Execute и чтение буфера.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: тестирование CLI
Что дальше#
Тесты — последний навык до финала курса: в итоговом проекте менеджер задач снабжён табличными тестами на 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 без запуска бинарника?
Внутри процесса: 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() для текста. Так тест отличает отказ пользователя от сбоя хранилища при одном и том же коде.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
- Структура большого CLI-проекта
- Сигналы и контексты: корректное завершение
- Сборка и версии: ldflags и goreleaser
- Viper: слежение за конфигом и секреты
- Генерация документации: man и markdown
- Кастомные подсказки: ValidArgsFunction
- Автодополнение: bash, zsh, fish, powershell
- Красивый вывод: цвета и таблицы в терминале
- Все курсы по Go
Следующая ступень учебного пути
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