В предыдущей статье я описал, как запускать процессы и управлять ими. Следующим шагом будет реализация утилиты 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 = ¶ms.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.