6 ms·
Maintainable software projects are modeled as a dependency graph of components that encapsulate implementation details and depend on other components. func
by sagichmal 5y ago
Maintainable software projects are modeled as a dependency graph of components that encapsulate implementation details and depend on other components.
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)
...
- closeparen 5y agoYou're giving a baby-level introduction to mocking in a thread about how this approach leads to low-quality, meaningless tests in some cases, and right beneath concrete suggestions about how to make tests better by deviating from this pattern.
- sagichmal 5y agoI'm sorry if you've had bad experiences with this approach in the past, but it emphatically does not lead to low quality and/or meaningless tests. It's the essential foundation of well-abstracted and maintainable software.
- closeparen 5y agoHere's a test I wrote recently, in the style expected at my company. Tell me what exactly you think this is contributing to maintainability, or what you think could be done better. I spent an hour on this and found it pure drudgery. I half suspect I could have written a code generator for it in that hour instead. I had no idea whether the code really worked until I ran it against the real upstream. The unit under test is a gateway to a configuration service. https://dpaste.com/FZGC8R66K https://dpaste.com/FZGC8R66K
- sagichmal 5y agoWow! No wonder you find this tedious. 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 }) }
- morelisp 5y agoIt's hard to give solid advice based on a view of this single layer, but at a glance unless this gateway client is itself something to be extended by other projects, this is probably not something I would write test cases for per se. If "apipb" stands for protobuf, I definitely wouldn't inject a mock here but would make a real pb server listening with expectations and canned responses. (Our protobuf services have something like this available in the same package as the client, i.e. anyone using the client also has the tools to write the application-specific tests they need.) 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.