Skip to content

Commit

Permalink
put a tl;dr for fakes and contracts
Browse files Browse the repository at this point in the history
  • Loading branch information
quii committed Sep 28, 2023
1 parent 25be08b commit 6b481cf
Showing 1 changed file with 124 additions and 118 deletions.
242 changes: 124 additions & 118 deletions working-without-mocks.md
Original file line number Diff line number Diff line change
@@ -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).

---

Expand All @@ -22,16 +28,16 @@ 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**.

Given this interface of a hypothetical recipe API:

```go
type RecipeBook interface {
GetRecipes() ([]Recipe, error)
AddRecipes(...Recipe) error
GetRecipes() ([]Recipe, error)
AddRecipes(...Recipe) error
}
```

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
}
```

Expand Down Expand Up @@ -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)
})
}
```

Expand All @@ -316,47 +322,47 @@ 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)
}
```

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
}
```

Expand Down Expand Up @@ -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)
}
```

Expand All @@ -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")
})
```

Expand Down Expand Up @@ -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)
}

```
Expand All @@ -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)
}

```
Expand Down

0 comments on commit 6b481cf

Please sign in to comment.