1
1
# Working without mocks, stubs and spies
2
2
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.
4
4
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 ) .
6
12
7
13
---
8
14
@@ -22,16 +28,16 @@ It's easy to roll your eyes when people like me are pedantic about the nomenclat
22
28
- Avoid latency and other performance issues
23
29
- Unable to exercise non-happy path cases
24
30
- 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
26
32
27
33
In 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** .
28
34
29
35
Given this interface of a hypothetical recipe API:
30
36
31
37
``` go
32
38
type RecipeBook interface {
33
- GetRecipes () ([]Recipe, error )
34
- AddRecipes (...Recipe ) error
39
+ GetRecipes () ([]Recipe, error )
40
+ AddRecipes (...Recipe ) error
35
41
}
36
42
```
37
43
@@ -41,12 +47,12 @@ We can construct test doubles in various ways, depending on how we're trying to
41
47
42
48
``` go
43
49
type StubRecipeStore struct {
44
- recipes []Recipe
45
- err error
50
+ recipes []Recipe
51
+ err error
46
52
}
47
53
48
54
func (s *StubRecipeStore ) GetRecipes () ([]Recipe , error ) {
49
- return s.recipes , s.err
55
+ return s.recipes , s.err
50
56
}
51
57
52
58
// AddRecipes omitted for brevity
@@ -59,13 +65,13 @@ stubStore := &StubRecipeStore{recipes: someRecipes}
59
65
60
66
``` go
61
67
type SpyRecipeStore struct {
62
- AddCalls [][]Recipe
63
- err error
68
+ AddCalls [][]Recipe
69
+ err error
64
70
}
65
71
66
72
func (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
69
75
}
70
76
71
77
// GetRecipes omitted for brevity
@@ -92,16 +98,16 @@ mockStore.WhenCalledWith(someRecipes).return(someError)
92
98
93
99
``` go
94
100
type FakeRecipeStore struct {
95
- recipes []Recipe
101
+ recipes []Recipe
96
102
}
97
103
98
104
func (f *FakeRecipeStore ) GetRecipes () ([]Recipe , error ) {
99
- return f.recipes , nil
105
+ return f.recipes , nil
100
106
}
101
107
102
108
func (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
105
111
}
106
112
```
107
113
@@ -254,54 +260,54 @@ Here is an example of a contract for one of the APIs the system depends on
254
260
255
261
``` go
256
262
type API1Customer struct {
257
- Name string
258
- ID string
263
+ Name string
264
+ ID string
259
265
}
260
266
261
267
type 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
265
271
}
266
272
267
273
type API1Contract struct {
268
- NewAPI1 func () API1
274
+ NewAPI1 func () API1
269
275
}
270
276
271
277
func (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
+ })
305
311
}
306
312
```
307
313
@@ -316,47 +322,47 @@ To create our in-memory fake, we can use the contract in a test.
316
322
317
323
``` go
318
324
func 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)
322
328
}
323
329
```
324
330
325
331
And here is the fake's code
326
332
327
333
``` go
328
334
func NewAPI1 () *API1 {
329
- return &API1{customers: make (map [string ]planner.API1Customer )}
335
+ return &API1{customers: make (map [string ]planner.API1Customer )}
330
336
}
331
337
332
338
type API1 struct {
333
- i int
334
- customers map [string ]planner.API1Customer
339
+ i int
340
+ customers map [string ]planner.API1Customer
335
341
}
336
342
337
343
func (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
349
355
}
350
356
351
357
func (a *API1 ) GetCustomer (ctx context .Context , id string ) (planner .API1Customer , error ) {
352
- return a.customers [id], nil
358
+ return a.customers [id], nil
353
359
}
354
360
355
361
func (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
360
366
}
361
367
```
362
368
@@ -401,38 +407,38 @@ Returning to the `API1` example, we can create a type that implements the needed
401
407
402
408
``` go
403
409
type 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
408
414
}
409
415
410
416
// assert API1Decorator implements API1
411
417
var _ API1 = &API1Decorator{}
412
418
413
419
func NewAPI1Decorator (delegate API1 ) *API1Decorator {
414
- return &API1Decorator{delegate: delegate}
420
+ return &API1Decorator{delegate: delegate}
415
421
}
416
422
417
423
func (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)
422
428
}
423
429
424
430
func (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)
429
435
}
430
436
431
437
func (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)
436
442
}
437
443
```
438
444
@@ -441,7 +447,7 @@ In our tests, we can then use the `XXXFunc` field to modify the behaviour of the
441
447
``` go
442
448
failingAPI1 = NewAPI1Decorator (inmemory.NewAPI1 ())
443
449
failingAPI1.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" )
445
451
})
446
452
```
447
453
@@ -492,17 +498,17 @@ Follow the TDD approach described above to drive out your persistence needs.
492
498
package inmemory_test
493
499
494
500
import (
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"
498
504
)
499
505
500
506
func 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)
506
512
}
507
513
508
514
```
@@ -511,24 +517,24 @@ func TestInMemoryPantry(t *testing.T) {
511
517
package sqlite_test
512
518
513
519
import (
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"
517
523
)
518
524
519
525
func 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)
532
538
}
533
539
534
540
```
0 commit comments