I’m Learning to Program in Go
I’ve set myself the task of learning Go whilst I work out what I want to do next with my life. I have read For the Love of Go by John Arundel cover to cover and back again and have just started his next book, The Power of Go. I’m going to try to deliver at least one post per week, solving a new problem each time. I’m under no illusions that my first few posts will not be in any way idiomatic Go as I’m quite settled into a functional programming style with F#.
The Task⌗
I decided to try to solve the domain problem from Chapter 1 of my Essential F# ebook with my current limited understanding of Go. Whilst it’s more complex than “Hello, world!”, it does force me to get my environment set up and showcase some of the things that I have/haven’t learned.
The task we are to implement is to calculate the discounted price for different customers using the following user story:
Feature: Applying a discount
Scenario: Eligible Registered Customers get 10% discount when they spend £100 or more
Given the following Registered Customers
|Customer Id|Is Eligible|
|John |true |
|Mary |true |
|Richard |false |
When [Customer Id] spends [Spend]
Then their order total will be [Total]
|Customer Id| Spend| Total|
|Mary | 99.00| 99.00|
|John | 100.00| 90.00|
|Richard | 100.00| 100.00|
|Sarah | 100.00| 100.00|
Notes:
Sarah is not a Registered Customer
Only Registered Customers can be Eligible
I’m going to approach the task the way that I’ve been learning Go, test first.
Getting Started⌗
Create a folder called discount and open it in VS Code.
In the Terminal, type the following:
go mod init discount
This creates a file called go.mod that contains code that looks like this:
module discount
go 1.19
Further discussion on this will have to wait for a future post.
Add two files to the folder: discount.go and discount_test.go. That’s correct, you put the test file in the same folder as the source file! The *_test.go convention is required by the testing package we are going to use.
At the top of discount.go, add the following:
package discount
We will reference this package name in the discount_test.go file.
Add the following to the top of discount_test.go:
package discount_test
import (
"discount"
"testing"
)
We are referencing both our discounts package and the testing package from the Go standard library.
We are now ready to try to write our first test.
Basic Test Structure⌗
We create a simple function that starts with the name Test and we pass in a testing instance as a parameter:
func TestBehaviourDescription(t *testing.T) {
t.Parallel()
want := 1 // expected value
got := 1 // actual value from function
if want != got {
t.Errorf("want %f, got %f", want, got)
}
}
Don’t worry about the *
prefixing the type of the t
parameter.
The t.Parallel()
tells the testing instance to run this test in parallel with any others.
The want := 1
and got := 1
are variable assignments where the type is inferred by the compiler. This is a shorter way than this original style:
// var name type
var want int // zero value of type if not set
isTrue := want == 0 // true
want = 1
If you don’t want to set the value of the variable, use the older style where it will be set to the zero value of that type. Otherwise use this shorter style:
want := 1 // type is inferred
So, we have a value we expect, want
and a value we calculate, got
, which we compare to each other, and if they don’t match, we report it as an error.
Now that we know what a basic test function looks like, let’s get started solving our problem.
Initial Version⌗
We will start with Richard, who spends sufficient to potentially get a discount and is registered but not eligible, so will not qualify for a discount.
Let’s add the want
value from the data table, create a Customer instance, and call the Calculate
function:
func TestRegisteredNoDiscount(t *testing.T) {
t.Parallel()
want := 100.0
c := discount.Customer{}
got := discount.Calculate(c, 100.0)
if want != got {
t.Errorf("want %f, got %f", want, got)
}
}
Let’s add sufficient code to discount.go to make it compile:
package discount
type Customer struct {}
func Calculate(c Customer, spend float64) float64 {
return 0.0
}
For the Customer type, we are creating a struct
with no properties as we don’t need them yet. The struct
is the primary data structure in Go and has no behaviour, just data.
The Calculate
function takes two input parameters, one of *Customer and one of float64, and a return type of float64.
Run the test by typing the following in the Terminal:
go test
We now have a failing test that we need to fix.
Let’s do the minimum to make it succeed:
func Calculate(c Customer, spend float64) float64 {
return spend
}
If we run the test again, it should succeed:
go test
Whilst the test passes, we have some additional data that we need to add to the Customer and the test:
type Customer struct {
Name string
IsRegistered bool
IsEligible bool
}
Notice that we don’t need to add the Name
property to the Customer instance in the test:
func TestRegisteredNoDiscount(t *testing.T) {
t.Parallel()
want := 100.0
c := discount.Customer{IsEligible: false, IsRegistered: true}
got := discount.Calculate(c, 100.0)
if want != got {
t.Errorf("want %f, got %f", want, got)
}
}
Run the test again to confirm that it still passes:
go test
Now we could continue with this test-first approach but for this blog post, we are going to assume that that is what I did!
Let’s add the logic to the Calculate
function:
func Calculate(c Customer, spend float64) float64 {
if c.IsRegistered && c.IsEligible && spend >= 100.0 {
return spend * 0.9
}
return spend
}
Add finally, the remaining tests for the other three customers:
package discount_test
import (
"discount"
"testing"
)
func TestEligibleWithDiscount(t *testing.T) {
t.Parallel()
want := 90.0
c := discount.Customer{IsEligible: true, IsRegistered: true}
got := discount.Calculate(c, 100.0)
if want != got {
t.Errorf("want %f, got %f", want, got)
}
}
func TestEligibleNoDiscount(t *testing.T) {
t.Parallel()
want := 99.0
c := discount.Customer{IsEligible: true, IsRegistered: true}
got := discount.Calculate(c, 99.0)
if want != got {
t.Errorf("want %f, got %f", want, got)
}
}
func TestRegisteredNoDiscount(t *testing.T) {
t.Parallel()
want := 100.0
c := discount.Customer{IsEligible: false, IsRegistered: true}
got := discount.Calculate(c, 100.0)
if want != got {
t.Errorf("want %f, got %f", want, got)
}
}
func TestGuestNoDiscount(t *testing.T) {
t.Parallel()
want := 100.0
c := discount.Customer{IsEligible: false, IsRegistered: false}
got := discount.Calculate(c, 100.0)
if want != got {
t.Errorf("want %f, got %f", want, got)
}
}
Run the tests to make sure that all is well:
go test
So far, so good. However, I’m worried about two things; the boolean properties and the ownership of the code. Time for a rethink. Ownership first.
Rethinking Ownership⌗
The Customer type being in a package for discount seems wrong. In fact, when you look at the Calculate
function, it all relates to the Customer type.
We need to rename the files and references plus the module name in go.mod to customer
. You should also rename the folder as well. Reload the folder into VS Code.
The customer.go file should start:
package customer
The customer_test.go file should start:
package customer_test
import (
"customer"
"testing"
)
As the Calculate
function is Customer-centric, we can adapt it slightly and turn it into a method instead of a function:
func (c Customer) Calculate(spend float64) float64 {
if c.IsRegistered && c.IsEligible && spend >= 100.0 {
return spend * 0.9
}
return spend
}
Notice that we have moved the Customer input parameter before the Calculate name. This new parameter is called the receiver.
This change has an impact on our tests because the Calculate
function is now a method on the Customer instance:
func TestEligibleWithDiscount(t *testing.T) {
t.Parallel()
want := 90.0
c := customer.Customer{IsEligible: true, IsRegistered: true}
got := c.Calculate(100.0)
if want != got {
t.Errorf("want %f, got %f", want, got)
}
}
func TestEligibleNoDiscount(t *testing.T) {
t.Parallel()
want := 99.0
c := customer.Customer{IsEligible: true, IsRegistered: true}
got := c.Calculate(99.0)
if want != got {
t.Errorf("want %f, got %f", want, got)
}
}
func TestRegisteredNoDiscount(t *testing.T) {
t.Parallel()
want := 100.0
c := customer.Customer{IsEligible: false, IsRegistered: true}
got := c.Calculate(100.0)
if want != got {
t.Errorf("want %f, got %f", want, got)
}
}
func TestGuestNoDiscount(t *testing.T) {
t.Parallel()
want := 100.0
c := customer.Customer{IsEligible: false, IsRegistered: false}
got := c.Calculate(100.0)
if want != got {
t.Errorf("want %f, got %f", want, got)
}
}
After making these changes, run the tests again from the Terminal:
go test
Much nicer. Now we need to replace those boolean properties with something safer and more domain-centric.
Further Improvements⌗
Sadly, we don’t have discriminated unions in Go, so we have to look at alternatives. I’m not really au fait with interfaces in Go, which I think I’m going to need, so I’m going to use a simpler construct: constants. We create a new type called Status that wraps a value, in this case, I used a string, set the type of each constant as Status, add a new property to the Customer struct, and use the Eligible status in the Calculate
method logic:
package customer
type Status string
const (
Guest Status = "G"
Registered Status = "R"
Eligible Status = "E"
)
type Customer struct {
Name string
Status Status
}
func (c Customer) Calculate(spend float64) float64 {
if c.Status == Eligible && spend >= 100.0 {
return spend * 0.9
}
return spend
}
We have to make a change in each test to accommodate the new property on the Customer struct:
package customer_test
import (
"customer"
"testing"
)
func TestEligibleWithDiscount(t *testing.T) {
t.Parallel()
want := 90.0
c := customer.Customer{Status: customer.Eligible}
got := c.Calculate(100.0)
if want != got {
t.Errorf("want %f, got %f", want, got)
}
}
func TestEligibleNoDiscount(t *testing.T) {
t.Parallel()
want := 99.0
c := customer.Customer{Status: customer.Eligible}
got := c.Calculate(99.0)
if want != got {
t.Errorf("want %f, got %f", want, got)
}
}
func TestRegisteredNoDiscount(t *testing.T) {
t.Parallel()
want := 100.0
c := customer.Customer{Status: customer.Registered}
got := c.Calculate(100.0)
if want != got {
t.Errorf("want %f, got %f", want, got)
}
}
func TestGuestNoDiscount(t *testing.T) {
t.Parallel()
want := 100.0
c := customer.Customer{Status: customer.Guest}
got := c.Calculate(100.0)
if want != got {
t.Errorf("want %f, got %f", want, got)
}
}
Once this is done, the code should compile and the tests should succeed:
go test
Whilst the code is better than it was, it doesn’t give me the type safety that I’ve come to expect from F#. I’m hoping that it’s just lack of knowledge that is holding me back.
Finally⌗
That’s the end of my first post using Go. It has been a pleasant experience working through For the Love of Go and I highly recommend it to anyone wanting to learn Go in a practical, hands-on way. You can purchase it like I did from https://bitfieldconsulting.com/books/love.
In the next post, I’ll dive deeper into my current understanding of functions.