Zero manual mocking. Full control.
Test impure functions without writing mock implementations.
imptest generates type-safe mocks from your interfaces. Each test interactively controls the mock—expect calls, inject responses, and validate behavior—all with compile-time safety or flexible matchers. No manual mock code. No complex setup. Just point at your functions and dependencies and test.
Sometimes you want to test those pesky impure functions that call out to other services, databases, or systems. Traditional mocking libraries often require you to write mock implementations by hand or configure complex expectations upfront. imptest changes the game by generating mocks automatically and letting you control them interactively during tests.
package mypackage_test
import (
"testing"
"github.com/toejough/imptest/UAT/run"
)
// create mock for your dependency interface
//go:generate impgen run.IntOps --dependency
// create wrapper for your function under test
//go:generate impgen run.PrintSum --target
func Test_PrintSum(t *testing.T) {
t.Parallel()
// Create the dependency mock (returns mock and expectation handle)
mock, expect := MockIntOps(t)
// Start the function under test
wrapper := StartPrintSum(t, run.PrintSum, 10, 32, mock)
// Expect calls in order, inject responses
expect.Add.ArgsEqual(10, 32).Return(42)
expect.Format.ArgsEqual(42).Return("42")
expect.Print.ArgsEqual("42").Return()
// Validate return values
wrapper.ReturnsEqual(10, 32, "42")
}What just happened?
- A
//go:generatedirective created a type-safe mock (MockIntOps) from the interface, providing interactive control over dependency behavior - A
//go:generatedirective created a type-safe wrapper (StartPrintSum) for the function under test, enabling return value and panic validation - The test controlled the dependency interactively—each
ArgsEqualcall waited for the actual call - Results were injected on-demand with
Return, simulating the desired behavior - Return values were validated with
ReturnsEqual
Use gomega-style matchers for flexible assertions:
import . "github.com/onsi/gomega"
import . "github.com/toejough/imptest/match"
func Test_PrintSum_Flexible(t *testing.T) {
t.Parallel()
mock, expect := MockIntOps(t)
wrapper := StartPrintSum(t, run.PrintSum,10, 32, mock)
// Flexible matching with gomega-style matchers
expect.Add.ArgsShould(
BeNumerically(">", 0),
BeNumerically(">", 0),
).Return(42)
expect.Format.ArgsShould(BeAny).Return("42")
expect.Print.ArgsShould(BeAny).Return()
wrapper.ReturnsShould(
Equal(10),
Equal(32),
ContainSubstring("4"),
)
}| Concept | Description |
|---|---|
| Interface Mocks | Generate type-safe mocks from any interface with //go:generate impgen <package.Interface> --dependency |
| Callable Wrappers | Wrap functions to validate returns/panics with //go:generate impgen <package.Function> --target. Generates StartXxx function. |
| Two-Return Pattern | Mocks return (mock, expect): mock is the interface, expect holds method expectations |
| Two-Step Matching | Access methods via expect.X, then specify matching mode (ArgsEqual() or ArgsShould()) |
| Type Safety | ArgsEqual(int, int) is compile-time checked; ArgsShould(matcher, matcher) accepts matchers |
| Concurrent Support | Use expect.Eventually.X for async expectations, then imptest.Wait(t) to block until satisfied |
| Matcher Compatibility | Works with any gomega-style matcher via duck typing—implement Match(any) (bool, error) and FailureMessage(any) string |
func Test_Concurrent(t *testing.T) {
mock, expect := MockCalculator(t)
go func() { mock.Add(1, 2) }()
go func() { mock.Add(5, 6) }()
// Register async expectations (non-blocking)
expect.Eventually.Add.ArgsEqual(5, 6).Return(11)
expect.Eventually.Add.ArgsEqual(1, 2).Return(3)
// Wait for all expectations to be satisfied
imptest.Wait(t)
}func Test_PrintSum_Panic(t *testing.T) {
mock, expect := MockIntOps(t)
wrapper := StartPrintSum(t, run.PrintSum,10, 32, mock)
// Inject a panic
expect.Add.ArgsEqual(10, 32).Panic("math overflow")
// Expect the function to panic with matching value
wrapper.PanicShould(ContainSubstring("overflow"))
}For maximum control, use type-safe GetArgs() or raw RawArgs() to manually inspect arguments:
func Test_Manual(t *testing.T) {
mock, expect := MockCalculator(t)
go func() { mock.Add(1, 2) }()
call := expect.Add.ArgsEqual(1, 2)
// Access typed arguments
args := call.GetArgs()
result := args.A + args.B
call.Return(result)
}When your code passes callback functions to mocked dependencies, imptest makes it easy to extract and test those callbacks:
import . "github.com/toejough/imptest/match"
// Create mock for dependency that receives callbacks
mock, expect := MockTreeWalker(t)
// Wait for the call with a callback parameter (use BeAny to match any function)
call := expect.Eventually.Walk.ArgsShould(Equal("/test"), BeAny)
// Extract the callback from the arguments (blocks until call arrives and matches)
rawArgs := call.RawArgs()
callback := rawArgs[1].(func(string, fs.DirEntry, error) error)
// Invoke the callback with test data
err := callback("/test/file.txt", mockDirEntry{name: "file.txt"}, nil)
// Verify callback behavior and complete the mock call
call.Return(nil)When your code communicates via channels, imptest gives you full control. The key insight: channels are just values—you can inject them as return values or access them from arguments.
When a dependency returns a channel, inject one you control:
// Interface: type EventSource interface { Events() <-chan Event }
func Test_ChannelReturn(t *testing.T) {
mock, expect := MockEventSource(t)
wrapper := StartProcessEvents(t, ProcessEvents,mock)
// Create a channel the test controls
eventChan := make(chan Event)
// Inject it as the return value
expect.Events.Called().Return(eventChan)
// Send events when you want
eventChan <- Event{Type: "start"}
eventChan <- Event{Type: "data", Payload: "hello"}
close(eventChan) // Signal completion
wrapper.ReturnsEqual(2, nil) // Processed 2 events
}When the function under test passes a channel to a dependency, access it via GetArgs():
import . "github.com/toejough/imptest/match"
// Interface: type Worker interface { StartJob(id int, results chan<- Result) error }
func Test_ChannelArg(t *testing.T) {
mock, expect := MockWorker(t)
go func() {
results := make(chan Result, 1)
mock.StartJob(42, results)
// Function blocks waiting for result
r := <-results
fmt.Println(r.Status)
}()
// Capture the call and access the channel argument
call := expect.StartJob.ArgsShould(Equal(42), BeAny)
resultsChan := call.GetArgs().Results
// Send a result on the captured channel
resultsChan <- Result{Status: "done"}
call.Return(nil)
}For request/response patterns over channels:
// Interface: type RPC interface { Call(req <-chan Request, resp chan<- Response) }
func Test_Bidirectional(t *testing.T) {
mock, expect := MockRPC(t)
// Channels the function under test will create
go func() {
reqChan := make(chan Request)
respChan := make(chan Response)
go mock.Call(reqChan, respChan)
reqChan <- Request{ID: 1, Data: "ping"}
resp := <-respChan
// ... use resp
}()
call := expect.Eventually.Call.Called()
// Access both channels from args
args := call.GetArgs()
// Read from request channel, write to response channel
req := <-args.Req
args.Resp <- Response{ID: req.ID, Data: "pong"}
call.Return()
imptest.Wait(t)
}The pattern is consistent: channels are values. Inject them as returns, access them from args, then send/receive as your test requires.
Install the library with:
go get github.com/toejough/imptestInstall the code generator tool:
go install github.com/toejough/imptest/impgen@latestThen add //go:generate impgen <interface|callable> --dependency (for interfaces) or //go:generate impgen <callable> --target (for functions) directives to your test files and run go generate:
go generate ./...- Capability Reference: TAXONOMY.md - comprehensive matrix of what imptest can and cannot do, with examples and workarounds
- API Reference: pkg.go.dev/github.com/toejough/imptest
- More Examples: See the UAT/core for basic patterns and UAT/variations for edge cases
- How It Works: imptest generates mocks that communicate via channels, enabling interactive test control of even asynchronous function behavior
Traditional mocking libraries require you to:
- Write mock implementations by hand, or
- Configure complex expectations upfront, then run the code
imptest lets you:
- Generate mocks automatically from interfaces
- Control mocks interactively—inject responses as calls happen
- Choose type-safe exact matching OR flexible gomega-style matchers
- Test concurrent behavior with timeout-based call matching
Let's test a function that processes user data by calling an external service. Here's how different testing approaches compare:
The Function Under Test:
func ProcessUser(svc ExternalService, userID int) (string, error) {
data, err := svc.FetchData(userID)
if err != nil {
return "", err
}
return svc.Process(data), nil
}func TestProcessUser_Basic(t *testing.T) {
// ❌ Problem: Must write a complete mock implementation by hand
mock := &MockService{
fetchResult: "test data",
processResult: "processed",
}
result, err := ProcessUser(mock, 42)
// ❌ Problem: Manual assertions, verbose error messages
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result != "processed" {
t.Fatalf("expected 'processed', got '%s'", result)
}
// ❌ Problem: Can't verify FetchData was called with correct args
}func TestProcessUser_Other(t *testing.T) {
// ❌ Still need to write mock implementation
mock := &MockService{
fetchResult: "test data",
processResult: "processed",
}
result, err := ProcessUser(mock, 42)
// ✅ Better: Cleaner assertions
assert.NoError(t, err)
assert.Equal(t, "processed", result)
// ❌ Problem: can't control behavior per call interactively
}For testing with dependencies:
//go:generate impgen ExternalService --dependency
//go:generate impgen ProcessUser --target
func TestProcessUser_Imptest(t *testing.T) {
t.Parallel()
// ✅ Generated mock, no manual implementation
mock, expect := MockExternalService(t)
// ✅ Start function for return value validation
wrapper := StartProcessUser(t, ProcessUser,mock, 42)
// ✅ Interactive control: expect calls and inject responses
expect.FetchData.ArgsEqual(42).Return("test data", nil)
expect.Process.ArgsEqual("test data").Return("processed")
// ✅ Validate return values (can use gomega matchers too!)
wrapper.ReturnsEqual("processed", nil)
}For simple return value assertions (without dependencies):
// generate the wrapper for the Add function
//go:generate impgen Add --target
func Add(a, b int) int {
return a + b
}
func TestAdd_Simple(t *testing.T) {
t.Parallel()
// ✅ Start function and validate returns in one line
// ✅ Args are type-safe and checked at compile time - your IDE can autocomplete them or inform you of mismatches!
// ✅ Panics are caught cleanly and reported in failure messages
StartAdd(t, Add,2, 3).ReturnsEqual(5)
}Key Differences:
| Feature | Basic Go | others | imptest |
|---|---|---|---|
| Clean Assertions | ❌ Verbose | ✅ Yes | ✅ Yes |
| Auto-Generated Mocks | ❌ No | ✅ Yes | ✅ Yes |
| Verify Call Order | ❌ Manual | ❌ Complex | ✅ Easy |
| Verify Call Args | ❌ Manual | ✅ Per call | |
| Interactive Control | ❌ Difficult | ❌ Difficult | ✅ Easy |
| Concurrent Testing | ❌ Difficult | ✅ Easy | |
| Return Validation | ❌ Manual | ✅ Yes | ✅ Yes |
| Panic Validation | ❌ Manual | ❌ Manual | ✅ Yes/Automatic |
Zero manual mocking. Full control.
