For medium-to-large Go projects, I'd avoid treating any one testing style as a rule. The projects that tend to age well usually combine several approaches, with each serving a different purpose.
Here's the pattern I've found scales best.
| Test type | Preferred style | Why |
|---|
| Pure functions | Table-driven tests | Compact, easy to extend, idiomatic Go |
| Components with dependencies | Helper functions + fakes/mocks | Keeps setup readable |
| API/behavior tests | Subtests (t.Run) | Organizes scenarios naturally |
| Integration tests | Real fixtures/test databases | Verifies actual wiring |
| Complex stateful systems | Small test harness | Avoids repetitive setup |
| Large OO-style suites | Rarely use testify/suite | Usually adds indirection without much benefit |
1. Table-driven tests are the default—but not everywhere
They're excellent whenever you're validating lots of input/output combinations.
func TestParseUser(t *testing.T) {
tests := []struct {
name string
input string
want User
wantErr bool
}{
{
name: "valid",
input: "...",
want: User{Name: "Alice"},
},
{
name: "missing name",
input: "...",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseUser(tt.input)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
Where people overdo it is forcing this structure onto tests with only one scenario or tests that require pages of setup.
If each table entry becomes 50 lines long, you've probably chosen the wrong abstraction.
2. Prefer helpers over giant fixtures
Instead of huge fixture files:
fixtures/
user1.json
user2.json
user3.json
prefer builders:
func NewTestUser() User {
return User{
ID: "123",
Name: "Alice",
}
}
or
user := NewTestUser(func(u *User) {
u.Admin = true
})
The advantages:
- defaults stay consistent
- only overridden fields are visible
- less fixture drift
- easier refactoring
Large JSON fixtures are still useful when you're testing parsing or compatibility with external APIs.
3. Share setup with helpers—not inheritance
A common pattern:
func setup(t *testing.T) *Service {
db := NewTestDB(t)
cache := NewFakeCache()
svc := NewService(db, cache)
t.Cleanup(func() {
db.Close()
})
return svc
}
Then each test starts with:
func TestCreateUser(t *testing.T) {
svc := setup(t)
...
}
This keeps every test independent while avoiding duplication.
4. Use subtests heavily
Instead of:
func TestFoo(t *testing.T)
func TestFooInvalid(t *testing.T)
func TestFooTimeout(t *testing.T)
consider:
func TestFoo(t *testing.T) {
t.Run("success", ...)
t.Run("timeout", ...)
t.Run("invalid token", ...)
}
Benefits include:
- shared setup
- better organization
- selective execution:
go test -run TestFoo/timeout
5. testify is great; testify/suite is often unnecessary
I use:
require.NoError(t, err)
assert.Equal(t, expected, actual)
constantly.
I rarely use:
type ServiceSuite struct {
suite.Suite
}
Reasons:
- hides setup
- encourages mutable shared state
- feels less idiomatic than plain Go
- doesn't compose as naturally with
t.Run
Most Go projects stick with standard testing plus assert/require.
6. For integration tests, build a small harness
Instead of repeating:
db := startDB()
redis := startRedis()
server := newServer(...)
client := newClient(...)
everywhere, create:
type Harness struct {
DB *sql.DB
Client *Client
}
func NewHarness(t *testing.T) *Harness
Then:
func TestPurchaseFlow(t *testing.T) {
h := NewHarness(t)
...
}
This scales much better than dozens of copied setup blocks.
7. Organize test helpers separately
A structure that works well is:
service/
service.go
service_test.go
helpers_test.go
builders_test.go
integration_test.go
Because files ending in _test.go are excluded from production builds, you can freely add helper functions without affecting your shipping binary.
For larger repositories:
internal/testutil/
db.go
http.go
auth.go
builders.go
Shared helpers live there instead of being copied across packages.
8. Mock behavior, not implementation
Instead of mocking every dependency, define small interfaces:
type UserStore interface {
Save(User) error
}
Then write a tiny fake:
type FakeUserStore struct {
Saved []User
}
Handwritten fakes are often easier to understand and maintain than generated mocks, especially when behavior matters more than call counts.
What I'd recommend for a medium-to-large codebase
- ✅ Standard
testing package
- ✅ Table-driven tests for pure logic
- ✅
t.Run for scenarios
- ✅
require/assert from testify
- ✅ Builder functions for test data
- ✅ Small setup helpers using
t.Helper()
- ✅ Lightweight integration harnesses
- ✅ Handwritten fakes where practical
- ⚠️ JSON fixtures only when they're the thing being tested
- ⚠️
testify/suite only if you truly benefit from lifecycle hooks and shared state
This combination stays close to idiomatic Go while remaining maintainable as a codebase grows into hundreds or thousands of tests. It minimizes hidden state, keeps tests readable, and makes it straightforward to add new scenarios without introducing unnecessary abstractions.