Introduction

Over the past few years, Go has developed into the language of choice for many types of applications. Its popularity can be attributed to several factors, out of which its testing story is a prominent one. Out of the box, Go comes with a great package for testing.

This tutorial will explain how you can incorporate behavior-driven development with Go using Ginkgo, with the help of a sample application. Ginkgo makes use of Go’s existing testing package, which means that you don’t need elaborate setup to start using and benefiting from it.

Goals

By the end of this tutorial, you will:

  • Become familiar with Ginkgo, and
  • Learn how to use Ginkgo with a sample application.

Prerequisites

This tutorial assumes:

  • Basic familiarity with Go, and
  • Go 1.4+ is installed on your machine.

Introducing Behavior-driven Development (BDD)

This section will briefly introduce Behavior-driven development (BDD) and will touch upon the options available in Go.

Brief Introduction to BDD

In BDD, the development of an application is guided and measured against a predefined set of desirable functionality. If you are familiar with test-driven development (TDD), you can think of BDD as something similar but at a higher level.

While TDD focuses on the technical, or implementation details, BDD focuses on visible, behavioral details. Another way to think about it is that TDD focuses on ensuring that the individual parts of the application do what they should, while BDD focuses on ensuring that these parts work together as expected.

BDD specifications are usually written in non-technical language so that non-technical stakeholders can be involved in developing them. This ensures that the development and the final product are aligned with the expectations of the stakeholders.

BDD Libraries in Go

Given the role of BDD in developing high quality applications, it isn’t surprising that Go has its fair share of packages that help incorporate BDD. Some of the popular ones include GoConvey, Goblin, Godog, and Ginkgo.

Introducing Ginkgo

This section will introduce Ginkgo and help you install and set it up for use.

About Ginkgo

Ginkgo builds on Go’s testing package, and allows you to write expressive tests in an efficient and effective manner. If you have BDD specs in a simple language, you’ll find that creating corresponding tests with Ginkgo is quite straightforward.

One good thing about Ginkgo is that it can co-exist with normal Go tests that are based solely on the testing package. This allows you to try it out, and use it only where you think it adds real value.

As with popular BDD frameworks in other languages, Ginkgo allows you to group tests in Describe and Context container blocks. Ginkgo provides the It and Specify blocks which can hold your assertions. It also comes with handy structural utilities such as BeforeEach, AfterEach, BeforeSuite, AfterSuite and JustBeforeEach that allow you to separate test configuration from test creation, and improve code reuse.

Ginkgo comes with support for writing asynchronous tests. This makes testing code that use goroutines and/or channels as easy as testing synchronous code.

Installing Ginkgo

As mentioned in the prerequisites, you need Go 1.4+ installed on your machine to start using Ginkgo. Once you have this, you can install Ginkgo using the following command:

go get github.com/onsi/ginkgo/ginkgo

This will fetch and install the Ginkgo package for use in your tests as well as Ginkgo’s command line utility that offers a lot of additional features which you might find useful once you start using Ginkgo extensively.

In addition to Ginkgo, let’s also install Gomega which is the recommended matcher library that we’ll use in conjunction with Ginkgo. Use the following command to install it:

go get github.com/onsi/gomega

Ginkgo is flexible enough to use any matcher library. Since it recommends the use of Gomega, that is what we’ll use in this tutorial.

Before we can use Ginkgo and Gomega in tests, there’s a tiny bit of boilerplate code to set set up. In the test file — a file that ends with _test.go, place the following code:

import (
	"testing"

	. "github.com/onsi/ginkgo"
	. "github.com/onsi/gomega"
)

func TestCart(t *testing.T) {
	RegisterFailHandler(Fail)
	RunSpecs(t, "Shopping Cart Suite")
}

var _ = Describe("Shopping cart", func() {})

Note: This boilerplate can automatically be set up for you if you use the ginkgo command line utility. This tutorial does this manually to minimize the ‘magic’ and present things in their entirety.

We import the ginkgo and gomega packages with the . so that we can use functions from these packages without the package prefix. In short, this allows us to use Describe instead of ginkgo.Describe, and Equal instead of gomega.Equal.

You can name the TestCart function however you want, as long as it begins with Test, since it’s just a regular Go test function.

The call to RegisterFailHandler registers a handler, the Fail function from the ginkgo package, with Gomega. This creates the coupling between Ginkgo and Gomega.

Calling RunSpecs hands over the control to Ginkgo which runs the test suite.

The final line (var _ = Describe("Shopping cart", func() {})) evaluates the Describe block. An alternative way of doing this would be to wrap the Describe block in an init() function.

With Ginkgo and Gomega installed, let’s take a quick look at the application that we will build.

Introducing the Application

The application that we will build is a simple shopping cart. It’s a simple application but it will help us demonstrate how BDD can help develop, it in a manner that engages all of the stakeholders, and ensures that the final product matches the original expectations.

Let’s start by listing our requirements for this shopping cart:

  • It should allow the user to add items,
  • It should allow the user to remove items,
  • It should allow the user to retrieve the number of unique items at any point,
  • It should allow the user to retrieve the total number of units (individual pieces) of items at any point, and
  • It should allow the user to retrieve the total amount at any point.

A real shopping cart will likely have a lot more requirements, but for demonstration purposes these should suffice. Let’s build a specification for our application using Ginkgo based on these requirements.

Building a Specification Based on Requirements

In this section, we’ll start by writing out some specifications based on the above requirements. These specifications will be written in a manner that any non-technical stakeholder can understand.

For example, the requirement that a shopping cart should initially be empty can be written as follows:

Given a shopping cart
  initially
    it has 0 items
    it has 0 units
    the total amount is 0.00

Grouping of specifications can be indicated using indentation. This particular specification can be written using Ginkgo as follows:

Describe("Shopping cart", func() {
	Context("initially", func() {
		It("has 0 items", func() {})
		It("has 0 units", func() {})
		Specify("the total amount is 0.00", func() {})
	})
})

In this case, we can see that there will be three related assertions in the same ‘context’. You can group other related assertions and/or contexts together in a similar manner. Specifications can be mapped quite easily to tests using the constructs provided by Ginkgo.

Note that Specify is just an alias for It, and has only been used because it reads more naturally in this case. This means that the particular block could be replaced with

It("the total amount is 0.00", func() {})

without changing how the tests run.

Mapping Requirements to Specifications

Now that we know how to write the specifications and how they will be mapped to test code in Go, we can write the entire specification as follows:

Given a shopping cart
  initially
    it has 0 items
    it has 0 units
    the total amount is 0.00

  when a new item is added
    the shopping cart has 1 more unique item than it had earlier
    the shopping cart has 1 more unit than it had earlier
    the total amount increases by item price

  when an existing item is added
    the shopping cart has the same number of unique items as earlier
    the shopping cart has 1 more unit than it had earlier
    the total amount increases by item price

  that has 0 unit of item A
    removing item A
      should not change the number of items
      should not change the number of units
      should not change the amount

  that has 1 unit of item A
    removing 1 unit item A
      should reduce the number of items by 1
      should reduce the number of units by 1
      should reduce the amount by the item price

  that has 2 units of item A
    removing 1 unit of item A
      should not reduce the number of items
      should reduce the number of units by 1
      should reduce the amount by the item price

    removing 2 units of item A
      should reduce the number of items by 1
      should reduce the number of units by 2
      should reduce the amount by twice the item price

Creating the Test Structure

With specifications written down, let’s create a structure for the tests as follows:

// cart_test.go

package cart_test

import (
	"testing"

	. "github.com/onsi/ginkgo"
	. "github.com/onsi/gomega"
)

func TestCart(t *testing.T) {
	RegisterFailHandler(Fail)
	RunSpecs(t, "Shopping Cart Suite")
}

var _ = Describe("Shopping cart", func() {
	Context("initially", func() {
		It("has 0 items", func() {})
		It("has 0 units", func() {})
		Specify("the total amount is 0.00", func() {})
	})

	Context("when a new item is added", func() {
		Context("the shopping cart", func() {
			It("has 1 more unique item than it had earlier", func() {})
			It("has 1 more unit than it had earlier", func() {})
			Specify("total amount increases by item price", func() {})
		})
	})

	Context("when an existing item is added", func() {
		Context("the shopping cart", func() {
			It("has the same number of unique items as earlier", func() {})
			It("has 1 more unit than it had earlier", func() {})
			Specify("total amount increases by item price", func() {})
		})
	})

	Context("that has 0 unit of item A", func() {
		Context("removing item A", func() {
			It("should not change the number of items", func() {})
			It("should not change the number of units", func() {})
			It("should not change the amount", func() {})
		})
	})

	Context("that has 1 unit of item A", func() {
		Context("removing 1 unit item A", func() {
			It("should reduce the number of items by 1", func() {})
			It("should reduce the number of units by 1", func() {})
			It("should reduce the amount by item price", func() {})
		})
	})

	Context("that has 2 units of item A", func() {

		Context("removing 1 unit of item A", func() {
			It("should not reduce the number of items", func() {})
			It("should reduce the number of units by 1", func() {})
			It("should reduce the amount by the item price", func() {})
		})

		Context("removing 2 units of item A", func() {
			It("should reduce the number of items by 1", func() {})
			It("should reduce the number of units by 2", func() {})
			It("should reduce the amount by twice the item price", func() {})
		})
	})
})

The shopping cart will be created in a package named cart. We have put the tests in the cart_test package. This will allow us to place the test files in the same directory and yet test cart as an external package.

The It and Specify blocks currently contain placeholder functions without any assertions. If you run go test, or ginkgo, from the command line, you should see something similar to the following:

Running Suite: Shopping Cart Suite
==================================
Random Seed: 1478868383
Will run 21 of 21 specs

•••••••••••••••••••••
Ran 21 of 21 Specs in 0.000 seconds
SUCCESS! -- 21 Passed | 0 Failed | 0 Pending | 0 Skipped PASS

Ginkgo ran 1 suite in 762.302932ms
Test Suite Passed

The tests pass because we haven’t written any assertions yet. We’ll fix that soon. However, with this output, we can tell that we have set things up correctly.

Creating the Application Structure

Before we write the tests, we need to create the minimum application, so that the tests can compile and run. Based on the requirements, the application will need two structs:

  • Cart, and
  • Item.

Let’s define these as follows:

type Cart struct {
	items map[string]Item
}

type Item struct {
	ID    string
	Name  string
	Price float64
	Qty   int
}

Cart simply contains a map of Items, with the item ID as the key, and Item has fields that a product is likely to have. Based on the required functionality, we need to add the following methods on Cart:

func (c *Cart) AddItem(i Item) {}

func (c *Cart) RemoveItem(id string, n int) {}

func (c *Cart) TotalAmount() float64 {}

func (c *Cart) TotalUnits() int {}

func (c *Cart) TotalUniqueItems() int {}

Combining all of these, the minimal application can be represented with the following code:

package cart

// Cart represents the state of a buyer's shopping cart
type Cart struct {
	items map[string]Item
}

// Item represents any item available for sale
type Item struct {
	ID    string
	Name  string
	Price float64
	Qty   int
}

// AddItem adds an item to the cart
func (c *Cart) AddItem(i Item) {}

// RemoveItem removes n number of items with give id from the cart
func (c *Cart) RemoveItem(id string, n int) {}

// TotalAmount returns the total amount of the cart
func (c *Cart) TotalAmount() float64 {
	return 324 // return a random number as a placeholder
}

// TotalUnits returns the total number of units across all items in the cart
func (c *Cart) TotalUnits() int {
	return 9932 // return a random number as a placeholder
}

// TotalUniqueItems returns the number of unique items in the cart
func (c *Cart) TotalUniqueItems() int {
	return 24 // return a random number as a placeholder
}

At this stage, the methods on Cart are just placeholders that will allow us to write, compile and run the tests.

Writing Tests using Ginkgo and Gomega

We have already seen some of the functions that Ginkgo provides. In addition, it also provides the Expect function which allows you to make assertions. When combined with Gomega’s matchers, it allows us to write tests that read like natural language.

For example, the first Context block, when completed with assertions, will look as follows:

Context("initially", func() {
	cart := Cart{}

	It("has 0 items", func() {
		Expect(cart.TotalUniqueItems()).Should(BeZero())
	})

	It("has 0 units", func() {
		Expect(cart.TotalUnits()).Should(BeZero())
	})

	Specify("the total amount is 0.00", func() {
		Expect(cart.TotalAmount()).Should(BeZero())
	})
})

In this block, we define a new Cart and assert that the initial state of the cart is as we expect it to be. The assertions are wrapped in It or Specify blocks. In the example above, BeZero() is a matcher provided by Gomega. It checks that a given value is zero.

Completing the rest of the tests in this manner should give us something similar to the following:

// cart_test.go

package cart_test

import (
	"testing"

	. "github.com/onsi/ginkgo"
	. "github.com/onsi/gomega"

	. "."
)

func TestCart(t *testing.T) {
	RegisterFailHandler(Fail)
	RunSpecs(t, "Shopping Cart Suite")
}

var _ = Describe("Shopping cart", func() {
	itemA := Item{ID: "itemA", Name: "Item A", Price: 10.20, Qty: 0}
	itemB := Item{ID: "itemB", Name: "Item B", Price: 7.66, Qty: 0}

	Context("initially", func() {
		cart := Cart{}

		It("has 0 items", func() {
			Expect(cart.TotalUniqueItems()).Should(BeZero())
		})

		It("has 0 units", func() {
			Expect(cart.TotalUnits()).Should(BeZero())
		})

		Specify("the total amount is 0.00", func() {
			Expect(cart.TotalAmount()).Should(BeZero())
		})
	})

	Context("when a new item is added", func() {
		cart := Cart{}

		originalItemCount := cart.TotalUniqueItems()
		originalUnitCount := cart.TotalUnits()
		originalAmount := cart.TotalAmount()

		cart.AddItem(itemA)

		Context("the shopping cart", func() {
			It("has 1 more unique item than it had earlier", func() {
				Expect(cart.TotalUniqueItems()).Should(Equal(originalItemCount + 1))
			})

			It("has 1 more unit than it had earlier", func() {
				Expect(cart.TotalUnits()).Should(Equal(originalUnitCount + 1))
			})

			Specify("total amount increases by item price", func() {
				Expect(cart.TotalAmount()).Should(Equal(originalAmount + itemA.Price))
			})
		})
	})

	Context("when an existing item is added", func() {
		cart := Cart{}

		cart.AddItem(itemA)

		originalItemCount := cart.TotalUniqueItems()
		originalUnitCount := cart.TotalUnits()
		originalAmount := cart.TotalAmount()

		cart.AddItem(itemA)

		Context("the shopping cart", func() {
			It("has the same number of unique items as earlier", func() {
				Expect(cart.TotalUniqueItems()).Should(Equal(originalItemCount))
			})

			It("has 1 more unit than it had earlier", func() {
				Expect(cart.TotalUnits()).Should(Equal(originalUnitCount + 1))
			})

			Specify("total amount increases by item price", func() {
				Expect(cart.TotalAmount()).Should(Equal(originalAmount + itemA.Price))
			})
		})
	})

	Context("that has 0 unit of item A", func() {
		cart := Cart{}

		cart.AddItem(itemB) // just to mimic the existence other items
		cart.AddItem(itemB) // just to mimic the existence other items

		originalItemCount := cart.TotalUniqueItems()
		originalUnitCount := cart.TotalUnits()
		originalAmount := cart.TotalAmount()

		Context("removing item A", func() {
			cart.RemoveItem(itemA.ID, 1)

			It("should not change the number of items", func() {
				Expect(cart.TotalUniqueItems()).Should(Equal(originalItemCount))
			})
			It("should not change the number of units", func() {
				Expect(cart.TotalUnits()).Should(Equal(originalUnitCount))
			})
			It("should not change the amount", func() {
				Expect(cart.TotalAmount()).Should(Equal(originalAmount))
			})
		})
	})

	Context("that has 1 unit of item A", func() {
		cart := Cart{}

		cart.AddItem(itemB) // just to mimic the existence other items
		cart.AddItem(itemB) // just to mimic the existence other items

		cart.AddItem(itemA)

		originalItemCount := cart.TotalUniqueItems()
		originalUnitCount := cart.TotalUnits()
		originalAmount := cart.TotalAmount()

		Context("removing 1 unit item A", func() {
			cart.RemoveItem(itemA.ID, 1)

			It("should reduce the number of items by 1", func() {
				Expect(cart.TotalUniqueItems()).Should(Equal(originalItemCount - 1))
			})

			It("should reduce the number of units by 1", func() {
				Expect(cart.TotalUnits()).Should(Equal(originalUnitCount - 1))
			})

			It("should reduce the amount by item price", func() {
				Expect(cart.TotalAmount()).Should(Equal(originalAmount - itemA.Price))
			})
		})
	})

	Context("that has 2 units of item A", func() {

		Context("removing 1 unit of item A", func() {
			cart := Cart{}

			cart.AddItem(itemB) // just to mimic the existence other items
			cart.AddItem(itemB) // just to mimic the existence other items
			//Reset the cart with 2 units of item A
			cart.AddItem(itemA)
			cart.AddItem(itemA)

			originalItemCount := cart.TotalUniqueItems()
			originalUnitCount := cart.TotalUnits()
			originalAmount := cart.TotalAmount()

			cart.RemoveItem(itemA.ID, 1)

			It("should not reduce the number of items", func() {
				Expect(cart.TotalUniqueItems()).Should(Equal(originalItemCount))
			})

			It("should reduce the number of units by 1", func() {
				Expect(cart.TotalUnits()).Should(Equal(originalUnitCount - 1))
			})

			It("should reduce the amount by the item price", func() {
				Expect(cart.TotalAmount()).Should(Equal(originalAmount - itemA.Price))
			})
		})

		Context("removing 2 units of item A", func() {
			cart := Cart{}

			cart.AddItem(itemB) // just to mimic the existence other items
			cart.AddItem(itemB) // just to mimic the existence other items
			//Reset the cart with 2 units of item A
			cart.AddItem(itemA)
			cart.AddItem(itemA)

			originalItemCount := cart.TotalUniqueItems()
			originalUnitCount := cart.TotalUnits()
			originalAmount := cart.TotalAmount()

			cart.RemoveItem(itemA.ID, 2)

			It("should reduce the number of items by 1", func() {
				Expect(cart.TotalUniqueItems()).Should(Equal(originalItemCount - 1))
			})

			It("should reduce the number of units by 2", func() {
				Expect(cart.TotalUnits()).Should(Equal(originalUnitCount - 2))
			})

			It("should reduce the amount by twice the item price", func() {
				Expect(cart.TotalAmount()).Should(Equal(originalAmount - 2*itemA.Price))
			})
		})

	})
})

Running these tests with go test or ginkgo, from the command line should result in a lot of failed tests that end with the following message:

Ran 21 of 21 Specs in 0.010 seconds
FAIL! -- 5 Passed | 16 Failed | 0 Pending | 0 Skipped --- FAIL: TestCart (0.01s)
FAIL

Ginkgo ran 1 suite in 877.200095ms
Test Suite Failed

Note that the 5 tests that pass are those tests that expect no change in a particular value after an action. As we haven’t implemented anything yet, we can ignore these for now.

At this point, we have defined the expected behavior of our application using Ginkgo tests that are based on the specification we wrote earlier. All that remains is to complete the application so that it satisfies these tests.

Building and Testing the Application

In this section, we’ll complete the shopping cart implementation to satisfy the tests written in the previous section.

Let’s begin with creating an init method on Cart which will instantiate the items map.

func (c *Cart) init() {
	if c.items == nil {
		c.items = map[string]Item{}
	}
}

This method will be called from all other methods which need to access the items map directly.

Next, let’s implement the AddItem method. This method should check if there’s an item in the cart. If it is, it should just increase the quantity for that item. If the item isn’t in the cart, this method should add it to the cart.

func (c *Cart) AddItem(i Item) {
	c.init()
	if existingItem, ok := c.items[i.ID]; ok {
		existingItem.Qty++
		c.items[i.ID] = existingItem
	} else {
		i.Qty = 1
		c.items[i.ID] = i
	}
}

The RemoveItem method can be implemented in a similar manner:

func (c *Cart) RemoveItem(id string, n int) {
	c.init()
	if existingItem, ok := c.items[id]; ok {
		if existingItem.Qty <= n {
			delete(c.items, id)
		} else {
			existingItem.Qty -= n
			c.items[id] = existingItem
		}
	}
}

This method reduces the quantity of the item if the item exists in a quantity greater than the number passed in. Otherwise, this method removes the item from the cart.

The TotalAmount method should go through all the items, calculate the total amount based on the quantity and price of the individual items an return this amount.

func (c *Cart) TotalAmount() float64 {
	c.init()
	totalAmount := 0.0
	for _, i := range c.items {
		totalAmount += i.Price * float64(i.Qty)
	}
	return totalAmount
}

The TotalUnits method should return the sum of the quantities of all the items, and can be implemented as follows:

func (c *Cart) TotalUnits() int {
	c.init()
	totalUnits := 0
	for _, i := range c.items {
		totalUnits += i.Qty
	}
	return totalUnits
}

Finally, the TotalUniqueItems method which should return the total number of unique items, can be implemented as follows:

func (c *Cart) TotalUniqueItems() int {
	return len(c.items)
}

Putting all of this together, the cart.go file should contain the following code:

// cart.go

package cart

// Cart represents the state of a buyer's shopping cart
type Cart struct {
	items map[string]Item
}

// Item represents any item available for sale
type Item struct {
	ID    string
	Name  string
	Price float64
	Qty   int
}

func (c *Cart) init() {
	if c.items == nil {
		c.items = map[string]Item{}
	}
}

// AddItem adds an item to the cart
func (c *Cart) AddItem(i Item) {
	c.init()
	if existingItem, ok := c.items[i.ID]; ok {
		existingItem.Qty++
		c.items[i.ID] = existingItem
	} else {
		i.Qty = 1
		c.items[i.ID] = i
	}
}

// RemoveItem removes n number of items with give id from the cart
func (c *Cart) RemoveItem(id string, n int) {
	c.init()
	if existingItem, ok := c.items[id]; ok {
		if existingItem.Qty <= n {
			delete(c.items, id)
		} else {
			existingItem.Qty -= n
			c.items[id] = existingItem
		}
	}
}

// TotalAmount returns the total amount of the cart
func (c *Cart) TotalAmount() float64 {
	c.init()
	totalAmount := 0.0
	for _, i := range c.items {
		totalAmount += i.Price * float64(i.Qty)
	}
	return totalAmount
}

// TotalUnits returns the total number of units across all items in the cart
func (c *Cart) TotalUnits() int {
	c.init()
	totalUnits := 0
	for _, i := range c.items {
		totalUnits += i.Qty
	}
	return totalUnits
}

// TotalUniqueItems returns the number of unique items in the cart
func (c *Cart) TotalUniqueItems() int {
	return len(c.items)
}

Having implemented the entire functionality for the shopping cart, if we now run the tests again, using go test or ginkgo, all of them should pass:

Running Suite: Shopping Cart Suite
==================================
Random Seed: 1478948612
Will run 21 of 21 specs

•••••••••••••••••••••
Ran 21 of 21 Specs in 0.000 seconds
SUCCESS! -- 21 Passed | 0 Failed | 0 Pending | 0 Skipped PASS

Ginkgo ran 1 suite in 831.023716ms
Test Suite Passed

Note: The code for the sample application developed in this tutorial can be found in this Github repository.

Setting Up Continuous Testing with Semaphore

A tutorial about testing wouldn’t be complete without talking about continuous integration. Let’s take a look at how we can set up continuous integration using Semaphore.

If you haven’t already, sign up for Semaphore. Continuous integration on Semaphore can be set up for repositories hosted on Github or Bitbucket, so make sure that you push the application code to either of these hosts.

Once you have done this and signed in to your Semaphore account, click on the Add new project button:

Add new project

When you’re asked to select an organization, select your account:

Select an organization

If this is your first time using Semaphore, you will be asked to select your repository host (either Github or Bitbucket). Choose the one that applies:

Choose host

You should then see a list of projects from your chosen host. Select the repository for which you want to set up continuous integration:

Select repository

Semaphore should now prompt you to choose a branch. Select the branch that you wish to run the tests against. If in doubt, select the master branch.

Select branch

At this point, Semaphore will analyze your project and will set it up with default settings as applicable to a Go project. This is where you can customize the build and testing process if you want to. You can also change these settings later in the project’s settings section. For now, we’ll just change the version of Go used to the latest available version and click the Build With These Settings button at the bottom.

Project settings

At this point, Semaphore will begin building and testing your project. Better yet, every time you push an update to your repository, Semaphore will automatically build and test your repository. That’s one less thing for you to worry about.

Conclusion

This tutorial introduced you to Ginkgo and demonstrated how you can use it for behavior-driven development with Go. As shown here, BDD engages all stakeholders and aligns technical development with business requirements.

If you have any questions and comments, feel free to leave them in the section below.