55

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 the os.Exit() call cannot impact the other tests and should be trapped.

John Pick
  • 5,562
  • 31
  • 31
mbrevoort
  • 5,075
  • 6
  • 38
  • 48
  • 1
    Of 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](http://stackoverflow.com/a/18969976/455009), 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 Aug 07 '16 at 11:44
  • If you do follow that pattern and you happen to use Gomega, it has [a pretty cool `gexec` package](http://onsi.github.io/gomega/#gexec-testing-external-processes) that is nice for testing results of executables in a black box manner. – ches Aug 07 '16 at 11:46

7 Answers7

77

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

xorpaul
  • 207
  • 3
  • 10
Timo Reimann
  • 9,359
  • 2
  • 28
  • 25
  • Running the test results in: `process ran with err exec: "cmd": executable file not found in $PATH, want exit status 1` – 030 Nov 16 '15 at 00:29
  • @Alfred Did you keep the implementation and tests in separate files, e.g., `main.go` and `main_test.go`, respectively? I amended my answer and double-checked that it works on my machine. – Timo Reimann Nov 17 '15 at 01:50
  • Yes. They are separated. Could it be possible that something is wrong with some environment variables `go env`? I always use `go test` for testing and I have created several tests to test other files as well. – 030 Nov 17 '15 at 10:17
  • Sorry 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? – Timo Reimann Jul 04 '16 at 00:11
  • 7
    This method can not let the -cover show your lines have tested – Daniel YC Lin Oct 12 '17 at 05:49
  • That 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. – Timo Reimann Oct 12 '17 at 06:17
  • 1
    For 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 https://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. – TheDiveO May 05 '20 at 18:49
  • This works like magic for error code 1, but how would you go about detecting a hard coded exit(0)? – glemiere Jul 12 '21 at 19:34
  • Beware that the `-test.run=TestCrasher` flag matches a regexp, so it will run any other tests that contain `TestCrasher` in their name ([docs](https://pkg.go.dev/cmd/go#hdr-Testing_flags)). To run only `TestCrasher` you could use `-test.run=^TestCrasher$` – Gus Jan 20 '22 at 14:13
10

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.

Allen Luce
  • 7,859
  • 3
  • 40
  • 53
  • 4
    For reference, the license of bouk/monkey is likely incompatible with your project: https://github.com/bouk/monkey/pull/18 – Adam M-W Jul 13 '18 at 21:14
  • You 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. – Allen Luce Jul 14 '18 at 00:55
  • Indeed, 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. – Olli Jan 21 '21 at 20:19
  • 1
    That 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. https://www.reuters.com/article/uk-eu-copyright-software-idUKBRE8410O720120502 – thenamewasmilo Aug 12 '21 at 21:06
  • However, 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... https://github.com/TheApeMachine/errnie/blob/master/guard.go?ts=4 – thenamewasmilo Aug 12 '21 at 21:14
  • https://news.ycombinator.com/item?id=22442523 – M. Gopal Jul 05 '23 at 05:35
7

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:

Go Playground

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)
}
Matt Self
  • 19,520
  • 2
  • 32
  • 36
3

You can't, you would have to use exec.Command and test the returned value.

OneOfOne
  • 95,033
  • 20
  • 184
  • 185
2

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
}
Alex Geer
  • 39
  • 3
  • To 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. – Helyrk Jan 25 '21 at 15:53
  • This is not good practice; to have the power to change an exit scenario through a global variable. – Alaska Feb 15 '22 at 22:45
0

To test the os.Exit like scenarios we can use the https://github.com/undefinedlabs/go-mpatch along with the below code. This ensures that your code remains clean as well as readable and maintainable.

type PatchedOSExit struct {
    Called     bool
    CalledWith int
    patchFunc  *mpatch.Patch
}

func PatchOSExit(t *testing.T, mockOSExitImpl func(int)) *PatchedOSExit {
    patchedExit := &PatchedOSExit{Called: false}

    patchFunc, err := mpatch.PatchMethod(os.Exit, func(code int) {
        patchedExit.Called = true
        patchedExit.CalledWith = code

        mockOSExitImpl(code)
    })

    if err != nil {
        t.Errorf("Failed to patch os.Exit due to an error: %v", err)

        return nil
    }

    patchedExit.patchFunc = patchFunc

    return patchedExit
}

func (p *PatchedOSExit) Unpatch() {
    _ = p.patchFunc.Unpatch()
}

You can consume the above code as follows:

func NewSampleApplication() {
    os.Exit(101)
}

func Test_NewSampleApplication_OSExit(t *testing.T) {
    // Prepare mock setup
    fakeExit := func(int) {}

    p := PatchOSExit(t, fakeExit)
    defer p.Unpatch()

    // Call the application code
    NewSampleApplication()

    // Assert that os.Exit gets called
    if p.Called == false {
        t.Errorf("Expected os.Exit to be called but it was not called")
        return
    }

    // Also, Assert that os.Exit gets called with the correct code
    expectedCalledWith := 101

    if p.CalledWith != expectedCalledWith {
        t.Errorf("Expected os.Exit to be called with %d but it was called with %d", expectedCalledWith, p.CalledWith)
        return
    }
}

I've also added a link to Playground: https://go.dev/play/p/FA0dcwVDOm7

Hardik Modha
  • 12,098
  • 3
  • 36
  • 40
0

In my code I've just used

func doomedOrNot() int {
  if (doomed) {
    return 1
  }
  return 0
}

then calling it like:

if exitCode := doomedOrNot(); exitCode != 0 {
  os.Exit(exitCode)
}

This way doomedOrNot can be tested easily.

vendelin
  • 138
  • 1
  • 3
  • 16