Тестирование сценариев os.Exit в Go с информацией о покрытии (coveralls.io/Goveralls)

Этот вопрос: Как протестировать сценарии os.exit в Go (и самый высокий проголосовавший ответ в нем) описывает, как тестировать сценарии os.Exit() внутри go. Поскольку os.Exit() не может быть легко перехвачен, используемый метод заключается в повторном вызове двоичного файла и проверке значения выхода. Этот метод описан в слайд 23 на этой презентации Эндрю Джерранда (одного из основных членов команды Go); код очень прост и воспроизводится полностью ниже.

Соответствующие тестовые и основные файлы выглядят как (обратите внимание, что эта пара файлов является MVCE):

package foo

import (
    "os"
    "os/exec"
    "testing"
)

func TestCrasher(t *testing.T) {
    if os.Getenv("BE_CRASHER") == "1" {
        Crasher() // This causes os.Exit(1) to be called
        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() {
        fmt.Printf("Error is %v\n", e)
    return
    }
    t.Fatalf("process ran with err %v, want exit status 1", err)
}

и

package foo

import (
    "fmt"
    "os"
)

// Coverage testing thinks (incorrectly) that the func below is
// never being called
func Crasher() {
    fmt.Println("Going down in flames!")
    os.Exit(1)
}

Однако этот метод, по-видимому, страдает некоторыми ограничениями:

  • Тестирование покрытия с помощью goveralls/coveralls.io не работает - см., например, пример здесь (тот же код, что и выше но вставляем в github для вашего удобства), который производит тест покрытия здесь, то есть он не записывает выполняемые тестовые функции. Учтите, что вам не нужны эти ссылки, чтобы ответить на вопрос - приведенный выше пример будет работать нормально - они просто там, чтобы показать, что произойдет, если вы поместите выше в github и пройдите все это через travis coveralls.io

  • Перезапуск тестового двоичного файла выглядит хрупким.

В частности, в соответствии с запросом, вот скриншот (а не ссылка) для отказа покрытия; красное затенение указывает, что в отношении coveralls.io речь не идет о Crasher().

Тест покрытия, показывающий, что Crasher() не называется

Есть ли способ обойти это? В частности, первая точка.

На уровне голанга проблема такова:

  • Рамка Goveralls запускает go test -cover ..., которая вызывает вышеуказанный тест.

  • Тест выше вызывает exec.Command / .Run без -cover в аргументах ОС

  • Безусловное размещение -cover и т.д. в списке аргументов непривлекательно, так как затем он запускает тест покрытия (как подпроцесс) в тесте без покрытия и анализирует список аргументов для наличия -cover и т.д., представляется сложным решением.

  • Даже если я помещаю -cover и т.д. в список аргументов, я понимаю, что у меня было бы два выхода покрытия, записанные в один и тот же файл, который не сработает - им потребуется слияние как-то. Самое близкое, что у меня есть, это эта проблема golang.


Резюме

То, что мне нужно, - это простой способ запустить тестирование покрытия (предпочтительно через travis, goveralls и coveralls.io), где можно обойти оба теста, где тестируемая процедура завершается с помощью os.Exit(), и где отмечен охват этого теста. Мне бы очень хотелось, чтобы он использовал метод re-exec выше (если это может быть сделано для работы), если это можно сделать для работы.

В решении должно быть указано тестирование покрытия Crasher(). Исключение Crasher() из тестирования покрытия не является вариантом, так как в реальном мире то, что я пытаюсь сделать, это проверить более сложную функцию, где где-то глубоко внутри, при определенных условиях, она вызывает, например. log.Fatalf(); то, что я тестирую на покрытие, заключается в том, что тесты для этих условий работают правильно.

Ответ 1

С небольшим рефакторингом вы можете легко достичь покрытия на 100%.

foo/bar.go:

package foo

import (
    "fmt"
    "os"
)

var osExit = os.Exit

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

И код тестирования: foo/bar_test.go:

package foo

import "testing"

func TestCrasher(t *testing.T) {
    // Save current function and restore at the end:
    oldOsExit := osExit
    defer func() { osExit = oldOsExit }()

    var got int
    myExit := func(code int) {
        got = code
    }

    osExit = myExit
    Crasher()
    if exp := 1; got != exp {
        t.Errorf("Expected exit code: %d, got: %d", exp, got)
    }
}

Запуск go test -cover:

Going down in flames!
PASS
coverage: 100.0% of statements
ok      foo        0.002s

Да, вы могли бы сказать, что это работает, если os.Exit() вызывается явно, но что, если os.Exit() вызывается кем-то другим, например. log.Fatalf()?

Те же методы работают там, вам просто нужно переключить log.Fatalf() вместо os.Exit(), например:

Соответствующая часть foo/bar.go:

var logFatalf = log.Fatalf

func Crasher() {
    fmt.Println("Going down in flames!")
    logFatalf("Exiting with code: %d", 1)
}

И код тестирования: TestCrasher() в foo/bar_test.go:

func TestCrasher(t *testing.T) {
    // Save current function and restore at the end:
    oldLogFatalf := logFatalf
    defer func() { logFatalf = oldLogFatalf }()

    var gotFormat string
    var gotV []interface{}
    myFatalf := func(format string, v ...interface{}) {
        gotFormat, gotV = format, v
    }

    logFatalf = myFatalf
    Crasher()
    expFormat, expV := "Exiting with code: %d", []interface{}{1}
    if gotFormat != expFormat || !reflect.DeepEqual(gotV, expV) {
        t.Error("Something went wrong")
    }
}

Запуск go test -cover:

Going down in flames!
PASS
coverage: 100.0% of statements
ok      foo     0.002s

Ответ 2

Интерфейсы и mocks

Использование интерфейсов Go позволяет создавать макетные композиции. Тип может иметь интерфейсы как связанные зависимости. Эти зависимости могут быть легко заменены макетами, соответствующими интерфейсам.

type Exiter interface {
    Exit(int)
}

type osExit struct {}

func (o* osExit) Exit (code int) {
    os.Exit(code)
}

type Crasher struct {
    Exiter
}

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

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

type MockOsExit struct {
    ExitCode int
}

func (m *MockOsExit) Exit(code int){
    m.ExitCode = code
}

func TestCrasher(t *testing.T) {
    crasher := &Crasher{&MockOsExit{}}
    crasher.Crash() // This causes os.Exit(1) to be called
    f := crasher.Exiter.(*MockOsExit)
    if f.ExitCode == 1 {
        fmt.Printf("Error code is %d\n", f.ExitCode)
        return
    }
    t.Fatalf("Process ran with err code %d, want exit status 1", f.ExitCode)
}

Недостатки

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

Функции являются гражданами первого класса

Зависимость параметров

Функции являются гражданами первого класса в Го. Допускается множество операций с функциями, поэтому мы можем делать некоторые трюки с функциями напрямую.

Используя операцию "pass as parameter", мы можем выполнить инъекцию зависимостей:

type osExit func(code int)

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

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

var exit_code int 
func os_exit_mock(code int) {
     exit_code = code
}

func TestCrasher(t *testing.T) {

    Crasher(os_exit_mock) // This causes os.Exit(1) to be called
    if exit_code == 1 {
        fmt.Printf("Error code is %d\n", exit_code)
        return
    }
    t.Fatalf("Process ran with err code %v, want exit status 1", exit_code)
}

Недостатки

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

Переменная замена

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

var osExit = os.Exit

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

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

var exit_code int
func osExitMock(code int) {
    exit_code = code
}

func TestCrasher(t *testing.T) {
    origOsExit := osExit
    osExit = osExitMock
    // Don't forget to switch functions back!
    defer func() { osExit = origOsExit }()

    Crasher()
    if exit_code != 1 {
        t.Fatalf("Process ran with err code %v, want exit status 1", exit_code)
    }
}

недостатки

Неявно и легко сбой.

Замечания по дизайну

Если вы планируете объявить некоторую логику ниже Exit, логика выхода должна быть изолирована блоком else или дополнительным return после выхода, потому что макет не остановит выполнение.

func (c *Crasher) Crash() {
    if SomeCondition == true {
        fmt.Println("Going down in flames!")
        c.Exit(1)  // Exit in real situation, invoke mock when testing
    } else {
        DoSomeOtherStuff()
    }

}

Ответ 3

Не рекомендуется распространять тесты вокруг функции Main приложения в GOLANG специально из-за таких проблем. Был уже вопрос, на который уже был дан ответ, касающийся этой же проблемы.

показывает охват функциональных тестов без слепых пятен

Чтобы суммировать

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

Для получения дополнительной информации просмотрите GOLANG Testing.

Охват до 100%

Как я подробно рассказал о предыдущем ответе, так как это плохая идея попробовать тесты вокруг функции Main, и лучшая практика заключается в том, чтобы поместить как можно меньше кода, чтобы он мог быть протестирован правильно, без слепых пятен что попытка получить 100% -ый охват при попытке включить Main func - это потраченное впустую усилие, поэтому лучше проигнорировать его в тестах.

Вы можете использовать теги сборки, чтобы исключить из main.go файл из тестов, чтобы достичь покрытия 100% или все зеленые.

Проверьте: показывая охват функциональных тестов без слепых пятен

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