How to test os.exit scenarios in Go
Solution 1
There's a presentation by Andrew Gerrand (one of the core members of the Go team) where he shows how to do it.
Given a function (in main.go
)
package main
import (
"fmt"
"os"
)
func Crasher() {
fmt.Println("Going down in flames!")
os.Exit(1)
}
here's how you would test it (through main_test.go
):
package main
import (
"os"
"os/exec"
"testing"
)
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)
}
What the code does is invoke go test
again in a separate process through exec.Command
, limiting execution to the TestCrasher
test (via the -test.run=TestCrasher
switch). It also passes in a flag via an environment variable (BE_CRASHER=1
) which the second invocation checks for and, if set, calls the system-under-test, returning immediately afterwards to prevent running into an infinite loop. Thus, we are being dropped back into our original call site and may now validate the actual exit code.
Source: Slide 23 of Andrew's presentation. The second slide contains a link to the presentation's video as well. He talks about subprocess tests at 47:09
Solution 2
I do this by using bouk/monkey:
func TestDoomed(t *testing.T) {
fakeExit := func(int) {
panic("os.Exit called")
}
patch := monkey.Patch(os.Exit, fakeExit)
defer patch.Unpatch()
assert.PanicsWithValue(t, "os.Exit called", doomed, "os.Exit was not called")
}
monkey is super-powerful when it comes to this sort of work, and for fault injection and other difficult tasks. It does come with some caveats.
Solution 3
I don't think you can test the actual os.Exit
without simulating testing from the outside (using exec.Command
) process.
That said, you might be able to accomplish your goal by creating an interface or function type and then use a noop implementation in your tests:
package main
import "os"
import "fmt"
type exiter func (code int)
func main() {
doExit(func(code int){})
fmt.Println("got here")
doExit(func(code int){ os.Exit(code)})
}
func doExit(exit exiter) {
exit(1)
}
Solution 4
You can't, you would have to use exec.Command
and test the returned value.
Solution 5
Code for testing:
package main
import "os"
var my_private_exit_function func(code int) = os.Exit
func main() {
MyAbstractFunctionAndExit(1)
}
func MyAbstractFunctionAndExit(exit int) {
my_private_exit_function(exit)
}
Testing code:
package main
import (
"os"
"testing"
)
func TestMyAbstractFunctionAndExit(t *testing.T) {
var ok bool = false // The default value can be omitted :)
// Prepare testing
my_private_exit_function = func(c int) {
ok = true
}
// Run function
MyAbstractFunctionAndExit(1)
// Check
if ok == false {
t.Errorf("Error in AbstractFunction()")
}
// Restore if need
my_private_exit_function = os.Exit
}
Related videos on Youtube
Comments
-
mbrevoort almost 2 years
Given this code
func doomed() { os.Exit(1) }
How do I properly test that calling this function will result in an exit using
go test
? This needs to occur within a suite of tests, in other words theos.Exit()
call cannot impact the other tests and should be trapped.-
ches almost 8 yearsOf course this isn't a direct answer to the question, and that's why I'm not writing it as one, but generally: avoid writing code like this. If you only
Exit
"at the end of the world" (main
), like this pattern, then you won't be stuck writing such painful tests as the (good) accepted solution here. I fully acknowledge you may have been stuck testing someone else's code you couldn't readily refactor, but just hoping the advice is helpful to future readers… -
ches almost 8 yearsIf you do follow that pattern and you happen to use Gomega, it has a pretty cool
gexec
package that is nice for testing results of executables in a black box manner.
-
-
030 over 8 yearsRunning the test results in:
process ran with err exec: "cmd": executable file not found in $PATH, want exit status 1
-
030 over 8 yearsCould you add the test as well please?
-
Timo Reimann over 8 years@Alfred Did you keep the implementation and tests in separate files, e.g.,
main.go
andmain_test.go
, respectively? I amended my answer and double-checked that it works on my machine. -
030 over 8 yearsYes. They are separated. Could it be possible that something is wrong with some environment variables
go env
? I always usego test
for testing and I have created several tests to test other files as well. -
Timo Reimann almost 8 yearsSorry for not getting back to you, I totally lost track of this. In case it is still somewhat relevant to you: Can you take a closer look at what
cmd
looks for you? Specifically, does the path look reasonable? -
Daniel YC Lin over 6 yearsThis method can not let the -cover show your lines have tested
-
Timo Reimann over 6 yearsThat is true, unfortunately. To my knowledge, the only way to mitigate that problem is to minimize the need for integration-testing executables and focus on unit tests instead. What I tend to do these days is have my executable call some function as soon as possible and focus on unit-testing that function.
-
Adam M-W almost 6 yearsFor reference, the license of bouk/monkey is likely incompatible with your project: github.com/bouk/monkey/pull/18
-
Allen Luce almost 6 yearsYou shouldn't use any software without being comfortable with all the risks, including the legal ones. You also shouldn't assume you can understand a software license, this or any other, just by reading it.
-
TheDiveO about 4 yearsFor code coverage of my tests requiring reexecution for entering mount namespaces I came up solution which automatically merges the separate code coverages from the re-executions into the final coverage; please see github.com/thediveo/gons and here its gons/reexec and gons/reexec/testing packages. The reexec code is geared towards namespaced re-entry, but you'll quickly see how to make your own re-exec without namespace support, but using the profile merging.
-
Olli over 3 yearsIndeed, considering the license, no-one should be using this. In this case understanding the license is very easy and straightforward. Even though the code is available, the license does not allow using it. In your personal, private projects that has no practical meaning but for anything else it matters.
-
Helyrk over 3 yearsTo make it more clear, this answer suggests to put the
os.Exit
in a variable and call it through this variable in the production code, while in testing you can replace its value with another function that wouldn't exit but would let you know if it was called. This is a valid solution if you can afford to modify your production code to improve its testability. -
glemiere almost 3 yearsThis works like magic for error code 1, but how would you go about detecting a hard coded exit(0)?
-
thenamewasmilo almost 3 yearsThat license is not valid in the European Union, where this guy resides. On the other hand I would not use it in any way shape or form, and change the way my program works. Besides it being incredibly dirty what he is doing I would not feel comfortable relying on a project made by a developer who is clearly not acting in a rational or professional manner and that is just a left-pad situation waiting to happen. reuters.com/article/…
-
thenamewasmilo almost 3 yearsHowever, if you are going to panic anyway, and you like dirty code, you could do this, which I am told I should not have written, but I like it :p I would not advice it, or if you do like it, copy the code, because I don't maintain it in a stable way, and change things all the time. Another option, possibly better still would be to use the re-exec trick the docker guys developed since that is actually maintained code that is used in a real project. I would go with that... github.com/TheApeMachine/errnie/blob/master/guard.go?ts=4
-
Gus over 2 yearsBeware that the
-test.run=TestCrasher
flag matches a regexp, so it will run any other tests that containTestCrasher
in their name (docs). To run onlyTestCrasher
you could use-test.run=^TestCrasher$
-
Alaska over 2 yearsThis is not good practice; to have the power to change an exit scenario through a global variable.