Понимание тестирования подпроцессов Golang

Go

Недавно я столкнулся с проблемой при написании юнит-теста логгера, если я выполняю его напрямуюlogger.Fatal, потому что основная функция этой функции называетсяos.Exit(1), то процесс будет прекращен напрямую, а тестирующий пакет будет думать, что тест провален.Хотя это простая функция, и она почти бесполезна, но из-за обсессивно-компульсивного расстройства для него надо устроить юнит-тест. Позже я случайно нашел слайд о методах тестирования Эндрю Герранда (одного из разработчиков Golang) на Google I/O 2014 (см.приложение), в котором говорится о тестах подпроцессов, то есть тестах подпроцессов, следующим образом:

Sometimes you need to test the behavior of a process, not just a function.

   func Crasher() {
        fmt.Println("Going down in flames!")
        os.Exit(1)
    }

To test this code, we invoke the test binary itself as a subprocess:

func TestCrasher(t *testing.T) {
    if os.Getenv("BE_CRASHER") == "1" {
        Crasher()
        return
    }
    cmd := exec.Command(os.Args[0], "-test.run=TestCrasher")
    cmd.Env = append(os.Environ(), "BE_CRASHER=1")
    err := cmd.Run()
    if e, ok := err.(*exec.ExitError); ok && !e.Success() {
        return
    }
    t.Fatalf("process ran with err %v, want exit status 1", err)
}

Здесь упоминается, что если мы хотим протестировать поведение процесса, а не только функции, то мы можем создать подпроцесс через двоичный файл модульного теста для тестирования. Итак, возвращаясь назад, с точки зрения тестирования, нам нужно протестироватьFatalЭти два поведения функции:

  • распечатать текст журнала
  • Завершить процесс с ошибкой

Таким образом, наш модульный тест должен охватывать эти два поведения функции, и навыки тестирования подпроцессов, упомянутые Эндрю Джеррандом, подходят для этой ситуации. Таким образом, вы можете обратиться к этому примеру, даваяFatalнаписать модульный тест

Предположим, что наша фатальная функция представляет собой пакет, основанный на стандартном библиотечном пакете журналов.

    package logger

    func Fatal(v ...interface{}){
        log.Fatal(v...)
    }

Здесь мы можем сначала взглянуть на реализацию стандартного библиотечного лог-пакета (на самом деле zap/logrus и другие лог-пакетыFatalФункция также похожа и, наконец, вызываетos.Exit(1))

func Fatal(v ...interface{}) {
	std.Output(2, fmt.Sprint(v...))  //输出到标准输出/标准错误输出
	os.Exit(1) // 有错误地退出进程
}

Сначала напишите модульный тест обычным способом.

func TestFatal(t *testing.T) {
    Fatal("fatal log")
}

Результат выполнения юнит-теста следующий, как я уже говорил, результат FAIL

go test -v
=== RUN   TestFatal
2020/01/11 11:39:24 fatal log
exit status 1
FAIL    github.com/YouEclipse/mytest/log        0.001

Давайте попробуем написать тест подпроцесса, здесь я распечатываю стандартный вывод и стандартный вывод ошибок

func TestFatal(t *testing.T) {
	if os.Getenv("SUB_PROCESS") == "1" {
		Fatal("fatal log")
		return
	}
	var outb, errb bytes.Buffer
	cmd := exec.Command(os.Args[0], "-test.run=TestFatal")
	cmd.Env = append(os.Environ(), "SUB_PROCESS=1")
	cmd.Stdout = &outb
	cmd.Stderr = &errb
	err := cmd.Run()
	if e, ok := err.(*exec.ExitError); ok && !e.Success() {
		fmt.Print(cmd.Stderr)
		fmt.Print(cmd.Stdout)
		return
	}
	t.Fatalf("process ran with err %v, want exit status 1", err)
}

Выполните модульный тест, и результат будет успешным, оправдав наши ожидания.

go test -v
=== RUN   TestFatal
2020/01/11 11:40:38 fatal log
--- PASS: TestFatal (0.00s)
PASS
ok      github.com/YouEclipse/mytest/log        0.002s

Конечно, мы должны не только знать, что есть, но и знать, почему. Разберем, почему тестовый код подпроцесса написан именно так

  • пройти черезos.GetenvПолучите переменную среды, значение здесь пустое, поэтому Fatal не будет выполняться

  • Определенныйoutb,errb, здесь для последующего захвата stdout и stderr

  • перечислитьexec.CommandПостроить структуру Cmd на основе переданных параметров

exec — это пакет в стандартной библиотеке, предназначенный для выполнения команд, поэтому я не буду вдаваться в подробности. Мы видим, что первый параметр exec.Cmmand — это имя команды или двоичного файла, который нужно выполнить, а второй параметр — неопределенный параметр, который является параметром команды, которую нам нужно выполнить.
Здесь мы передаем первый параметрos.Args[0], os.Args[0]— это путь к бинарному файлу программы при запуске программы, а второй параметр — это параметр при выполнении бинарного файла. Что касается того, почемуos.Args[0]вместоos.Args[1]илиos.Args[2]Ну давайте выполнимgo test -n, вы увидите множество выходных данных (самое нерелевантное опущено)

mkdir -p $WORK/b001/

#
# internal/cpu
#
... 
/usr/local/go/pkg/tool/linux_amd64/compile -o ./_pkg_.a -trimpath "$WORK/b001=>" -p main -complete -buildid 5WmoKx2_LnkcztVfW1Bj/5WmoKx2_LnkcztVfW1Bj -dwarf=false -goversion go1.13.5 -D "" -importcfg ./importcfg -pack -c=4 ./_testmain.go
...

/usr/local/go/pkg/tool/linux_amd64/link -o $WORK/b001/log.test -importcfg $WORK/b001/importcfg.link -s -w -buildmode=exe -buildid=o8I_q2gkkk-Xda8yeh2G/5WmoKx2_LnkcztVfW1Bj/5WmoKx2_LnkcztVfW1Bj/o8I_q2gkkk-Xda8yeh2G -extld=gcc $WORK/b001/_pkg_.a
...
cd /home/yoyo/go/src/github.com/YouEclipse/mytest/log
TERM='dumb' /usr/local/go/pkg/tool/linux_amd64/vet -atomic -bool -buildtags -errorsas -nilfunc -printf $WORK/b052/vet.cfg
$WORK/b001/log.test -test.timeout=10m0s

Из вывода мы можем знатьgo testПоследним шагом является компиляция и компоновка исходных файлов в двоичные файлы (и, конечно, статические проверки govet) для выполнения. Фактическиgo testиgo runНаконец вызывается та же самая функция, и в этой статье она не слишком подробно обсуждается, подробности можно посмотреть в исходном коде.cmd/go/internal/test/test.goиcmd/go/internal/work/build.goсодержимое этих двух файлов.

и-nпараметры, которые можно распечататьgo testилиgo runВсе команды используются в процессе выполнения, поэтому мы выполняем окончательный бинарный файл и выводим его на последнюю строку вывода.-test.timeout=10m0sФлаг тайм-аута по умолчанию. А os.Args — это константа пакета os, при запуске процесса в него будет записана исполняемая команда и флаг

// Args hold the command-line arguments, starting with the program name.
var Args []string

такos.Args[0]Естественно, получается полное имя файла скомпилированного бинарника.

второй параметр-test.run=TestFatalфлаг для выполнения бинарного файла,test.runИмя функции теста, заданное флагом. когда мы выполняемgo test -run TestFatal, на самом деле окончательное исполнение$WORK/b001/log.test -run=TestFatalДругие флаги могут быть выполненыgo help testflagПосмотреть или обратитьсяcmd/go/internal/test/testflag.goв файлеtestFlagDefnВходящие, конкретные определения и описания находятся в исходном коде.

  • cmd.Env устанавливает переменную среды для запуска дочернего процесса. os.Environ() получает копию текущей переменной среды, мы добавляемSUB_PROCESSПользователь переменной среды определяет, является ли процесс дочерним.
  • cmd.Stdout = &outb
  • cmd.Stderr = &ошибка Захватите вывод аннотации и стандартную ошибку во время работы подпроцесса, потому что нам нужно проверить, соответствует ли вывод
  • cmd.Выполнить() Запустите дочерний процесс и дождитесь возврата результата. Может вернуться, если он завершится.exec.ExitError, вы можете получить exited statusCode, и наша цель — проверить, завершается ли процесс
  • В дочернем процессе переменная окружения в это времяSUB_PROCESSзначение1, то он будет выполнятьсяFatalФункция, основной процесс получает код выхода и печатает вывод дочернего процесса.

Пока что принцип этого тестового кода ясен.

Однако ложка дегтя заключается в том, что в реализацииgo test -coverПри выполнении статистики тестового покрытия функции модульных тестов, запущенных подпроцессами, учитываться не будут.

приложение

  1. Testing Techniques - Andrew Gerrand