The gexec package provides powerful utilities for testing external processes in Go. It simplifies building Go binaries, executing them, and making assertions about their behavior including exit codes, stdout, and stderr output.
Package: github.com/onsi/gomega/gexec
The gexec package is designed for testing command-line applications and external processes. It provides:
{ .api }
type Session struct {
Command *exec.Cmd
Out *Buffer
Err *Buffer
Exited <-chan struct{}
// Contains filtered or unexported fields
}Represents a running external process with captured output.
Fields:
Command - The underlying *exec.Cmd for the processOut - Thread-safe buffer containing stdout output (from gbytes package)Err - Thread-safe buffer containing stderr output (from gbytes package)Exited - Channel that closes when the command exits{ .api }
func (s *Session) Wait(timeout ...time.Duration) *SessionWaits for the session to exit. If timeout is provided, waits up to that duration.
Parameters:
timeout - Optional timeout duration. If omitted, waits indefinitelyReturns: The Session (for chaining)
Usage:
session.Wait(5 * time.Second){ .api }
func (s *Session) Kill() *SessionSends SIGKILL to the process, forcing immediate termination.
Returns: The Session (for chaining)
{ .api }
func (s *Session) Terminate() *SessionSends SIGTERM to the process, requesting graceful termination.
Returns: The Session (for chaining)
{ .api }
func (s *Session) Signal(signal os.Signal) *SessionSends a custom signal to the process.
Parameters:
signal - The OS signal to send (e.g., syscall.SIGUSR1)Returns: The Session (for chaining)
{ .api }
func (s *Session) Interrupt() *SessionSends SIGINT to the process (equivalent to Ctrl+C).
Returns: The Session (for chaining)
{ .api }
func (s *Session) ExitCode() intReturns the exit code of the process. Returns -1 if the process hasn't exited yet.
Returns: Exit code as integer
{ .api }
func (s *Session) Buffer() *gbytes.BufferReturns the Out buffer, implementing the gbytes.BufferProvider interface.
Returns: *gbytes.Buffer - The stdout buffer
Usage:
session, _ := gexec.Start(command, nil, nil)
// Can use session directly with gbytes matchers
Eventually(session).Should(gbytes.Say("pattern")){ .api }
func Start(command *exec.Cmd, outWriter, errWriter io.Writer) (*Session, error)Starts an external command and returns a Session for managing it.
Parameters:
command - The *exec.Cmd to execute (created with exec.Command())outWriter - Writer for stdout. Pass nil to use default Buffer, or pass GinkgoWriter to see output in GinkgoerrWriter - Writer for stderr. Pass nil to use default Buffer, or pass GinkgoWriter to see output in GinkgoReturns:
*Session - Session managing the running processerror - Error if the command fails to startExample:
command := exec.Command("myapp", "--flag", "value")
session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter)
Expect(err).NotTo(HaveOccurred())
Eventually(session).Should(gexec.Exit(0)){ .api }
func Build(packagePath string, args ...string) (string, error)Builds a Go package and returns the path to the compiled binary. The binary is built in a temporary directory and automatically tracked for cleanup.
Parameters:
packagePath - Go package path to build (e.g., "github.com/user/myapp" or relative path like "./cmd/server")args - Optional additional arguments passed to go build (e.g., "-race", "-tags=integration")Returns:
string - Absolute path to the compiled binaryerror - Error if compilation failsExample:
binPath, err := gexec.Build("github.com/myorg/myapp")
Expect(err).NotTo(HaveOccurred())
command := exec.Command(binPath, "run")
session, err := gexec.Start(command, nil, nil)
Expect(err).NotTo(HaveOccurred()){ .api }
func BuildWithEnvironment(packagePath string, env []string, args ...string) (string, error)Builds a Go package with custom environment variables. Useful for cross-compilation or setting custom build flags.
Parameters:
packagePath - Go package path to buildenv - Environment variables for the build process (e.g., []string{"GOOS=linux", "GOARCH=amd64"})args - Optional additional build argumentsReturns:
string - Absolute path to the compiled binaryerror - Error if compilation failsExample:
// Cross-compile for Linux
env := []string{
"GOOS=linux",
"GOARCH=amd64",
"CGO_ENABLED=0",
}
binPath, err := gexec.BuildWithEnvironment("./cmd/server", env, "-tags", "production")
Expect(err).NotTo(HaveOccurred()){ .api }
func BuildIn(gopath string, packagePath string, args ...string) (string, error)Builds a Go package with a custom GOPATH. Useful for legacy projects or specific build setups.
Parameters:
gopath - Custom GOPATH to use for the buildpackagePath - Go package path to buildargs - Optional additional build argumentsReturns:
string - Absolute path to the compiled binaryerror - Error if compilation failsExample:
binPath, err := gexec.BuildIn("/custom/gopath", "myapp", "-v")
Expect(err).NotTo(HaveOccurred()){ .api }
func CleanupBuildArtifacts()Removes all compiled binaries created by Build, BuildWithEnvironment, and BuildIn functions. Typically called in test cleanup hooks.
Example:
var _ = AfterSuite(func() {
gexec.CleanupBuildArtifacts()
})These functions operate on all sessions started with gexec.
{ .api }
func Kill()Sends SIGKILL to all tracked sessions without waiting for them to exit.
Usage:
// Emergency cleanup
gexec.Kill(){ .api }
func KillAndWait(timeout ...any)Sends SIGKILL to all tracked sessions and waits for them to exit.
Parameters:
timeout - Optional timeout duration (time.Duration or string like "5s")Usage:
gexec.KillAndWait(5 * time.Second){ .api }
func Terminate()Sends SIGTERM to all tracked sessions without waiting for them to exit.
Usage:
gexec.Terminate(){ .api }
func TerminateAndWait(timeout ...any)Sends SIGTERM to all tracked sessions and waits for them to exit.
Parameters:
timeout - Optional timeout duration (time.Duration or string like "5s")Usage:
gexec.TerminateAndWait(10 * time.Second){ .api }
func Signal(signal os.Signal)Sends the specified signal to all tracked sessions without waiting.
Parameters:
signal - OS signal to sendUsage:
gexec.Signal(syscall.SIGUSR1){ .api }
func Interrupt()Sends SIGINT to all tracked sessions without waiting for them to exit.
Usage:
gexec.Interrupt(){ .api }
type PrefixedWriter struct {
// Contains filtered or unexported fields
}An io.Writer that prefixes each line with a specified string.
Constructor:
{ .api }
func NewPrefixedWriter(prefix string, writer io.Writer) *PrefixedWriterParameters:
prefix - String to prepend to each linewriter - Underlying writerReturns: *PrefixedWriter
Usage:
prefixed := gexec.NewPrefixedWriter("[APP] ", GinkgoWriter)
session, _ := gexec.Start(command, prefixed, prefixed)
// Output will be prefixed: "[APP] log message"{ .api }
type Exiter interface {
ExitCode() int
}Interface for types that have an exit code. Session implements this interface.
{ .api }
const INVALID_EXIT_CODE = 254Special exit code indicating the process exit code couldn't be determined.
{ .api }
func Exit(optionalExitCode ...int) types.GomegaMatcherMatcher that succeeds when a Session has exited. Optionally verifies the exit code.
Parameters:
optionalExitCode - Expected exit code. If omitted, matches any exit (even non-zero)Returns: GomegaMatcher for use with Should(), ShouldNot(), etc.
Examples:
// Wait for any exit
Eventually(session).Should(gexec.Exit())
// Verify successful exit (code 0)
Eventually(session).Should(gexec.Exit(0))
// Verify specific exit code
Eventually(session).Should(gexec.Exit(137))
// Verify non-zero exit
Eventually(session).ShouldNot(gexec.Exit(0))package myapp_test
import (
"os/exec"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/onsi/gomega/gexec"
)
var _ = Describe("MyApp", func() {
var pathToApp string
BeforeSuite(func() {
var err error
pathToApp, err = gexec.Build("github.com/myorg/myapp")
Expect(err).NotTo(HaveOccurred())
})
AfterSuite(func() {
gexec.CleanupBuildArtifacts()
})
It("should exit successfully", func() {
command := exec.Command(pathToApp)
session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter)
Expect(err).NotTo(HaveOccurred())
Eventually(session).Should(gexec.Exit(0))
})
})It("should output the correct message", func() {
command := exec.Command(pathToApp, "greet", "World")
session, err := gexec.Start(command, nil, nil)
Expect(err).NotTo(HaveOccurred())
Eventually(session).Should(gexec.Exit(0))
// Check stdout
Expect(session.Out.Contents()).To(ContainSubstring("Hello, World!"))
// Check stderr is empty
Expect(session.Err.Contents()).To(BeEmpty())
})It("should fail with invalid arguments", func() {
command := exec.Command(pathToApp, "--invalid-flag")
session, err := gexec.Start(command, nil, nil)
Expect(err).NotTo(HaveOccurred())
Eventually(session).ShouldNot(gexec.Exit(0))
Expect(session.Err.Contents()).To(ContainSubstring("unknown flag"))
})It("should stream output progressively", func() {
command := exec.Command(pathToApp, "stream")
session, err := gexec.Start(command, nil, nil)
Expect(err).NotTo(HaveOccurred())
// Use gbytes.Say to wait for specific output
Eventually(session.Out).Should(gbytes.Say("Starting process"))
Eventually(session.Out).Should(gbytes.Say("Processing complete"))
Eventually(session).Should(gexec.Exit(0))
})It("should run a server that can be terminated", func() {
command := exec.Command(pathToApp, "serve", "--port", "8080")
session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter)
Expect(err).NotTo(HaveOccurred())
// Wait for server to start
Eventually(session.Out).Should(gbytes.Say("Server listening"))
// Do some testing...
// Terminate gracefully
session.Terminate()
Eventually(session, "5s").Should(gexec.Exit())
})It("should handle unresponsive processes", func() {
command := exec.Command(pathToApp, "hang")
session, err := gexec.Start(command, nil, nil)
Expect(err).NotTo(HaveOccurred())
// Try graceful termination
session.Terminate()
// If not exited after 2 seconds, force kill
Eventually(session, "2s").Should(gexec.Exit())
if session.ExitCode() == -1 {
session.Kill()
Eventually(session, "1s").Should(gexec.Exit())
}
})var _ = BeforeSuite(func() {
var err error
pathToApp, err = gexec.Build("github.com/myorg/myapp", "-race")
Expect(err).NotTo(HaveOccurred())
})
It("should be race-free", func() {
command := exec.Command(pathToApp, "concurrent-operation")
session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter)
Expect(err).NotTo(HaveOccurred())
Eventually(session).Should(gexec.Exit(0))
Expect(session.Err.Contents()).NotTo(ContainSubstring("DATA RACE"))
})It("should respect environment variables", func() {
command := exec.Command(pathToApp)
command.Env = append(os.Environ(), "APP_MODE=test", "LOG_LEVEL=debug")
session, err := gexec.Start(command, nil, nil)
Expect(err).NotTo(HaveOccurred())
Eventually(session).Should(gexec.Exit(0))
Expect(session.Out.Contents()).To(ContainSubstring("DEBUG"))
})It("should process stdin input", func() {
command := exec.Command(pathToApp, "process")
stdin, err := command.StdinPipe()
Expect(err).NotTo(HaveOccurred())
session, err := gexec.Start(command, nil, nil)
Expect(err).NotTo(HaveOccurred())
// Write to stdin
_, err = stdin.Write([]byte("test input\n"))
Expect(err).NotTo(HaveOccurred())
stdin.Close()
Eventually(session).Should(gexec.Exit(0))
Expect(session.Out.Contents()).To(ContainSubstring("Processed: test input"))
})It("should handle SIGUSR1 gracefully", func() {
command := exec.Command(pathToApp, "daemon")
session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter)
Expect(err).NotTo(HaveOccurred())
Eventually(session.Out).Should(gbytes.Say("Daemon started"))
// Send custom signal
session.Signal(syscall.SIGUSR1)
Eventually(session.Out).Should(gbytes.Say("Received SIGUSR1"))
session.Terminate()
Eventually(session).Should(gexec.Exit(0))
})Always Build Once Per Suite: Build binaries in BeforeSuite() and reuse them across tests for better performance.
Clean Up Artifacts: Always call CleanupBuildArtifacts() in AfterSuite() to remove temporary binaries.
Use GinkgoWriter for Debugging: Pass GinkgoWriter as the output writer when you need to see process output during test failures.
Set Reasonable Timeouts: Use Eventually(session, "5s") with appropriate timeouts to avoid hanging tests.
Check Both Exit Code and Output: Verify both that the process exits correctly and produces expected output.
Handle Cleanup in AfterEach: Ensure processes are terminated in AfterEach() to prevent leaked processes:
AfterEach(func() {
if session != nil {
session.Kill()
}
})Use Race Detector in CI: Build with -race flag in CI environments to catch concurrency issues.
Test Both Success and Failure Cases: Verify both successful operations and error handling.
The Session's Out and Err buffers are from the gbytes package and support all gbytes matchers:
// Wait for output
Eventually(session.Out).Should(gbytes.Say("pattern"))
// Check final contents
Expect(session.Out.Contents()).To(MatchRegexp("regex.*pattern"))
// Use with timeout
Eventually(session.Out, "3s").Should(gbytes.Say("startup complete"))var _ = Describe("CLI Application", func() {
var binPath string
BeforeSuite(func() {
var err error
binPath, err = gexec.Build("./cmd/mycli")
Expect(err).NotTo(HaveOccurred())
})
AfterSuite(func() {
gexec.CleanupBuildArtifacts()
})
Context("version command", func() {
It("should print version", func() {
cmd := exec.Command(binPath, "version")
session, err := gexec.Start(cmd, nil, nil)
Expect(err).NotTo(HaveOccurred())
Eventually(session).Should(gexec.Exit(0))
Expect(session.Out.Contents()).To(MatchRegexp(`v\d+\.\d+\.\d+`))
})
})
})This comprehensive approach ensures robust testing of command-line applications and external processes.