(I should add that this is explicitly contra to e.g sethammons's suggestion above, which seems to be relatively common in the part of the Go community that come from PHP. I inherited a couple large projects that did this. Today they use sqlite instead, and both the program and test code is ~50% the size it used to be.)
For us, stub injection points come naturally out of 12-factor-style application design; the program can already configure the address of the 2-3 other things it needs to talk to or files it needs to output, etc, just out of our need for manual testing or staging vs. production environments. If you have technical leadership encouraging Spring-but-in-Go, you'll probably hit a wall here too though.
It's also possible you're simply writing too many functions that can return errors. Over-complex code makes over-complex tests; always think about whether you're handling an error or a programming mistake - if the latter, panic instead of returning.
func main
foo, err := NewFoo()
handle err
bar, err := NewBar(foo)
handle err
Given a single component, each external dependency should be injected as an interface. type Fooer interface{ Foo() int }
func NewBar(f Fooer) (*Bar, error) {
...
}
Test components in isolation by providing mock (stub, fake, whatever, it's all meaningless) implementations of its dependencies. func TestBar(t *testing.T) {
f := &mockFoo{...}
b, err := NewBar(f)
...The unit under test is a gateway to a configuration service.
Your gateway struct hopefully looks something like
type Fooer interface{ ... }
type Barer interface{ ... }
type gateway struct{ f Fooer; b Barer; ... }
newGateway(f Fooer, b Barer, ...) (*gateway, error) { ... }
func (g *gateway) StartTransaction(...)
That is, a gateway is something that depends on other things, modeled as interfaces, and provides capabilities as methods. +--------------------+
| gateway |
| - f Fooer |
| - b Barer |
| - ... |
| |
input -------> StartTransaction -----> output
| ... |
+--------------------+
When you want to exercise this code, you want to construct an instance with mock/deterministic dependencies, so that you have predictable results when you apply input and receive output. That's the model: give input, assert output.But your linked code is kind of different! Each subtest varies not the input but the behavior of the mocked dependencies. I understand the point: you want to run through all the codepaths in the gateway method. But is that worth testing? Do the tests meaningfully reduce risk? I dunno. It's not obvious to me that they do.
The use of gomock is also a big smell. Generating mocks kind of defeats the purpose of using them. I would definitely write a bespoke client:
type mockClient struct {
StartTransaction func(...) (xxx.Transaction, error)
Upsert func(...) (xxx.Result, error)
AbortTransavtion func(...) (xxx.Xxx, error)
}
Then each test case is simpler to express as for _, tc := range []struct{
name string
client *mockClient
input UpdateConfigRequest
startRes xxx.Transaction
startErr error
upsertRes xxx.Result
upsertErr error
res xxx.UpdateConfigResponse
err error
} {
{
name: "success",
client: &mockClient{StartTransaction: good, ...},
startRes: ...,
upsertRes: ...,
res: ...,
},
{
name: "bad start",
client: &mockClient{StartTransaction: bad, ...},
startErr: ...,
},
{
name: "bad upsert",
client: &mockClient{StartTransaction: good, Upsert: bad, ...},
startRes: ...,
upsertErr: ...,
},
...
} {
t.Run(tc.name, func(t *testing.T) {
g := newGateway{tc.client}
output := g.StartTransaction(tc.input)
// asserts
})
}The resulting code probably wouldn't be shorter, but it would exercise a lot more of the real code paths. The availability of a test server with expectation methods could also (IMO) improve readability. Instead of trying to model multiple variants of behavior via a single test case table, using a suite setup + methods (e.g. `s.ExpectStartTransaction(...); s.ExpectUpsert(...)`) would make clearer test bodies. Check sqlmock for something I think is a good example of a fluent expectation API in Go.