Browsing the /r/golang subreddit, you might get the impression that "mock" is a dirty word.
I was recently reading a thread on testing strategies and was surprised by the intensity of the anti-mock sentiment. The arguments were visceral:
Avoid mocks like the plague.
Mocks are like payday loans. Convenient. You say “expect this method to be called with args X and Y, return Z”. Seems cheap. But the loan shark quickly starts charging interest.
At FunnelStory, we see it differently. We’ve learned to love mocks—not as a shortcut, but as the only way to meaningfully build and maintain a complex application as a small team (with no dedicated QA).
We manage a complex dist…
Browsing the /r/golang subreddit, you might get the impression that "mock" is a dirty word.
I was recently reading a thread on testing strategies and was surprised by the intensity of the anti-mock sentiment. The arguments were visceral:
Avoid mocks like the plague.
Mocks are like payday loans. Convenient. You say “expect this method to be called with args X and Y, return Z”. Seems cheap. But the loan shark quickly starts charging interest.
At FunnelStory, we see it differently. We’ve learned to love mocks—not as a shortcut, but as the only way to meaningfully build and maintain a complex application as a small team (with no dedicated QA).
We manage a complex distributed system that orchestrates LLMs, syncs CRM data, and handles long-running workflows. In this environment, relying solely on "real" integration tests left us with blind spots.
We couldn’t reliably test what happens when an API sends a 429 Rate Limit, or when a workflow needs to pause for exactly 24 hours. More importantly, we realized that the majority of our edge cases depend on specific combinations of integrations that exist in a customer’s workspace—states that are impossible to reproduce or recreate in our own environments.
This reality drove us to define a new goal: 100% meaningful coverage.
We don’t just want green checkmarks for the "happy paths" that run when the database is online. We want to cover the "chaos paths" that cause production outages. While we are still refactoring our legacy code to fully meet this standard, this disciplined style of mocking has become indispensable to our scale.
Here is the "Tactical Pair" strategy we adopted to make mocks safe, reliable, and effective.
The Problem: Mock Drift vs. Integration Complexity
When testing systems, you face two opposing risks:
Mock Drift: Mocks are static, but reality evolves. If your database schema changes but your mock doesn’t, your test suite stays green while production breaks. The mock becomes a lie. 1.
The Integration Trap: Testing logic that depends on external volatility (like API rate limits or long-running workflows) against real dependencies is slow, flaky, and non-deterministic.
The solution isn’t to pick a side; it’s to sequence them. You should never mock a dependency until you have verified its behavior with a real, isolated Contract Test.
The Contract Test (Real Infrastructure)
A Contract Test verifies the Data Layer against real infrastructure. It does not test business logic; it tests the Round Trip.
The Database Contract (Postgres)
By writing data and immediately reading it back, we prove that the code speaks the correct SQL dialect and that the struct tags match the schema.
// internal/store/user_repo_test.go
// RUNS AGAINST: Real Postgres Container
func TestUserRepo_RoundTrip(t *testing.T) {
db := testdb.New(t)
repo := NewPostgresUserRepo(db)
// 1. Write Data (Prove the schema accepts it)
err := repo.UpsertIdentity(ctx, &Identity{UserID: "u1", Token: "xoxb-123"})
require.NoError(t, err)
// 2. Read Data (Prove we map it back correctly)
identity, err := repo.GetIdentity(ctx, "u1")
assert.NoError(t, err)
assert.Equal(t, "xoxb-123", identity.Token)
}
The API Contract (Salesforce)
For external APIs, we can’t always hit the real endpoint (due to rate limits or auth). Instead, we verify our client can parse Real JSON Responses. We use httpmock with a fixture captured from a real Salesforce call.
// internal/integrations/crm/sfdc_test.go
// RUNS AGAINST: HttpMock with Real JSON Fixture
func TestSFDC_FindAccountByDomain_ParsesResponse(t *testing.T) {
// 1. Load a real, messy JSON response captured from Salesforce
// This proves our struct tags match the actual API field names
fixture, _ := os.ReadFile("testdata/sfdc_account_response.json")
httpmock.RegisterResponder("GET", "https://na1.salesforce.com/services/data/v53.0/query",
httpmock.NewBytesResponder(200, fixture),
)
client := NewSFDCClient("valid-token")
// 2. Execute the Client Method
account, err := client.FindAccountByDomain(ctx, "acme.com")
// 3. Verify Parsing
assert.NoError(t, err)
assert.Equal(t, "001xx000003Dgsd", account.ID)
assert.Equal(t, "Acme Corp", account.Name)
}
Once these tests pass, we trust that GetIdentity speaks SQL and FindAccountByDomain speaks Salesforce JSON. We can now mock these contracts in higher-level tests without fear of drift.
You Still Need E2E Tests
It is important to note that Contract Tests are not a replacement for End-to-End (E2E) tests.
A Contract Test proves that "If the API returns X, my code does Y." It does not prove that the API still returns X. If Salesforce changes their API schema tomorrow (which they ideally shouldn’t, without a version change), our Contract Test will pass (because it uses a cached fixture), but production will fail.
We still rely on a lightweight layer of true E2E smoke tests (running on a schedule, not on every PR) to verify that our fixtures are still valid and that authentication flows haven’t changed.
Once these Contract tests pass, however, we trust the boundary. We can now mock these interfaces in our complex logic tests without fear of drift.
Designing for Testability
You cannot simply add mocks to coupled code. The application must define dependencies via interfaces rather than concrete structs.
Consider a worker that fetches a calendar event, checks a CRM, and sends a Slack notification.
Bad: Coupled Dependencies
type MeetingWorker struct {
db *sql.DB // Concrete SQL connection
nylasClient *nylas.Client // Concrete HTTP client
}
Good: Interface-Based Design
By defining contracts, we separate infrastructure (SQL, HTTP) from business value (filtering, alerting).
// internal/worker/meeting_prep.go
// The Contracts
type CalendarClient interface {
GetUpcomingEvents(ctx context.Context, token string) ([]Event, error)
}
type CRMClient interface {
FindAccountByDomain(ctx context.Context, domain string) (*Account, error)
}
type SlackNotifier interface {
PostMessage(ctx context.Context, userID string, msg string) error
}
// The Logic
type Runner struct {
calendar CalendarClient
crm CRMClient
slack SlackNotifier
}
func NewRunner(cal CalendarClient, crm CRMClient, slack SlackNotifier) *Runner {
return &Runner{ calendar: cal, crm: crm, slack: slack }
}
The Scenario Test (Mocked Logic)
With trusted contracts in place, we can use mocks to test "Impossible" scenarios—edge cases that are difficult or destructive to reproduce in a real environment.
Logic Filtering
Testing that nothing happens is notoriously flaky in integration tests. Mocks make this deterministic.
func TestProcess_Scenario_InternalMeeting(t *testing.T) {
// Simulate: CRM returns no match
mockCRM.On("FindAccountByDomain", ctx, "funnelstory.ai").Return(nil, nil)
// Expectation: DO NOT call Slack
mockSlack.AssertNotCalled(t, "PostMessage")
runner.ProcessMeeting(ctx, "user_1")
}
Error Handling
We can simulate specific downstream failures, such as a Rate Limit error, to ensure the worker re-queues the job correctly.
func TestProcess_Scenario_SlackRateLimit(t *testing.T) {
// Simulate: CRM Match
mockCRM.On("FindAccountByDomain", ctx, "acme.com").Return(&Account{ID: "001"}, nil)
// Simulate: Slack returns a specific error
mockSlack.On("PostMessage", ctx, "user_1", mock.Anything).
Return(errors.New("slack: rate limit_exceeded"))
err := runner.ProcessMeeting(ctx, "user_1")
// Verify the error bubbles up for the Job Queue to handle
assert.ErrorContains(t, err, "rate limit")
}
The Toolkit
To make this practical, we rely on a few battle-tested Go packages:
- https://pkg.go.dev/github.com/stretchr/testify/mock: The industry standard for assertions and mocking (
testify/mock). It allows us to generate mocks for our interfaces easily. - https://pkg.go.dev/github.com/jarcoal/httpmock: Essential for testing our API client contracts. It lets us intercept HTTP requests at the transport layer to verify URL structure and simulate JSON responses without hitting the network.
- https://pkg.go.dev/github.com/DATA-DOG/go-sqlmock: Used sparingly. We prefer real Postgres containers for Contract Tests, but
sqlmockis occasionally useful for testing complex transaction logic or driver-specific edge cases where a container is overkill.
Summary
This approach uses Contract Tests to prove the code interacts with the database and external APIs correctly, and Scenario Tests to prove the code handles business logic and errors correctly. This separation allows for high confidence in the system’s behavior without the overhead of maintaining a full end-to-end test suite for every edge case.