"If you are using a third party framework to generate SQL your TDD tests should check that the framework is being called correctly." - Uncle Bob
Problem
Creating unit tests where there is concurrency can become complicated if we do not take special care. In this article, we are going to show a simple scenario, but it could take us a few minutes to figure out how to do it. It occurs when we want to create an unit test for a function that internally calls another library asynchronously.
Let's look at the model of this scenario.
now take a look at the notification unit test
We created a basicService
with the mock notifier and called the Add()
function to verify that the expected message has been sent to the emailNotifierMock
.
import (
"context"
"testing"
"github.com/group/project/pkg/employeeapp"
"github.com/stretchr/testify/assert"
)
func TestEmailNotification(t *testing.T) {
// GIVEN
newEmployee := employeeapp.Employee{
FirstName: "Daenerys",
LastName: "Targaryen",
Email: "someone@somewhere.com",
Salary: 25000,
}
expectedMessage := employeeapp.Message{
Subject: "Hey",
To: "someone@somewhere.com",
From: "anybody@somewhere.com",
Body: "Good morning there",
}
ctx := context.TODO()
emailNotifier := NewEmailNotifierMock()
basicService := employeeapp.NewBasicService(emailNotifier)
// WHEN
_, err := basicService.Add(ctx, newEmployee)
// THEN
if err != nil {
t.Errorf("no error was expected but got: %q", err)
}
assert.Equal(t, expectedMessage, emailNotifier.messages[0])
}
For the notifier dependency, we have created emailNotifierMock below.
// emailNotifierMock is a Notifier mock that track
// received messages
type emailNotifierMock struct {
messages []employeeapp.Message
}
func NewEmailNotifierMock() *emailNotifierMock {
return &emailNotifierMock{
messages: make([]employeeapp.Message, 0),
}
}
// Notify adds the given message to the messages map contained in the mock.
func (e *emailNotifierMock) Notify(ctx context.Context, message employeeapp.Message) error {
e.messages = append(e.messages, message)
return nil
}
If you run the unit test, the result will be successful.
go test -timeout 30s github.com/group/project/pkg/employeeapp -run ^TestEmailNotification$
ok github.com/group/project/pkg/employeeapp
now take a look at the synchronous Add() business function. Please see that Notify()
call is synchronous.
// Add validates and save a new employee.
func (b *basicService) Add(ctx context.Context, newemployee Employee) (string, error) {
newemployee.ID = uuid.New().String()
// do some business logic
b.notify(ctx, newemployee)
return newemployee.ID, nil
}
// notify notifies to the employee using the given notifier.
func (b *basicService) notify(ctx context.Context, employee Employee) {
if b.notifier == nil {
return
}
newMessage := Message{
Subject: "Hey",
To: employee.Email,
From: "anybody@somewhere.com",
Body: "Good morning there",
}
notifierErr := b.notifier.Notify(ctx, newMessage)
if notifierErr != nil {
log.Printf("unexpected error: %s sending message %+v to %q ", notifierErr, newMessage, employee.Email)
}
}
We now receive a request from the tech leader requesting to call the Notify()
function asynchronously.
Then we go to basicService
logic and create a goroutine to call the internal notify function.
// Add validates and save a new employee.
func (b *basicService) Add(ctx context.Context, newemployee Employee) (string, error) {
newemployee.ID = uuid.New().String()
// do some business logic
go b.notify(ctx, newemployee)
return newemployee.ID, nil
}
Now we try to run our unit test, but wait, it does not work anymore.
go test -timeout 30s github.com/group/project/pkg/employeeapp -run ^TestEmailNotification$
--- FAIL: TestEmailNotification (0.00s)
panic: runtime error: index out of range [0] with length 0 [recovered]
panic: runtime error: index out of range [0] with length 0
and it becomes worst when we add the -race
flag.
go test -race -timeout 30s github.com/group/project/pkg/employeeapp -run ^TestEmailNotification$
--- FAIL: TestEmailNotification (0.00s)
==================
WARNING: DATA RACE
Write at 0x00c0000932d0 by goroutine 9:
github.com/group/project/pkg/employeeapp_test.(*emailNotifierMock).Notify()
Well that's the reason of this article.
Solution
... One way to solve this could be to follow my proposal without changing our implementation.
- Add a
sync.WaitGroup
and async.Mutex
attributes in the Notifier mock.
// emailNotifierMock is a domain.Notifier mock that track
// received messages
type emailNotifierMock struct {
err error
messages []employeeapp.Message
mu sync.Mutex
wg sync.WaitGroup
}
- In the
Notify()
function within notifier mock we add adefer wg.Done()
statement. Here we have to avoid race conditions on themessages
slice, because we don't know how many goroutines could try to push items into it. That's the reason of thesync.Mutex
attribute and theLock()
andUnlock()
calls.
// Notify adds the given message to the messages map contained in the mock.
func (e *emailNotifierMock) Notify(ctx context.Context, message employeeapp.Message) error {
defer e.wg.Done()
if e.err != nil {
return e.err
}
e.mu.Lock()
e.messages = append(e.messages, message)
e.mu.Unlock()
return nil
}
- In the unit test before calling the
Add()
function, we add theemailNotifier.wg.Add(1)
statement and after calling theAdd()
function we add the following alsoemailNotifier.wg.Wait()
.
...
// WHEN
emailNotifier.wg.Add(1)
_, err := basicService.Add(ctx, newEmployee)
emailNotifier.wg.Wait()
// THEN
...
obtaining this result.
import (
"context"
"sync"
"testing"
"github.com/group/project/pkg/employeeapp"
"github.com/stretchr/testify/assert"
)
func TestEmailNotification(t *testing.T) {
// GIVEN
newEmployee := employeeapp.Employee{
FirstName: "Daenerys",
LastName: "Targaryen",
Email: "someone@somewhere.com",
Salary: 25000,
}
expectedMessage := employeeapp.Message{
Subject: "Hey",
To: "someone@somewhere.com",
From: "anybody@somewhere.com",
Body: "Good morning there",
}
ctx := context.TODO()
emailNotifier := NewEmailNotifierMock()
basicService := employeeapp.NewBasicService(emailNotifier)
// WHEN
emailNotifier.wg.Add(1)
_, err := basicService.Add(ctx, newEmployee)
emailNotifier.wg.Wait()
// THEN
if err != nil {
t.Errorf("no error was expected but got: %q", err)
}
assert.Equal(t, expectedMessage, emailNotifier.messages[0])
}
now let's check the unit test again.
go test -race -timeout 30s github.com/group/project/pkg/employeeapp -run ^TestEmailNotification$
ok github.com/group/project/pkg/employeeapp
Conclusion
- Always make your code testeable. In the above scenario, the notifier has a well-known interface and even internally in the service, we are calling a private function.
- The interfaces go a long way in making the code testable.
- As much as possible, just test the public or visible features. Try not to make private functions part of your base code contract. That will give you flexibility in the future. Otherwise, feel free to test them with special care.
-
sync.WaitGroup
library help us to wait for goroutines. - Mocks provide the opportunity to have control over the behavior of the code.
- Remember to use
go test -race
if the unit test involves goroutines.
Acknowledgement
- For modeling I was using PlantUML
Top comments (2)
Hi, your code tests that a function in a go routine is called.
It would be interesting to know how to test that the function is called inside a go routine. I think that's harder.
e.g., if you remove the
go
in thego b.notify(ctx, newemployee)
line, the test passes.I'm figuring out how to do it so that the test fails in that case.
I could not find difference between syncronous and assyncronous sequence diagrams. Is there a specific notation for that?