11# Working without mocks, stubs and spies
22
3- This chapter delves into the world of test doubles and explores how they influence the testing and development process. We'll uncover the limitations of traditional mocks, stubs, and spies and introduce a more efficient and adaptable approach using fakes and contracts. These methods simplify testing, enhance local development experiences, and streamline the management of evolving dependencies.
3+ This chapter delves into the world of test doubles and explores how they influence the testing and development process. We'll uncover the limitations of traditional mocks, stubs, and spies and introduce a more efficient and adaptable approach using fakes and contracts.
44
5- This is a longer chapter than normal, so as a palette cleanser, you might want to explore an [ example repo first] ( https://github.com/quii/go-fakes-and-contracts ) . In particular, check out the [ planner test] ( https://github.com/quii/go-fakes-and-contracts/blob/main/domain/planner/planner_test.go ) .
5+ ## tl;dr
6+
7+ - Mocks, spies and stubs encourage you to encode assumptions of the behaviour of your dependencies ad-hocly in each test.
8+ - These assumptions are usually not validated beyond manual checking, so they threaten your test suite's usefulness.
9+ - Fakes and contracts give us a more sustainable method for creating test doubles with validated assumptions and better reuse than the alternatives.
10+
11+ This is a longer chapter than normal, so as a palette cleanser, you should explore an [ example repo first] ( https://github.com/quii/go-fakes-and-contracts ) . In particular, check out the [ planner test] ( https://github.com/quii/go-fakes-and-contracts/blob/main/domain/planner/planner_test.go ) .
612
713---
814
@@ -22,16 +28,16 @@ It's easy to roll your eyes when people like me are pedantic about the nomenclat
2228- Avoid latency and other performance issues
2329- Unable to exercise non-happy path cases
2430- Decoupling your build from another team's.
25- - You wouldn't want to prevent deployments if an engineer in another team accidentally shipped a bug
31+ - You wouldn't want to prevent deployments if an engineer in another team accidentally shipped a bug
2632
2733In Go, you'll typically model a dependency with an interface, then implement your version to control the behaviour in a test. ** Here are the kinds of test doubles covered in this post** .
2834
2935Given this interface of a hypothetical recipe API:
3036
3137``` go
3238type RecipeBook interface {
33- GetRecipes () ([]Recipe, error )
34- AddRecipes (...Recipe ) error
39+ GetRecipes () ([]Recipe, error )
40+ AddRecipes (...Recipe ) error
3541}
3642```
3743
@@ -41,12 +47,12 @@ We can construct test doubles in various ways, depending on how we're trying to
4147
4248``` go
4349type StubRecipeStore struct {
44- recipes []Recipe
45- err error
50+ recipes []Recipe
51+ err error
4652}
4753
4854func (s *StubRecipeStore ) GetRecipes () ([]Recipe , error ) {
49- return s.recipes , s.err
55+ return s.recipes , s.err
5056}
5157
5258// AddRecipes omitted for brevity
@@ -59,13 +65,13 @@ stubStore := &StubRecipeStore{recipes: someRecipes}
5965
6066``` go
6167type SpyRecipeStore struct {
62- AddCalls [][]Recipe
63- err error
68+ AddCalls [][]Recipe
69+ err error
6470}
6571
6672func (s *SpyRecipeStore ) AddRecipes (r ...Recipe ) error {
67- s.AddCalls = append (s.AddCalls , r)
68- return s.err
73+ s.AddCalls = append (s.AddCalls , r)
74+ return s.err
6975}
7076
7177// GetRecipes omitted for brevity
@@ -92,16 +98,16 @@ mockStore.WhenCalledWith(someRecipes).return(someError)
9298
9399``` go
94100type FakeRecipeStore struct {
95- recipes []Recipe
101+ recipes []Recipe
96102}
97103
98104func (f *FakeRecipeStore ) GetRecipes () ([]Recipe , error ) {
99- return f.recipes , nil
105+ return f.recipes , nil
100106}
101107
102108func (f *FakeRecipeStore ) AddRecipes (r ...Recipe ) error {
103- f.recipes = append (f.recipes , r...)
104- return nil
109+ f.recipes = append (f.recipes , r...)
110+ return nil
105111}
106112```
107113
@@ -254,54 +260,54 @@ Here is an example of a contract for one of the APIs the system depends on
254260
255261``` go
256262type API1Customer struct {
257- Name string
258- ID string
263+ Name string
264+ ID string
259265}
260266
261267type API1 interface {
262- CreateCustomer (ctx context.Context , name string ) (API1Customer, error )
263- GetCustomer (ctx context.Context , id string ) (API1Customer, error )
264- UpdateCustomer (ctx context.Context , id string , name string ) error
268+ CreateCustomer (ctx context.Context , name string ) (API1Customer, error )
269+ GetCustomer (ctx context.Context , id string ) (API1Customer, error )
270+ UpdateCustomer (ctx context.Context , id string , name string ) error
265271}
266272
267273type API1Contract struct {
268- NewAPI1 func () API1
274+ NewAPI1 func () API1
269275}
270276
271277func (c API1Contract ) Test (t *testing .T ) {
272- t.Run (" can create, get and update a customer" , func (t *testing.T ) {
273- var (
274- ctx = context.Background ()
275- sut = c.NewAPI1 ()
276- name = " Bob"
277- )
278-
279- customer , err := sut.CreateCustomer (ctx, name)
280- expect.NoErr (t, err)
281-
282- got , err := sut.GetCustomer (ctx, customer.ID )
283- expect.NoErr (t, err)
284- expect.Equal (t, customer, got)
285-
286- newName := " Robert"
287- expect.NoErr (t, sut.UpdateCustomer (ctx, customer.ID , newName))
288-
289- got, err = sut.GetCustomer (ctx, customer.ID )
290- expect.NoErr (t, err)
291- expect.Equal (t, newName, got.Name )
292- })
293-
294- // example of strange behaviours we didn't expect
295- t.Run (" the system will not allow you to add 'Dave' as a customer" , func (t *testing.T ) {
296- var (
297- ctx = context.Background ()
298- sut = c.NewAPI1 ()
299- name = " Dave"
300- )
301-
302- _ , err := sut.CreateCustomer (ctx, name)
303- expect.Err (t, ErrDaveIsForbidden)
304- })
278+ t.Run (" can create, get and update a customer" , func (t *testing.T ) {
279+ var (
280+ ctx = context.Background ()
281+ sut = c.NewAPI1 ()
282+ name = " Bob"
283+ )
284+
285+ customer , err := sut.CreateCustomer (ctx, name)
286+ expect.NoErr (t, err)
287+
288+ got , err := sut.GetCustomer (ctx, customer.ID )
289+ expect.NoErr (t, err)
290+ expect.Equal (t, customer, got)
291+
292+ newName := " Robert"
293+ expect.NoErr (t, sut.UpdateCustomer (ctx, customer.ID , newName))
294+
295+ got, err = sut.GetCustomer (ctx, customer.ID )
296+ expect.NoErr (t, err)
297+ expect.Equal (t, newName, got.Name )
298+ })
299+
300+ // example of strange behaviours we didn't expect
301+ t.Run (" the system will not allow you to add 'Dave' as a customer" , func (t *testing.T ) {
302+ var (
303+ ctx = context.Background ()
304+ sut = c.NewAPI1 ()
305+ name = " Dave"
306+ )
307+
308+ _ , err := sut.CreateCustomer (ctx, name)
309+ expect.Err (t, ErrDaveIsForbidden)
310+ })
305311}
306312```
307313
@@ -316,47 +322,47 @@ To create our in-memory fake, we can use the contract in a test.
316322
317323``` go
318324func TestInMemoryAPI1 (t *testing .T ) {
319- API1Contract{NewAPI1: func () API1 {
320- return inmemory.NewAPI1 ()
321- }}.Test (t)
325+ API1Contract{NewAPI1: func () API1 {
326+ return inmemory.NewAPI1 ()
327+ }}.Test (t)
322328}
323329```
324330
325331And here is the fake's code
326332
327333``` go
328334func NewAPI1 () *API1 {
329- return &API1{customers: make (map [string ]planner.API1Customer )}
335+ return &API1{customers: make (map [string ]planner.API1Customer )}
330336}
331337
332338type API1 struct {
333- i int
334- customers map [string ]planner.API1Customer
339+ i int
340+ customers map [string ]planner.API1Customer
335341}
336342
337343func (a *API1 ) CreateCustomer (ctx context .Context , name string ) (planner .API1Customer , error ) {
338- if name == " Dave" {
339- return planner.API1Customer {}, ErrDaveIsForbidden
340- }
341-
342- newCustomer := planner.API1Customer {
343- Name: name,
344- ID: strconv.Itoa (a.i ),
345- }
346- a.customers [newCustomer.ID ] = newCustomer
347- a.i ++
348- return newCustomer, nil
344+ if name == " Dave" {
345+ return planner.API1Customer {}, ErrDaveIsForbidden
346+ }
347+
348+ newCustomer := planner.API1Customer {
349+ Name: name,
350+ ID: strconv.Itoa (a.i ),
351+ }
352+ a.customers [newCustomer.ID ] = newCustomer
353+ a.i ++
354+ return newCustomer, nil
349355}
350356
351357func (a *API1 ) GetCustomer (ctx context .Context , id string ) (planner .API1Customer , error ) {
352- return a.customers [id], nil
358+ return a.customers [id], nil
353359}
354360
355361func (a *API1 ) UpdateCustomer (ctx context .Context , id string , name string ) error {
356- customer := a.customers [id]
357- customer.Name = name
358- a.customers [id] = customer
359- return nil
362+ customer := a.customers [id]
363+ customer.Name = name
364+ a.customers [id] = customer
365+ return nil
360366}
361367```
362368
@@ -401,38 +407,38 @@ Returning to the `API1` example, we can create a type that implements the needed
401407
402408``` go
403409type API1Decorator struct {
404- delegate API1
405- CreateCustomerFunc func (ctx context.Context , name string ) (API1Customer, error )
406- GetCustomerFunc func (ctx context.Context , id string ) (API1Customer, error )
407- UpdateCustomerFunc func (ctx context.Context , id string , name string ) error
410+ delegate API1
411+ CreateCustomerFunc func (ctx context.Context , name string ) (API1Customer, error )
412+ GetCustomerFunc func (ctx context.Context , id string ) (API1Customer, error )
413+ UpdateCustomerFunc func (ctx context.Context , id string , name string ) error
408414}
409415
410416// assert API1Decorator implements API1
411417var _ API1 = &API1Decorator{}
412418
413419func NewAPI1Decorator (delegate API1 ) *API1Decorator {
414- return &API1Decorator{delegate: delegate}
420+ return &API1Decorator{delegate: delegate}
415421}
416422
417423func (a *API1Decorator ) CreateCustomer (ctx context .Context , name string ) (API1Customer , error ) {
418- if a.CreateCustomerFunc != nil {
419- return a.CreateCustomerFunc (ctx, name)
420- }
421- return a.delegate .CreateCustomer (ctx, name)
424+ if a.CreateCustomerFunc != nil {
425+ return a.CreateCustomerFunc (ctx, name)
426+ }
427+ return a.delegate .CreateCustomer (ctx, name)
422428}
423429
424430func (a *API1Decorator ) GetCustomer (ctx context .Context , id string ) (API1Customer , error ) {
425- if a.GetCustomerFunc != nil {
426- return a.GetCustomerFunc (ctx, id)
427- }
428- return a.delegate .GetCustomer (ctx, id)
431+ if a.GetCustomerFunc != nil {
432+ return a.GetCustomerFunc (ctx, id)
433+ }
434+ return a.delegate .GetCustomer (ctx, id)
429435}
430436
431437func (a *API1Decorator ) UpdateCustomer (ctx context .Context , id string , name string ) error {
432- if a.UpdateCustomerFunc != nil {
433- return a.UpdateCustomerFunc (ctx, id, name)
434- }
435- return a.delegate .UpdateCustomer (ctx, id, name)
438+ if a.UpdateCustomerFunc != nil {
439+ return a.UpdateCustomerFunc (ctx, id, name)
440+ }
441+ return a.delegate .UpdateCustomer (ctx, id, name)
436442}
437443```
438444
@@ -441,7 +447,7 @@ In our tests, we can then use the `XXXFunc` field to modify the behaviour of the
441447``` go
442448failingAPI1 = NewAPI1Decorator (inmemory.NewAPI1 ())
443449failingAPI1.UpdateCustomerFunc = func (ctx context.Context , id string , name string ) error {
444- return errors.New (" failed to update customer" )
450+ return errors.New (" failed to update customer" )
445451})
446452```
447453
@@ -492,17 +498,17 @@ Follow the TDD approach described above to drive out your persistence needs.
492498package inmemory_test
493499
494500import (
495- " github.com/quii/go-fakes-and-contracts/adapters/driven/persistence/inmemory"
496- " github.com/quii/go-fakes-and-contracts/domain/planner"
497- " testing"
501+ " github.com/quii/go-fakes-and-contracts/adapters/driven/persistence/inmemory"
502+ " github.com/quii/go-fakes-and-contracts/domain/planner"
503+ " testing"
498504)
499505
500506func TestInMemoryPantry (t *testing .T ) {
501- planner.PantryContract {
502- NewPantry: func () planner.Pantry {
503- return inmemory.NewPantry ()
504- },
505- }.Test (t)
507+ planner.PantryContract {
508+ NewPantry: func () planner.Pantry {
509+ return inmemory.NewPantry ()
510+ },
511+ }.Test (t)
506512}
507513
508514```
@@ -511,24 +517,24 @@ func TestInMemoryPantry(t *testing.T) {
511517package sqlite_test
512518
513519import (
514- " github.com/quii/go-fakes-and-contracts/adapters/driven/persistence/sqlite"
515- " github.com/quii/go-fakes-and-contracts/domain/planner"
516- " testing"
520+ " github.com/quii/go-fakes-and-contracts/adapters/driven/persistence/sqlite"
521+ " github.com/quii/go-fakes-and-contracts/domain/planner"
522+ " testing"
517523)
518524
519525func TestSQLitePantry (t *testing .T ) {
520- client := sqlite.NewSQLiteClient ()
521- t.Cleanup (func () {
522- if err := client.Close (); err != nil {
523- t.Error (err)
524- }
525- })
526-
527- planner.PantryContract {
528- NewPantry: func () planner.Pantry {
529- return sqlite.NewPantry (client)
530- },
531- }.Test (t)
526+ client := sqlite.NewSQLiteClient ()
527+ t.Cleanup (func () {
528+ if err := client.Close (); err != nil {
529+ t.Error (err)
530+ }
531+ })
532+
533+ planner.PantryContract {
534+ NewPantry: func () planner.Pantry {
535+ return sqlite.NewPantry (client)
536+ },
537+ }.Test (t)
532538}
533539
534540```
0 commit comments