From 6b481cf7633a19fffb98c0059d0a467793c4991f Mon Sep 17 00:00:00 2001 From: Chris James Date: Thu, 28 Sep 2023 17:35:12 +0100 Subject: [PATCH] put a tl;dr for fakes and contracts --- working-without-mocks.md | 242 ++++++++++++++++++++------------------- 1 file changed, 124 insertions(+), 118 deletions(-) diff --git a/working-without-mocks.md b/working-without-mocks.md index e62ccf43..f277dc06 100644 --- a/working-without-mocks.md +++ b/working-without-mocks.md @@ -1,8 +1,14 @@ # Working without mocks, stubs and spies -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. +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. -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). +## tl;dr + +- Mocks, spies and stubs encourage you to encode assumptions of the behaviour of your dependencies ad-hocly in each test. +- These assumptions are usually not validated beyond manual checking, so they threaten your test suite's usefulness. +- Fakes and contracts give us a more sustainable method for creating test doubles with validated assumptions and better reuse than the alternatives. + +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). --- @@ -22,7 +28,7 @@ It's easy to roll your eyes when people like me are pedantic about the nomenclat - Avoid latency and other performance issues - Unable to exercise non-happy path cases - Decoupling your build from another team's. - - You wouldn't want to prevent deployments if an engineer in another team accidentally shipped a bug + - You wouldn't want to prevent deployments if an engineer in another team accidentally shipped a bug 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**. @@ -30,8 +36,8 @@ Given this interface of a hypothetical recipe API: ```go type RecipeBook interface { - GetRecipes() ([]Recipe, error) - AddRecipes(...Recipe) error +GetRecipes() ([]Recipe, error) +AddRecipes(...Recipe) error } ``` @@ -41,12 +47,12 @@ We can construct test doubles in various ways, depending on how we're trying to ```go type StubRecipeStore struct { - recipes []Recipe - err error +recipes []Recipe +err error } func (s *StubRecipeStore) GetRecipes() ([]Recipe, error) { - return s.recipes, s.err +return s.recipes, s.err } // AddRecipes omitted for brevity @@ -59,13 +65,13 @@ stubStore := &StubRecipeStore{recipes: someRecipes} ```go type SpyRecipeStore struct { - AddCalls [][]Recipe - err error +AddCalls [][]Recipe +err error } func (s *SpyRecipeStore) AddRecipes(r ...Recipe) error { - s.AddCalls = append(s.AddCalls, r) - return s.err +s.AddCalls = append(s.AddCalls, r) +return s.err } // GetRecipes omitted for brevity @@ -92,16 +98,16 @@ mockStore.WhenCalledWith(someRecipes).return(someError) ```go type FakeRecipeStore struct { - recipes []Recipe +recipes []Recipe } func (f *FakeRecipeStore) GetRecipes() ([]Recipe, error) { - return f.recipes, nil +return f.recipes, nil } func (f *FakeRecipeStore) AddRecipes(r ...Recipe) error { - f.recipes = append(f.recipes, r...) - return nil +f.recipes = append(f.recipes, r...) +return nil } ``` @@ -254,54 +260,54 @@ Here is an example of a contract for one of the APIs the system depends on ```go type API1Customer struct { - Name string - ID string +Name string +ID string } type API1 interface { - CreateCustomer(ctx context.Context, name string) (API1Customer, error) - GetCustomer(ctx context.Context, id string) (API1Customer, error) - UpdateCustomer(ctx context.Context, id string, name string) error +CreateCustomer(ctx context.Context, name string) (API1Customer, error) +GetCustomer(ctx context.Context, id string) (API1Customer, error) +UpdateCustomer(ctx context.Context, id string, name string) error } type API1Contract struct { - NewAPI1 func() API1 +NewAPI1 func() API1 } func (c API1Contract) Test(t *testing.T) { - t.Run("can create, get and update a customer", func(t *testing.T) { - var ( - ctx = context.Background() - sut = c.NewAPI1() - name = "Bob" - ) - - customer, err := sut.CreateCustomer(ctx, name) - expect.NoErr(t, err) - - got, err := sut.GetCustomer(ctx, customer.ID) - expect.NoErr(t, err) - expect.Equal(t, customer, got) - - newName := "Robert" - expect.NoErr(t, sut.UpdateCustomer(ctx, customer.ID, newName)) - - got, err = sut.GetCustomer(ctx, customer.ID) - expect.NoErr(t, err) - expect.Equal(t, newName, got.Name) - }) - - // example of strange behaviours we didn't expect - t.Run("the system will not allow you to add 'Dave' as a customer", func(t *testing.T) { - var ( - ctx = context.Background() - sut = c.NewAPI1() - name = "Dave" - ) - - _, err := sut.CreateCustomer(ctx, name) - expect.Err(t, ErrDaveIsForbidden) - }) +t.Run("can create, get and update a customer", func(t *testing.T) { +var ( +ctx = context.Background() +sut = c.NewAPI1() +name = "Bob" +) + +customer, err := sut.CreateCustomer(ctx, name) +expect.NoErr(t, err) + +got, err := sut.GetCustomer(ctx, customer.ID) +expect.NoErr(t, err) +expect.Equal(t, customer, got) + +newName := "Robert" +expect.NoErr(t, sut.UpdateCustomer(ctx, customer.ID, newName)) + +got, err = sut.GetCustomer(ctx, customer.ID) +expect.NoErr(t, err) +expect.Equal(t, newName, got.Name) +}) + +// example of strange behaviours we didn't expect +t.Run("the system will not allow you to add 'Dave' as a customer", func(t *testing.T) { +var ( +ctx = context.Background() +sut = c.NewAPI1() +name = "Dave" +) + +_, err := sut.CreateCustomer(ctx, name) +expect.Err(t, ErrDaveIsForbidden) +}) } ``` @@ -316,9 +322,9 @@ To create our in-memory fake, we can use the contract in a test. ```go func TestInMemoryAPI1(t *testing.T) { - API1Contract{NewAPI1: func() API1 { - return inmemory.NewAPI1() - }}.Test(t) +API1Contract{NewAPI1: func() API1 { +return inmemory.NewAPI1() +}}.Test(t) } ``` @@ -326,37 +332,37 @@ And here is the fake's code ```go func NewAPI1() *API1 { - return &API1{customers: make(map[string]planner.API1Customer)} +return &API1{customers: make(map[string]planner.API1Customer)} } type API1 struct { - i int - customers map[string]planner.API1Customer +i int +customers map[string]planner.API1Customer } func (a *API1) CreateCustomer(ctx context.Context, name string) (planner.API1Customer, error) { - if name == "Dave" { - return planner.API1Customer{}, ErrDaveIsForbidden - } - - newCustomer := planner.API1Customer{ - Name: name, - ID: strconv.Itoa(a.i), - } - a.customers[newCustomer.ID] = newCustomer - a.i++ - return newCustomer, nil +if name == "Dave" { +return planner.API1Customer{}, ErrDaveIsForbidden +} + +newCustomer := planner.API1Customer{ +Name: name, +ID: strconv.Itoa(a.i), +} +a.customers[newCustomer.ID] = newCustomer +a.i++ +return newCustomer, nil } func (a *API1) GetCustomer(ctx context.Context, id string) (planner.API1Customer, error) { - return a.customers[id], nil +return a.customers[id], nil } func (a *API1) UpdateCustomer(ctx context.Context, id string, name string) error { - customer := a.customers[id] - customer.Name = name - a.customers[id] = customer - return nil +customer := a.customers[id] +customer.Name = name +a.customers[id] = customer +return nil } ``` @@ -401,38 +407,38 @@ Returning to the `API1` example, we can create a type that implements the needed ```go type API1Decorator struct { - delegate API1 - CreateCustomerFunc func(ctx context.Context, name string) (API1Customer, error) - GetCustomerFunc func(ctx context.Context, id string) (API1Customer, error) - UpdateCustomerFunc func(ctx context.Context, id string, name string) error +delegate API1 +CreateCustomerFunc func(ctx context.Context, name string) (API1Customer, error) +GetCustomerFunc func(ctx context.Context, id string) (API1Customer, error) +UpdateCustomerFunc func(ctx context.Context, id string, name string) error } // assert API1Decorator implements API1 var _ API1 = &API1Decorator{} func NewAPI1Decorator(delegate API1) *API1Decorator { - return &API1Decorator{delegate: delegate} +return &API1Decorator{delegate: delegate} } func (a *API1Decorator) CreateCustomer(ctx context.Context, name string) (API1Customer, error) { - if a.CreateCustomerFunc != nil { - return a.CreateCustomerFunc(ctx, name) - } - return a.delegate.CreateCustomer(ctx, name) +if a.CreateCustomerFunc != nil { +return a.CreateCustomerFunc(ctx, name) +} +return a.delegate.CreateCustomer(ctx, name) } func (a *API1Decorator) GetCustomer(ctx context.Context, id string) (API1Customer, error) { - if a.GetCustomerFunc != nil { - return a.GetCustomerFunc(ctx, id) - } - return a.delegate.GetCustomer(ctx, id) +if a.GetCustomerFunc != nil { +return a.GetCustomerFunc(ctx, id) +} +return a.delegate.GetCustomer(ctx, id) } func (a *API1Decorator) UpdateCustomer(ctx context.Context, id string, name string) error { - if a.UpdateCustomerFunc != nil { - return a.UpdateCustomerFunc(ctx, id, name) - } - return a.delegate.UpdateCustomer(ctx, id, name) +if a.UpdateCustomerFunc != nil { +return a.UpdateCustomerFunc(ctx, id, name) +} +return a.delegate.UpdateCustomer(ctx, id, name) } ``` @@ -441,7 +447,7 @@ In our tests, we can then use the `XXXFunc` field to modify the behaviour of the ```go failingAPI1 = NewAPI1Decorator(inmemory.NewAPI1()) failingAPI1.UpdateCustomerFunc = func(ctx context.Context, id string, name string) error { - return errors.New("failed to update customer") +return errors.New("failed to update customer") }) ``` @@ -492,17 +498,17 @@ Follow the TDD approach described above to drive out your persistence needs. package inmemory_test import ( - "github.com/quii/go-fakes-and-contracts/adapters/driven/persistence/inmemory" - "github.com/quii/go-fakes-and-contracts/domain/planner" - "testing" + "github.com/quii/go-fakes-and-contracts/adapters/driven/persistence/inmemory" + "github.com/quii/go-fakes-and-contracts/domain/planner" + "testing" ) func TestInMemoryPantry(t *testing.T) { - planner.PantryContract{ - NewPantry: func() planner.Pantry { - return inmemory.NewPantry() - }, - }.Test(t) + planner.PantryContract{ + NewPantry: func() planner.Pantry { + return inmemory.NewPantry() + }, + }.Test(t) } ``` @@ -511,24 +517,24 @@ func TestInMemoryPantry(t *testing.T) { package sqlite_test import ( - "github.com/quii/go-fakes-and-contracts/adapters/driven/persistence/sqlite" - "github.com/quii/go-fakes-and-contracts/domain/planner" - "testing" + "github.com/quii/go-fakes-and-contracts/adapters/driven/persistence/sqlite" + "github.com/quii/go-fakes-and-contracts/domain/planner" + "testing" ) func TestSQLitePantry(t *testing.T) { - client := sqlite.NewSQLiteClient() - t.Cleanup(func() { - if err := client.Close(); err != nil { - t.Error(err) - } - }) - - planner.PantryContract{ - NewPantry: func() planner.Pantry { - return sqlite.NewPantry(client) - }, - }.Test(t) + client := sqlite.NewSQLiteClient() + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Error(err) + } + }) + + planner.PantryContract{ + NewPantry: func() planner.Pantry { + return sqlite.NewPantry(client) + }, + }.Test(t) } ```