👑 Make main() great again

Сегодня поговорим про main. Поскольку это отправная точка любого нашего Go-приложения, мы хотим иметь возможность удобно тестировать происходящее там.

Представим код:

func main() {
	fmt.Println("Hello, world")
}

Даже для такого простого примера мы не можем взять и написать тест, проверяющий его корректность. Всё из-за того, что main не принимает и не возвращает никаких аргументов, и поделать с этим ничего нельзя - сигнатуру поменять мы не можем.

Но решение есть. Мы можем добавить новую функцию-делегат, перенести туда всю нашу логику, а в main просто вызвать её:

func main() {
	if err := realMain(os.Stdin, os.Stdout, os.Args); err != nil {
		log.Fatal(err)
	}
}

func realMain(in io.Reader, out io.Writer, args []string) error {
	fmt.Fprintln(out, "Hello, world!")

	return nil
}

Наша новая функция realMain принимает на вход стандартный ввод/вывод (os.Stdin, os.Stdout), а также аргументы командной строки (os.Args). В качестве типов данных используются максимально простые интерфейсы io.Reader и io.Writer. На выходе realMain может вернуть ошибку, если что-то пошло не так.

Здесь важно обратить внимание на то, что теперь мы не должны использовать стандартный ввод/вывод напрямую. Именно поэтому мы заменили fmt.Println(...), на fmt.Fprintln(out, ...).

Такую конструкцию мы уже можем без проблем протестировать:

func TestRealMain(t *testing.T) {
	// Вместо реального ввода/вывода мы будем использовать bytes.Buffer
	var in, out bytes.Buffer
	err := realMain(&in, &out, []string{"test"})
	assert.NoError(t, err)
	assert.Contains(t, out.String(), "Hello, world!")
}

Работа с флагами

Немного усложним наш пример. Теперь мы хотим выводить в консоль не просто “Hello, world!”, но и имя, которое можно задать с помощью флага -name. Если же имя не указано, то необходимо вернуть пользователю ошибку.

Флаги командной строки мы можем получить из параметра args следующим образом:

var (
	errNoNameGiven = errors.New("no name given")
)

func realMain(in io.Reader, out io.Writer, args []string) error {
	// args[0] содержит имя исполняемого файла
	flags := flag.NewFlagSet(args[0], flag.ContinueOnError)
	name := flags.String("name", "", "your name")

	// Остальные аргументы лежат в args, начиная с 1-го элемента
	if err := flags.Parse(args[1:]); err != nil {
		return err
	}

	if *name == "" {
		return errNoNameGiven
	}

	fmt.Fprintf(out, "Hello, %s!\n", *name)

	return nil
}

Мы можем воспользоваться табличными тестами, чтобы проверить поведение нашего приложения при разных флагах на входе:

func TestRealMain(t *testing.T) {
	testCases := []struct {
		name   string
		flags  []string
		err    error
		output string
	}{
		{
			name:   "valid",
			flags:  []string{"-name", "John Doe"},
			err:    nil,
			output: "Hello, John Doe!",
		},
		{
			name:   "no name given",
			flags:  []string{},
			err:    errNoNameGiven,
			output: "",
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			var in, out bytes.Buffer
			err := realMain(&in, &out, append([]string{"test"}, tc.flags...))
			assert.Equal(t, err, tc.err)
			assert.Contains(t, out.String(), tc.output)
		})
	}
}

Вывод

Такой подход делает наш код более идиоматичным и удобным в тестировании. Теперь мы прозрачно можем тестировать такие вещи, как работа с вводом/выводом, флаги и т.п.

Вопросы или предложения по поводу этого поста?

Обсудить в Твиттере