Testable go with function types

trashhalo

Stephen Solka

Posted on October 9, 2020

Testable go with function types

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 // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
trashhalo
Stephen Solka

Posted on October 9, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related