DEV Community

Stephen Solka
Stephen Solka

Posted on • Edited on

2

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.

Image of AssemblyAI

Automatic Speech Recognition with AssemblyAI

Experience near-human accuracy, low-latency performance, and advanced Speech AI capabilities with AssemblyAI's Speech-to-Text API. Sign up today and get $50 in API credit. No credit card required.

Try the API

Top comments (0)

Heroku

This site is powered by Heroku

Heroku was created by developers, for developers. Get started today and find out why Heroku has been the platform of choice for brands like DEV for over a decade.

Sign Up

👋 Kindness is contagious

Engage with a sea of insights in this enlightening article, highly esteemed within the encouraging DEV Community. Programmers of every skill level are invited to participate and enrich our shared knowledge.

A simple "thank you" can uplift someone's spirits. Express your appreciation in the comments section!

On DEV, sharing knowledge smooths our journey and strengthens our community bonds. Found this useful? A brief thank you to the author can mean a lot.

Okay