---
title: "Тестирование команд CLI"
description: "Тестирование Cobra: SetArgs, SetOut и bytes.Buffer, табличные тесты CLI, ошибки RunE и коды выхода, golden-файлы для вывода команд."
course: "Cobra: CLI-приложения на Go"
url: "https://golangmaster.ru/courses/cobra/lessons/20-testirovanie-cobra/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

*Курс «Cobra: CLI-приложения на Go», урок 20 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/cobra/lessons/20-testirovanie-cobra/*

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

У команды 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), который собирает команду, подставляет буферы, исполняет и возвращает вывод с ошибкой: тело теста сжимается до трёх строк. На больших проектах фабрика принимает структуру зависимостей — путь к хранилищу, тайминг — и тесты подсовывают временный каталог вместо реального хранилища; к тому, как раскладывать такие зависимости по пакетам, вернёмся в уроке про [структуру проекта](/courses/cobra/lessons/21-struktura-proekta/).

### Фабрика и хелпер 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?

- ✅ Аргументы тестового бинарника: флаги go test из os.Args
- Ничего: Execute не работает без SetArgs
- Срез {"list"} — дефолт из справки
- Панику на пустом os.Args

Без SetArgs Execute читает os.Args, а в тестовом бинарнике это аргументы go test: -test.v, -test.run и прочие. Cobra честно попытается разобрать их как флаги notes и, скорее всего, ответит ошибкой unknown flag — тест падает с непонятным на первый взгляд сообщением. SetArgs в каркасе обязателен всегда.

### Что вернёт Execute

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

**Что выведет программа?**

- false — аргумент необязательный
- ✅ true — ошибка requires at least 1 arg(s)
- true — паника внутри Cobra
- false — Cobra подставит пустую заметку

Валидатор Args отрабатывает до RunE и возвращает ошибку с текстом вида requires at least 1 arg(s), only received 0. Для теста это удобно: валидация аргументов проверяется тем же каркасом — подставили плохие аргументы, проверили err. RunE при провале валидации не вызывается вовсе.

### Табличные тесты для 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. Какой код выхода получит скрипт?

- 0 — ошибка уже напечатана
- ✅ 1 — стандарт для ошибок Execute
- 2 — код валидации Cobra
- Код зависит от текста ошибки

main переводит любую ошибку Execute в os.Exit(1): различия между причинами живут в типах и сентинелах ошибок, а не в кодах. Ноль скрипт увидит только при nil. Расширенную иерархию — 2 для сбоев хранилища — main может добавить сам, но это осознанное решение, а не поведение Cobra по умолчанию.

> **Тест, который «проходит» из-за os.Args.** Тонкий кейс: тест вызывает 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 с модулями проекта.

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

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

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

Подсказка: Тип буфера из пакета bytes; аргументы подменяются методом, имя которого совпадает с тем, что он подменяет; Execute возвращает ошибку.

Правильные ответы в пропусках: `Buffer`, `Args`, `err`

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

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

Правильный порядок строк:

1. `buf := new(bytes.Buffer)`
2. `rootCmd.SetOut(buf)`
3. `rootCmd.SetErr(buf)`
4. `rootCmd.SetArgs([]string{"list"})`
5. `err := rootCmd.Execute()`
6. `got := buf.String()`

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

- **SetArgs** — Подмена os.Args срезом строк: rootCmd.SetArgs([]string{"list"}). Обязательна в каждом тесте — иначе Execute разберёт флаги go test.
- **SetOut / SetErr** — Перенаправление вывода команды в writer — обычно bytes.Buffer. Команды пишут через cmd.OutOrStdout() и ErrOrStderr, тест читает буфер после Execute.
- **newRootCmd** — Фабрика, создающая свежее дерево команд на каждый вызов: изолирует тесты от персистентных значений флагов pflag и глобальных переменных.
- **errors.Is** — Проверка сентинел-ошибки сквозь обёртки fmt.Errorf с %w: тест отличает errDeclined от сбоя хранилища, хотя код выхода у обоих единица.
- **golden-файлы** — Эталонный вывод в testdata/*.golden, побайтовое сравнение с буфером, обновление флагом -update. Опасность — нестабильные данные внутри эталона.

### Что дальше

Тесты — последний навык до финала курса: в [итоговом проекте](/courses/cobra/lessons/25-itogovyj-proekt/) менеджер задач снабжён табличными тестами на add и list, а [структура большого проекта](/courses/cobra/lessons/21-struktura-proekta/) покажет, где лежат testdata и фабрики команд в реальном репозитории. Приёмы тестирования переносятся и на веб: табличные тесты и буферы в [тестировании Gin](/courses/gin/lessons/21-testirovanie/) выглядят почти так же. Обзор курса — на странице [Cobra](/courses/cobra/). Практика: переведите свою утилиту на фабрику newRootCmd, напишите runCmd и табличный тест на три сценария одной команды, затем проверьте сентинел отказа через errors.Is.

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

- Каркас теста: 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() для текста. Так тест отличает отказ пользователя от сбоя хранилища при одном и том же коде.
