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 struct
s:
- 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 Item
s, 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:
When you’re asked to select an organization, select your account:
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:
You should then see a list of projects from your chosen host. Select the repository for which you want to set up continuous integration:
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.
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.
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.