Implementing a BDD Workflow in Go

Written by: Matthew Setter

I’m becoming quite the testing nut. After reading Nick Gauthier’s article on testing in Go and watching a number of videos of Bob Martin espousing the need to code from a test-driven perspective, I’ve actually refactored my workflow to be BDD-driven. It's changed my attitude, especially after seeing just how much it’s simplified my development workflow and how much more reliable it’s made my applications.

So today, I’m going to build on Nick's article and show you the approach I've started using.

 What Is BDD?

Wikipedia describes Behavior Driven Development as follows:

BDD is largely facilitated through the use of a simple domain specific language (DSL) using natural language constructs (e.g., English-like sentences) that can express the behavior and the expected outcomes. BDD specifies that business analysts and developers should collaborate in this area and should specify behavior in terms of user stories, which are each explicitly written down in a dedicated document.

Martin Fowler described the essence of how it works on his blog a little while ago. Here's one of the main concepts:

The essential idea is to break down writing a scenario (or test) into three sections, known as a Given-When-Then style:

The given part describes the state of the world before you begin the behavior you're specifying in this scenario. You can think of it as the pre-conditions to the test. The when section is that behavior that you're specifying. Finally the then section describes the changes you expect due to the specified behavior.

Here's a sample scenario written in that style:

Feature: User trades stocks
  Scenario: User requests a sell before close of trading
    Given I have 100 shares of MSFT stock
       And I have 150 shares of APPL stock
       And the time is before close of trading
    When I ask to sell 20 shares of MSFT stock
     Then I should have 80 shares of MSFT stock
      And I should have 150 shares of APPL stock
      And a sell order for 20 shares of MSFT stock should have been executed

But Why Is BDD a Good Thing?

BDD is a testing methodology that makes applications largely self-documenting. It’s able to do this because the tests are written in a way that’s reflective of the plain-language scenario we just saw.

More so than other testing styles, such as TDD (Test Driven Development) from which BDD originated, the BDD approach makes it easy for people who aren't technical in nature, such as business analysts, to read and write test specifications.

Doing so helps ensure applications do what the end user expects them to, as end users play an active role in determining test acceptance criteria.

 How Do You Implement BDD in Go?

Now all this talk about BDD is interesting, but how do you implement it in your workflow? Good question. After some research, I came across an excellent package called GoConvey.

GoConvey builds on Go's native testing package, adding an expressive DSL (Domain Specific Language). It’s one you'll be familiar with if you've worked with other BDD packages, such as PHPSpec, Behat, or Gerkhin.

It also integrates with Go's coverage reporting. So you can see the number of tests, how many are passing, how many are failing, rerun them, and so on, as well as the level of test coverage.

GoConvey can run either in your terminal as well as from your browser of choice. Speaking of the browser, it has a clean and full-featured browser-based UI, which we'll see shortly. And thanks to an internal file watcher, it automatically reruns your tests whenever changes occur and has desktop notifications, if you need up-to-the-minute change notification.

Installing and running GoConvey

With that said, let's step through how to install and run it, as well as how to test a small Go package. First, install GoConvey as you would any other package by running go get github.com/smartystreets/goconvey in your terminal.

When that's done, run GoConvey by calling $GOPATH/bin/goconvey. This displays a lot of output, so give it a few seconds before opening http://localhost:8080. You can see an example of it below. There, you'll see the tests run for your entire $GOPATH directory.

Displaying results for One Package

However, I'm not interested in the entire Go directory, just a particular package called Microphones that I've created for this article. In the middle of the UI, where you can see /Users/mattsetter/Workspace/Golang, I'll update the path to my package by appending src/github.com/settermjd/microphones.

Doing so filters the test results displayed to those for that specific package, as you can see in the screenshot below. Note that it now has two tests with 11 assertions and no failures.

On the left hand side, under "Coverage,” you can see the test coverage level for the current files under test. In the middle are the test stories, on the right is the build history, and at the bottom is a build snapshot.

Focusing on test coverage for a moment, note that the green bar is almost 100 percent filled. This indicates that the package, whilst already well covered, doesn't have 100 percent code coverage. Clicking on it auto-generates and renders the HTML coverage report, where you can see that there are two nil checks which aren't tested and some types which aren't tracked.

BDD Code Under Testing

Now all this is great, but what about the code being tested? To provide reasonable sample code, I wrote a small application which reads in and iterates over a microphone’s data from a CSV file. Specifically, it retrieves a microphone:

  • name

  • brand

  • description

  • price

  • URL

  • type (condenser or dynamic)

  • style (front- or side-address)

Here’s a sample:

"MXL 990","MXL","The MXL 990 remains one of the industry's most ground-breaking microphones. The first high quality condenser microphone to come into reach of working musicians, the MXL 990 has a FET preamp and a large diaphragm for truly professional sound quality in both digital and analog recordings. This revolutionary condenser microphone continues to astound artists with its silky high end and tight, solid low and midrange reproduction.",165,"http://www.mxlmics.com/microphones/900-series/990/","XLR","Condenser"

The example code contains two types, a Microphone and `MicrophoneFileReader'. Microphone contains the properties of a specific microphone. And MicrophoneFileReader stores a list of Microphones.

type Microphone struct {
    name, brand, description, url, micType, micStyle string
    price     float64
}
type MicrophoneFileReader struct {
    filename    string
    microphoneList    []Microphone
}

MicrophoneFileReader has two functions attached to it. The first, LoadMicrophones(), takes a csv.Reader object which provides the CSV microphone data, via a call to its ReadAll() function.

If no errors are raised, the retrieved records are iterated over, and in each iteration a Microphone object is initialized and appended to MicrophoneFileReader's microphoneList property.

func (mfr *MicrophoneFileReader) LoadMicrophones(reader *csv.Reader) (bool, error) {
    records, err := reader.ReadAll()
    if err != nil {
        fmt.Println(err)
        return false, err
    }
    for i := 0; i < len(records); i++ {
        price, err := strconv.ParseFloat(records[i][3], 64)
        if err != nil {
            return false, errors.New("Not able to parse price to float")
        }
        mic := Microphone{
            name:        records[i][0],
            brand:       records[i][1],
            description: records[i][2],
            price:       price,
            url:         records[i][4],
            micType:     records[i][5],
            micStyle:    records[i][6],
        }
        mfr.microphoneList = append(mfr.microphoneList, mic)
    }
    return true, nil
}

The second function, GetMicrophones(), is a utility function for retrieving the full microphone list.

func (mfr *MicrophoneFileReader) GetMicrophones() []Microphone {
    return mfr.microphoneList
}

The Tests for BDD Code

I appreciate that the point of BDD-style testing is to test first and code second, and that was how I developed the code. But it's a bit hard to show it, both meaningfully and concisely, in text format. Please forgive me if it seems like I'm not actually doing BDD testing.

GoConvey has a very expressive DSL, one which is easy to read and intuit. In the first of the two tests I wrote for the code, called TestCanInitialiseClass(), I used the native Convey/So syntax to test the process of initializing the class.

Specifically, I started by instantiating a new MicrophoneFileReader object, then verifying that microphoneList is initially empty. Here’s how it’s structured:

  1. The outer call to Convey provides the “Given” component of the test

  2. Inner calls to Convey provides the “When” component of the test

  3. Calls to So() provides the “Then” component, where specific assertions are made

In the call to So below, I’ve asserted that the length of the microphoneList property should be zero in length. There are others, including ShouldNotEqual, ShouldPointTo, ShouldBeFalse, and ShouldBeNil.

func TestCanInitialiseClass(t *testing.T) {
    var mfr MicrophoneFileReader
    Convey("Given a new microphone file reader", t, func() {
        Convey("The microphone list should be empty", func() {
            So(len(mfr.microphoneList), ShouldEqual, 0)
        })
    })

To prepare for the following test, I initialized a new Reader object, with the microphone data from the CSV data file, passing it to LoadMicrophones, which instantiated a list of Microphones, from the data in the file.

    csvfile, _ := ioutil.ReadFile("./data.csv")
    data := string(csvfile)
    reader := csv.NewReader(strings.NewReader(data))
    mfr.LoadMicrophones(reader)

With that done, I used the nested Convey/So syntax to write a set of two tests. The first tests that the correct number of microphones were instantiated and stored. The second tests the properties on the instantiated microphones.

    Convey("Given an initialised microphone file reader", t, func() {
        Convey("The microphone list should contain 1 microphone", func() {
            So(len(mfr.microphoneList), ShouldEqual, 1)
            So(mfr.GetMicrophones(), ShouldNotBeEmpty) })
        Convey("The initialised microphone's properties should all be initialised", func() {
            So(mfr.GetMicrophones()[0].name, ShouldNotBeEmpty)
            So(mfr.GetMicrophones()[0].brand, ShouldNotBeEmpty)
            So(mfr.GetMicrophones()[0].description, ShouldNotBeEmpty)
            So(mfr.GetMicrophones()[0].price, ShouldNotBeEmpty)
            So(mfr.GetMicrophones()[0].url, ShouldNotBeEmpty)
            So(mfr.GetMicrophones()[0].micType, ShouldNotBeEmpty)
            So(mfr.GetMicrophones()[0].micStyle, ShouldNotBeEmpty)
        })
    })
}

Testing Goblin-Style

Convey isn't the only testing style available with GoConvey. One which I find almost as effective, if less expressive, is Goblin. Goblin is self-described as being:

a minimal and beautiful Go testing framework. Inspired by the flexibility and simplicity of Node BDD and frustrated by the rigor of the Go way of testing, we wanted to bring a new tool to write self-describing and comprehensive code.

Let's see how we could implement the previous tests using Goblin. One thing to note, whereas GoConvey follows a Convey/So approach, Goblin follows a Describe/It/Assert approach. For all intents and purposes though, the approaches are the same.

At the top of the test, I initialize Goblin, then use its Describe() method to indicate the base or given state. Within that, I then initialize a new Reader object to read in the CSV microphone data.

The It method states what should occur and uses an anonymous function which calls the LoadMicrophones method, initializing the microphone list. With that done, it then asserts that the correct number of Microphone objects were instantiated and stored.

With that done, the GetMicrophones function is called where we assert that the list is also of the correct length. Finally, we iterate over the list of microphones, checking, via the reflect library, that each microphone's type name and package path are correct.

func TestCanInitialise(t *testing.T) {
    g := Goblin(t)
    g.Describe("Given an initialised microphone file reader", func() {
        mfr := MicrophoneFileReader{}
        csvfile, _ := ioutil.ReadFile("./data.csv")
        data := string(csvfile)
        reader := csv.NewReader(strings.NewReader(data))
        g.It("Be able to retrieve a list of all the microphones", func() {
            loaded, _ := mfr.LoadMicrophones(reader)
            g.Assert(loaded).IsTrue("Microphones were not successfully loaded")
            g.Assert(len(mfr.microphoneList)).Equal(1)
            micList := mfr.GetMicrophones()
            g.Assert(len(micList)).Equal(1)
            for i := 0; i < len(micList); i++ {
                g.Assert(reflect.TypeOf(micList[i]).Name()).Equal("Microphone")
                g.Assert(reflect.TypeOf(micList[i]).PkgPath()).Equal("github.com/settermjd/microphones")
            }
        })

Here in the final test, we retrieve a microphone from the list and check that each property has been set. We could have been more thorough about this and confirmed that each property was initialized with the data from the CSV file. But for a simplistic test, we can see that the object was correctly initialized.

    g.It("Can retrieve the properties from a Microphone", func() {
            mfr.LoadMicrophones(reader)
            micList := mfr.GetMicrophones()
            micOne := micList[0]
            g.Assert(len(micOne.name) != 0).IsTrue("Microphone name should be set")
            g.Assert(len(micOne.brand) != 0).IsTrue("Microphone brand should be set")
            g.Assert(len(micOne.description) != 0).IsTrue("Microphone description should be set")
            g.Assert(micOne.price != 0).IsTrue("Microphone price should be set")
            g.Assert(len(micOne.url) != 0).IsTrue("Microphone url should be set")
            g.Assert(len(micOne.micType) != 0).IsTrue("Microphone micType should be set")
            g.Assert(len(micOne.micStyle) != 0).IsTrue("Microphone micStyle should be set")
        })
    })
}

Conclusion

This has been a short introduction to BDD, one that only scratches the surface of what BDD is and how to implement with Go. But I hope that, despite being overly positive toward GoConvey, it's shown how to add BDD to your existing workflow in a way that's non-invasive.

GoConvey aside, I couldn't be more enthusiastic about BDD if I tried. It’s made testing a really enjoyable task, one integral to all code I write.

Stay up to date

We'll never share your email address and you can opt out at any time, we promise.