How to test os.exit scenarios in Go
Asked Answered
R

8

58

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.

Rhnegative answered 6/10, 2014 at 21:56 Comment(2)
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, 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…Stutz
If 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.Stutz
P
82

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

Pumice answered 29/10, 2015 at 1:24 Comment(9)
Running the test results in: process ran with err exec: "cmd": executable file not found in $PATH, want exit status 1Wiese
@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.Pumice
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.Wiese
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?Pumice
This method can not let the -cover show your lines have testedAtmosphere
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.Pumice
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 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.Schoolfellow
This works like magic for error code 1, but how would you go about detecting a hard coded exit(0)?Arborescent
Beware that the -test.run=TestCrasher flag matches a regexp, so it will run any other tests that contain TestCrasher in their name (docs). To run only TestCrasher you could use -test.run=^TestCrasher$Transmogrify
A
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.

Aubreyaubrie answered 28/7, 2017 at 18:17 Comment(6)
For reference, the license of bouk/monkey is likely incompatible with your project: github.com/bouk/monkey/pull/18Bucher
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.Aubreyaubrie
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.Legendary
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. reuters.com/article/…Chinn
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... github.com/TheApeMachine/errnie/blob/master/guard.go?ts=4Chinn
news.ycombinator.com/item?id=22442523Disembogue
L
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)
}
Laevogyrate answered 6/10, 2014 at 23:25 Comment(1)
Could you add the test as well please?Wiese
P
4

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
}
Puebla answered 28/10, 2015 at 16:37 Comment(2)
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.Monohydric
This is not good practice; to have the power to change an exit scenario through a global variable.Barnett
H
3

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

Hydropathy answered 6/10, 2014 at 21:59 Comment(0)
K
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

Korten answered 20/7, 2022 at 10:15 Comment(0)
H
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.

Holladay answered 15/4, 2023 at 20:24 Comment(0)
A
-1

In a unit test you normally have the structure:

  1. Arrange: setup, create mocks, etc
  2. Act: call the function to test
  3. Assert: validate outputs/conditions with expected results after the function call

You can achieve unit testing a function with os.exit() or "expected" panic by making use of defer + recover and changing the order a bit on the traditional UT structure:

  1. Arrange
  2. Assert logic wrapped inside a defer
  3. Act

Example:

func TestWithExitCondition(t *testing.T) {
   // Arrange
   assert := assert.New(t)
   // more arrange/setup logic

   // Capture the panic error/os.exit() using recover
   defer func() {
        if r := recover(); r != nil {
            // Assert
            // Test passed if a panic/exit occurred (expected behavior)
            t.Log("Test passed as expected")
            // Assert logic checking, checking mock instances, etc
        }
    }()

    // Act
    RunLogicToBeTestedThatExits()
}
Acquiescent answered 7/12, 2023 at 23:19 Comment(1)
It doesn't catch os.Exit().Letters

© 2022 - 2024 — McMap. All rights reserved.