使用Go进行测试os.Exit场景,并提供覆盖率信息(coveralls.io/Goveralls)。

huangapple go评论95阅读模式
英文:

Testing os.Exit scenarios in Go with coverage information (coveralls.io/Goveralls)

问题

这个问题是关于如何在Go语言中测试os.Exit()场景的。由于os.Exit()无法轻易拦截,所以使用的方法是重新调用二进制文件并检查退出值。这种方法在Andrew Gerrand(Go团队的核心成员之一)的演示文稿的第23页中有描述;下面是完整的代码示例。

相关的测试和主文件如下所示(请注意,这对文件本身就是一个最小可复现示例):

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"
)

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

然而,这种方法似乎存在一些限制:

  1. 使用goveralls/coveralls.io进行覆盖测试无法正常工作-例如,查看这里的示例(与上面相同的代码,为了方便起见放在GitHub上),生成的覆盖测试结果在这里,即它不记录运行的测试函数。注意,你不需要访问这些链接来回答问题-上面的示例可以正常工作-它们只是用来展示如果将上述代码放入GitHub,并通过travis到coveralls.io的整个过程中会发生什么

  2. 重新运行测试二进制文件似乎很脆弱。

具体而言,如请求的那样,这是一个覆盖失败的屏幕截图(而不是链接),红色阴影表示在coveralls.io看来,Crasher()没有被调用。

使用Go进行测试os.Exit场景,并提供覆盖率信息(coveralls.io/Goveralls)。

有没有办法解决这个问题?特别是第一个问题。

在Go语言层面上,问题是:

  • Goveralls框架运行go test -cover ...,调用上面的测试函数。
  • 上面的测试函数在不带-cover的情况下调用exec.Command / .Run
  • 无条件地将-cover等参数放入参数列表中不太理想,因为这样会在非覆盖测试中运行覆盖测试(作为子进程),而且解析参数列表中是否存在-cover等参数似乎是一个繁重的解决方案。
  • 即使我将-cover等参数放入参数列表中,我理解的是,我会在同一个文件中写入两个覆盖率输出,这是行不通的-这些输出需要以某种方式合并。我找到的最接近的解决方案是这个Go语言问题

总结

我想要的是一种简单的方法来运行Go语言的覆盖测试(最好通过travis、goveralls和coveralls.io),在这种方法中,可以测试被测试的例程使用OS.exit()退出的情况,并记录该测试的覆盖率。如果可以的话,我希望使用上面的重新执行方法(如果可以使其正常工作)。

解决方案应该显示Crasher()的覆盖测试。将Crasher()从覆盖测试中排除不是一个选项,因为在实际情况下,我要测试的是一个更复杂的函数,在其中,在某些条件下,它调用log.Fatalf();我要进行覆盖测试的是这些条件的测试是否正常工作。

英文:

This question: https://stackoverflow.com/questions/26225513/how-to-test-os-exit-scenarios-in-go (and the highest voted answer therein) sets out how to test os.Exit() scenarios within go. As os.Exit() cannot easily be intercepted, the method used is to reinvoke the binary and check the exit value. This method is described at slide 23 on this presentation by Andrew Gerrand (one of the core members of the Go team); the code is very simple and is reproduced in full below.

The relevant test and main files look like this (note that this pair of files alone is an 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)
}

and

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)
}

However, this method appears to suffer certain limitations:

  1. Coverage testing with goveralls / coveralls.io does not work - see for instance the example here (the same code as above but put into github for your convenience) which produces the coverage test here, i.e. it does not record the test functions being run. NOTE that you don't need to those links to answer the question - the above example will work fine - they are just there to show what happens if you put the above into github, and take it all the way through travis to coveralls.io

  2. Rerunning the test binary appears fragile.

Specifically, as requested, here is a screenshot (rather than a link) for the coverage failure; the red shading indicates that as far as coveralls.io is concerned, Crasher() is not being called.

使用Go进行测试os.Exit场景,并提供覆盖率信息(coveralls.io/Goveralls)。

Is there a way around this? Particularly the first point.

At a golang level the problem is this:

  • The Goveralls framework runs go test -cover ..., which invokes the test above.

  • The test above calls exec.Command / .Run without -cover in the OS arguments

  • Unconditionally putting -cover etc. in the argument list is unattractive as it would then run a coverage test (as the subprocess) within a non-coverage test, and parsing the argument list for the presence of -cover etc. seems a heavy duty solution.

  • Even if I put -cover etc. in the argument list, my understanding is that I'd then have two coverage outputs written to the same file, which isn't going to work - these would need merging somehow. The closest I've got to that is this golang issue.


Summary

What I am after is a simple way to run go coverage testing (preferably via travis, goveralls, and coveralls.io), where it is possible to both test cases where the tested routine exits with OS.exit(), and where the coverage of that test is noted. I'd quite like it to use the re-exec method above (if that can be made to work) if that can be made to work.

The solution should show coverage testing of Crasher(). Excluding Crasher() from coverage testing is not an option, as in the real world what I am trying to do is test a more complex function, where somewhere deep within, under certain conditions, it calls e.g. log.Fatalf(); what I am coverage testing is that the tests for those conditions works properly.

答案1

得分: 26

通过轻微的重构,您可以轻松实现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) {
	// 保存当前函数并在最后恢复:
	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)
}

测试代码:foo/bar_test.go 中的 TestCrasher()

func TestCrasher(t *testing.T) {
	// 保存当前函数并在最后恢复:
	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
英文:

With a slight refactoring, you may easily achieve 100% coverage.

foo/bar.go:

package foo

import (
	"fmt"
	"os"
)

var osExit = os.Exit

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

And the testing code: 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)
	}
}

Running go test -cover:

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

Yes, you might say this works if os.Exit() is called explicitly, but what if os.Exit() is called by someone else, e.g. log.Fatalf()?

The same technique works there too, you just have to switch log.Fatalf() instead of os.Exit(), e.g.:

Relevant part of foo/bar.go:

var logFatalf = log.Fatalf

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

And the testing code: TestCrasher() in 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")
	}
}

Running go test -cover:

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

答案2

得分: 9

接口和模拟

使用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方法仍然不会被测试,因此它应该只负责退出,不做其他事情。

函数是一等公民

参数依赖

在Go中,函数是一等公民。可以对函数进行许多操作,因此我们可以直接对函数进行一些技巧。

使用“作为参数传递”操作,我们可以进行依赖注入:

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()
    }
}
英文:

Interfaces and mocks

Using Go interfaces possible to create mock-able compositions. A type could have interfaces as bound dependencies. These dependencies could be easily substituted with mocks appropriate to the interfaces.

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)
}

Testing

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)
}

Disadvantages

Original Exit method still won't be tested so it should be responsible only for exit, nothing more.

Functions are first class citizens

Parameter dependency

Functions are first class citizens in Go. A lot of operations are allowed with functions so we can do some tricks with functions directly.

Using 'pass as parameter' operation we can do a dependency injection:

type osExit func(code int)

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

Testing:

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)
}

Disadvantages

You must pass a dependency as a parameter. If you have many dependencies a length of params list could be huge.

Variable substitution

Actually it is possible to do it using "assign to variable" operation without explicit passing a function as a parameter.

var osExit = os.Exit

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

Testing

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)
    }
}

disadvantages

It is implicit and easy to crash.

Design notes

If you plan to declare some logic below Exit an exit logic must be isolated with else block or extra return after exit because mock won't stop execution.

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()
    }
   
}

huangapple
  • 本文由 发表于 2016年11月16日 01:03:51
  • 转载请务必保留本文链接:https://go.coder-hub.com/40615641.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定