Тестирование инструмента cli с протоколированием

В предыдущей статье я описал, как запускать процессы и управлять ими. Следующим шагом будет реализация утилиты cli, использующей полученный код. Задача реализовать такую утилиту не очень сложная, а вот как ее протестировать — это уже интереснее. В этой статье я опишу один из способов тестирования, который заключается в чтении выходных журналов.

Флаги

Для запуска необходимого процесса нужно что-то передать утилите. Кроме того, для запуска необходимы значения конфигурации, например

и, возможно, что-то еще будет добавлено в будущем. Окончательный вид команды запуска хотелось бы видеть такой

Существует несколько отличных решений для работы с аргументами командной строки, например, spf13/cobra или urfave/cli. Но они хороши для построения интерфейса из множества команд, а для одной (как в моем примере) они избыточны. Поэтому я использовал библиотеку flag.
Я определил следующую структуру с аргументами:

type CliParams struct {
    key      *string
    cmdArgs  []string
    logLevel *string
    ttl      *int
}
Войти в полноэкранный режим Выход из полноэкранного режима

И сделал функцию, которая анализирует аргументы:

func parseCliParams(args []string) (*CliParams, *flag.FlagSet, error) {
    flags := flag.NewFlagSet("djob", flag.ContinueOnError)

    params := &CliParams{
        key:      new(string),
        cmdArgs:  []string{},
        logLevel: new(string),
        ttl:      new(int),
    }

    flags.StringVar(params.key, "k", "", "Lock key. Default: filename")
    flags.StringVar(params.logLevel, "l", "error", "Log level: debug|info|warn|error|fatal. Default: error")
    flags.IntVar(params.ttl, "t", 60, "Ttl in seconds. Default: 60")
    flags.Usage = func() {
        fmt.Fprint(os.Stderr, "Usage: djob [OPTIONS] -- [command with arguments and options] n")
        flags.PrintDefaults()
    }
    if err := flags.Parse(args); err != nil {
        return nil, flags, fmt.Errorf("parse flags: %v", err)
    }

    for k, arg := range args {
        if arg == "--" {
            params.cmdArgs = args[k+1:]
            break
        }
    }

    if len(params.cmdArgs) == 0 {
        return nil, flags, errors.New("command is required")
    }

    if len(*params.key) == 0 {
        params.key = &params.cmdArgs[0]
    }

    return params, flags, nil
}
Войти в полноэкранный режим Выход из полноэкранного режима

Для работы с аргументами мне нужно создать набор флагов. Таких наборов может быть несколько, но сейчас мне достаточно одного.
Я определил флаги, о которых писал ранее. Определяем им типы и передаем адрес переменной (поле в структуре), куда должно быть помещено разобранное значение. Чтобы пользователю было проще понять, как пользоваться инструментом, я определил функцию Usage. Она печатает в выходной информации, а также описание всех флагов и их значений по умолчанию.
Затем мне нужно вызвать метод набора флагов Parse([]string). Этот метод получает на вход аргументы пользователя. Следующим будет os.Args. Я намеренно сделал эту функцию независимой от os.Args для удобства тестирования. Флаги, которые я не указал в конфигурации, будут игнорироваться.
Далее я нашел флаг -- и поставил после него все, что есть в параметре cmdArgs. Это команда, которую пользователь хочет запустить. Кроме того, я сделал минимальную валидацию и на выходе получил структурную команду для запуска и параметры.

Ведение журнала

Прежде чем перейти к тестированию, я расскажу о протоколировании. Логирование — это еще один компонент, который важен для реализации тестирования. В любом случае, он необходим практически в любой программе, в том числе и в описываемой мной утилите.
Я использовал библиотеку logrus, так как в ней есть возможность перехвата логов с помощью хуков. И я использовал эту возможность для чтения логов во время тестирования. При этом код самой главной функции не нужно подстраивать под тесты.

type testHook struct {
    channel chan *log.Entry
}

func (hook *testHook) Fire(entry *log.Entry) error {
    hook.channel <- entry
    return nil
}

func (hook *testHook) Levels() []log.Level {
    return log.AllLevels
}

func newTestLogger() (*log.Logger, chan *log.Entry) {
    logger := log.New()
    logger.Out = ioutil.Discard
    logger.Level = log.DebugLevel

    entryChan := make(chan *log.Entry, 0)

    logger.Hooks.Add(&testHook{channel: entryChan})

    return logger, entryChan
}
Вход в полноэкранный режим Выход из полноэкранного режима

Я создал новый логгер с выключенным выводом и добавил к нему testHook. Это мгновенно отправляет сообщение логгера в канал вместо того, чтобы выводить его в консоль. Не только сами сообщения, но и вся структура, содержащая полную информацию, включая уровни записи лога, время и т.д. Все это очень удобно.

Главная функция

Теперь я могу перейти непосредственно к главной функции. В ней мне нужно вызвать parseCliParams и передать туда os.Args, а затем запустить процесс, как я описал в предыдущей статье.
Однако у меня есть два препятствия:

  • Я не могу протестировать функцию main.
  • У меня нет возможности передавать в тестах различные аргументы, кроме os.Args (иначе это будет очень некрасиво и ненадежно)Поэтому я реализовал Exec():
func Exec(osArgs []string, logger *log.Logger) int {

    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    params, flags, err := parseCliParams(osArgs)
    if err != nil {
        logger.Errorf("parse arguments: %v n", err.Error())
        flags.Usage()
        return 2
    }

    lvl, err := logrus.ParseLevel(*params.logLevel)
    if err != nil {
        logger.Fatalf("ivalid log level %s n", *params.logLevel)
    }
    logger.SetLevel(lvl)

    memLock := internal.NewMemoryLock()

    locked, err := memLock.Lock(ctx, *params.key, *params.clientId)
    if err != nil {
        logger.Errorf("Didn't lock: %v n", err)
    }
    if !locked {
        logger.Info("Already locked n")
        return 2
    }

    cmd, err := internal.ExecCmd(ctx, params.cmdArgs)
    if err != nil {
        logger.Errorf("exec command: %v n", err)
        return 1
    }
    logger.Info("started")

    defer func() {
        unlocked, err := memLock.UnLock(ctx, *params.key, *params.clientId)
        if err != nil {
            logger.Errorf("can't unlock: %v", err)
        }
        if !unlocked {
            logger.Info("Already unlocked")
        }
    }()

    exitCode, err := internal.WaitCmd(ctx, cmd)
    if err != nil {
        logger.Fatalf("wait command: %v", err)
        return 1
    }

    return exitCode
}
Вход в полноэкранный режим Выход из полноэкранного режима

memLock := internal.NewMemoryLock() Это простая реализация блокировок в памяти. В данном примере она необходима только для того, чтобы код работал. Больше блокировок я рассмотрю в следующих статьях, а сейчас их реализация не имеет значения.

cmd, err := internal.ExecCmd(ctx, params.cmdArgs) Завершаем функцию запуска нового процесса, которую я рассматривал в прошлой статье. Не обязательно знать содержание этой функции, чтобы понять материал этой статьи.

exitCode, err := internal.WaitCmd(ctx, cmd) Эта функция ожидает завершения процесса, запущенного с помощью ExecCmd. Она возвращает код ответа. В этом случае также нет необходимости знать содержание функции.

Забегая вперед, скажу, что весь полученный код утилиты будет выложен на GitHub. Но это будет после того, как я пройдусь по всем частям реализации шаг за шагом.

func main() {
    logger := log.New()
    os.Exit(Exec(os.Args, logger))
}
Вход в полноэкранный режим Выход из полноэкранного режима

Тестирование

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

#!/usr/bin/env bash

trap "echo SIGINT; exit" SIGINT

for (( i=1; i<$1; i++ ))
do
  echo "step $i"
  sleep $2
done

echo "finish"
Вход в полноэкранный режим Выход из полноэкранного режима

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

Далее — сам тест.

func TestRunJob(t *testing.T) {
    logger, entryChan := newTestLogger()
    testCases := []struct {
        name       string
        args       []string
        logEntries []*struct {
            level   log.Level
            message string
        }
    }{
        {
            name: "first",
            args: []string{"djob", "-k", "key", "-c", "client1", "l", "info", "--", "../tests/script.sh", "2", "2"},
            logEntries: []*struct {
                level   log.Level
                message string
            }{
                {
                    level:   log.InfoLevel,
                    message: "start",
                },
                {
                    level:   log.InfoLevel,
                    message: "finish",
                },
            },
        },
    }

    for _, tt := range testCases {
        t.Run(tt.name, func(t *testing.T) {
            ctx, cancel := context.WithCancel(context.Background())
            defer cancel()

            go func() {
                entryIndex := 0
                for {
                    select {
                    case <-ctx.Done():
                        return
                    case entry := <-entryChan:
                        if entryIndex > len(tt.logEntries) {
                            t.Errorf("got more than expected (%d) log messages", len(tt.logEntries))
                        }
                        if tt.logEntries[entryIndex].level != entry.Level {
                            t.Errorf("Expected level: %s, actual: %s", tt.logEntries[entryIndex].level, entry.Level)
                        }
                        if tt.logEntries[entryIndex].message != entry.Message {
                            t.Errorf("Expected log message: %s, actual: %s", tt.logEntries[entryIndex].message, entry.Message)
                        }
                        entryIndex++
                    }
                }
            }()

            cmd.Exec(tt.args, logger)
        })
    }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Позвольте мне описать весь код шаг за шагом.

testCases := []struct {
    name       string
    args       []string
    logEntries []*struct {
        level   log.Level
        message string
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

В logEntries я указал ожидаемые записи в журналах. Меня интересует только сообщение и уровень.

go func() {
                entryIndex := 0
                for {
                    select {
                    case <-ctx.Done():
                        return
                    case entry := <-entryChan:
                        if entryIndex > len(tt.logEntries) {
                            t.Errorf("got more than expected (%d) log messages", len(tt.logEntries))
                        }
                        if tt.logEntries[entryIndex].level != entry.Level {
                            t.Errorf("Expected level: %s, actual: %s", tt.logEntries[entryIndex].level, entry.Level)
                        }
                        if tt.logEntries[entryIndex].message != entry.Message {
                            t.Errorf("Expected log message: %s, actual: %s", tt.logEntries[entryIndex].message, entry.Message)
                        }
                        entryIndex++
                    }
                }
            }()
Войти в полноэкранный режим Выход из полноэкранного режима

В тесте я запускаю процедуру go. Она прослушивает канал с логами. А затем я просто проверяю, есть ли в журнале запись, которую я ожидаю, или нет.

cmd. Exec(tt.args, logger) Я запускаю главную функцию с необходимыми аргументами и настроенным логгером.

Заключение

Я описал, как можно организовать тестирование утилиты cli с помощью логгера. Далее вам останется только сделать все необходимые тест-кейсы. Это была вторая статья из цикла «Распределенные блокировки». Далее я перейду к реализации самих замков и выложу результат на GitHub.

Оцените статью
devanswers.ru
Добавить комментарий