Stephen Solka
Posted on October 9, 2020
A helpful pattern I've picked up writing test cases for go code is called Ports and Adapters. Skipping past long blog post with theory lets get into how this can look in typical go code.
func TellEveryBobHeSmells() error {
emailSvc := getEmailService()
db := getDBConn()
rows := db.Exec(`select bobs from db`)
for row.Next() {
var bobsEmail string
err := rows.Scan(&bobsEmail)
if err != nil // ...
err = emailSvc.SendSpiceyEmail(bobsEmail)
if err != nil // ...
}
}
This function is going to be very annoying to test. It takes no inputs and reaches out to 2 external services. First to get a list of bobs and another to send emails. Lets refactor this code to be more testable.
type sendSpicyEmailFn = func(email string) error
type bobFinderFn = func() ([]string, error)
func tellEveryBobHeSmells(findBobs bobFinderFn, sendEmail sendSpicyEmailFn) error {
bobs, err := findBobs()
if err != nil // ...
for _, email := bobs {
err := sendEmail(email)
if err != nil // ...
}
return nil
}
We created two "ports" to abstract away the side effects of the implementation. sendSpicyEmailFn
and bobFinderFn
. Now our test cases can control the behavior of these functions. You can imagine writing a unit test for this function without even providing a database in the test suite.
// now we write a public wrapper function that fills in
// the adaptors so our callers dont have to care about ports/adaptors
func TellEveryBobHeSmells() error {
// note we still passing in dependencies to the adaptors
db := getDBConn()
findBobs := defaultBobFinderFn(db)
emailSvc := getEmailService()
sendEmail := defaultSendSpicyEmailFn(emailSvc)
return tellEveryBobHeSmells(findBobs, sendEmail)
}
// adaptor implementations
func defaultBobFinderFn(db *DB) bobFinderFn {
return func() ([]string, error) {
var bobs []string
rows := db.Exec(`select bobs from db`)
for row.Next() {
var email string
err := rows.Scan(&email)
if err != nil // ...
bobs = append(bobs, email)
}
return bobs, nil
}
}
func defaultSendSpicyEmailFn(svc *EmailSvc) sendSpicyEmailFn {
return func(email string) error{
return emailSvc.SendSpiceyEmail(email)
}
}
Now that we have cut apart the function into its business logic and external dependencies it is much easier to test. In general I would create a unit test for tellEveryBobHeSmells
and the two adaptors defaultBobFinderFn
and defaultSendSpicyEmailFn
but skip the test case for TellEveryBobHeSmells
.
Posted on October 9, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.