DEV Community

Jacob Kim
Jacob Kim

Posted on

Writing Tests in Go

In today's post, we will be looking into how to write tests in Go. Test-driven development, also known as TDD, is a paradigm in which developers are encouraged to test their code as they go.

What is a test and why should I care?

Image description

A test is a piece of code written to verify that your code behaves as it should. At first, this may sound quite tedious.

Meh. I know my code through and through. I know what I'm doing. Tests are such a waste of time.

-- Famous last words by yours truly :')

I used to think that writing tests were a waste of time as well. I wrote the code, so I should know best if it works or not, right? I can always just print debug and fix the bugs, right? There is no way that my function will bug out when it is called from somewhere else, right? ...Right?

There are a couple of reasons why writing tests are important. When you advance through your career or your school program, chances are that you will be working with other people. When you are working on a project together, you may be responsible for writing a piece of code that must be used by other people. We call this a library. When other people depend on your code working as it should, it is crucial to make sure that you deal with edge cases, or else they need to wait around until you fix it. This can be very time-consuming, and can mostly be prevented by testing your code on the way. Tests are like tools that you didn't know you need when coding solo, but happens to be a lifesaver in team projects.

A second reason to write tests is that it often leads to cleaner code. When we refer to tests, we usually mean unit tests, which means that we are testing each individual component of the code. In order to write these tests, you need to write your code in a way that is modular. For example, instead of one gigantic, bloated function, we can take that apart into atomic functions that do one thing well. Then each of those functions can be tested individually to ensure that they work properly.

A third reason is more subjective. I think TDD helps me visualize things better. In TDD, you are encouraged to write your tests first before actually implementing the functionality. In my case, by writing the test first, I am more sure of what my module should accept as input and what to spit out as output. I can also think of edge cases beforehand so that I don't need to go and fix my implementation afterward.

Writing tests in Go

It is pretty easy to write tests in Go. Go includes the testing package in its standard library, which I think is a very thoughtful decision from the developers of the language. You don't really need a separate testing framework or libraries like other languages, which I appreciate.

Go tests are stored in *_test.go files. The asterisk stands for your .go file with functions that you'd like to test. For example, let's say that I have a areas.go file that contains functions that calculate areas of different shapes. The tests for those functions will be in areas_test.go.

Let's begin with a high-level overview of our code. As I mentioned previously, areas.go will store three functions that calculate areas: findTriangleArea, findSquareArea, and findCircleArea. We will write the implementations later. We should write the input and output first, or else Go will complain when we are writing our tests.

package areas

func findTriangleArea(base, height float64) float64 {
    return 0.0
}

func findSquareArea()

func findCircleArea()
Enter fullscreen mode Exit fullscreen mode

We can now start writing our tests. Remember, we want to write the tests before the functions. Create a file named areas_test.go and type the following:

package areas

import (
    "testing"
)
Enter fullscreen mode Exit fullscreen mode

We start by importing these packages.

func TestFindTriangleArea(t *testing.T) {
    base := 2.0
    height := 3.0
    expectedArea := 3.0
    actualArea := findTriangleArea(base, height)

    if actualArea != expectedArea {
        t.Fatalf("expected %v, got %v", expectedArea, actualArea)
    }
}
Enter fullscreen mode Exit fullscreen mode

This is the test function for findTriangleArea. All Go test functions have names that are in the format of TestXxx(t *testing.T), where Xxx is the name of the function that you want to test. The first character of your test must be capitalized. testing.T is a variable that is responsible for keeping track of the state of your test. T can be used to call specific methods such as Errorf or Fatalf to terminate tests, return errors, log results, and more.

Inside the function, we see the main logic of the test. Simply put, a test checks to see if the expected value and the actual value match. In our case, we call findTriangleArea with a base of 2.0 and a height of 3.0. We expect to see an output of 3.0 because a triangle's area is found by (base * height) / 2. If the call to findTriangleArea returns a non-nil error, or if the returned area does not match the expected area, we will output an error and stop the test.

t.Fatalf is like fmt.Sprintf in the sense that we can format the output to our liking. I like to output the expected value, the actual value we got, and any errors if it's there.

Let's run the test. To run a test in Go, we go to the terminal, and cd into the directory where the test file is. Then, we run go test -v. You will get an output like this:

$ go test -v
=== RUN   TestFindTriangleArea
    areas_test.go:15: expected 3, got 0
--- FAIL: TestFindTriangleArea (0.00s)
FAIL
exit status 1
FAIL    example.com/areas     0.002s
Enter fullscreen mode Exit fullscreen mode

Now we can write out the implementation of findTriangleArea.

func findTriangleArea(base, height float64) float64 {
    area := base * height / 2
    return area
}
Enter fullscreen mode Exit fullscreen mode

Simple, right? I'll write one for other functions as well. Here's our final result.

// areas.go
package areas

import "math"

func findTriangleArea(base, height float64) float64 {
    area := base * height / 2
    return area, nil
}

func findSquareArea(side float64) float64 {
    area := side * side
    return area
}

func findCircleArea(radius float64) float64 {
    area := math.Pi * radius * radius

    // round to the 3rd decimal place
    roundedArea := math.Round(area*1000) / 1000
    return roundedArea
}
Enter fullscreen mode Exit fullscreen mode
package areas

import (
    "testing"
)

func TestFindTriangleArea(t *testing.T) {
    base := 2.0
    height := 3.0
    expectedArea := 3.0
    actualArea := findTriangleArea(base, height)

    if actualArea != expectedArea {
        t.Fatalf("expected %v, got %v", expectedArea, actualArea)
    }
}

func TestFindSquareArea(t *testing.T) {
    side := 2.0
    expectedArea := 4.0
    actualArea := findSquareArea(side)

    if actualArea != expectedArea {
        t.Fatalf("expected %v, got %v", expectedArea, actualArea)
    }
}

func TestFindCircleArea(t *testing.T) {
    radius := 2.0
    expectedArea := 12.566
    actualArea := findTriangleArea(radius)

    if actualArea != expectedArea {
        t.Fatalf("expected %v, got %v", expectedArea, actualArea)
    }
}
Enter fullscreen mode Exit fullscreen mode

If we now run go test -v, we will get the following result:

$ go test -v
=== RUN   TestFindTriangleArea
--- PASS: TestFindTriangleArea (0.00s)
=== RUN   TestFindSquareArea
--- PASS: TestFindSquareArea (0.00s)
=== RUN   TestFindCircleArea
--- PASS: TestFindCircleArea (0.00s)
PASS
ok    example.com/areas     0.001s
Enter fullscreen mode Exit fullscreen mode

Awesome! All our test passes.

Testing for multiple cases

This is all sunshine and rainbows, but what if we want to test for multiple cases? There are probably more than one cases that you want to run the test on. You'd be right.

Intuitively, we could just write something like this:

func TestFindSquareArea(t *testing.T) {
    side := 2.0
    expectedArea := 4.0
    actualArea := findSquareArea(side)

    if actualArea != expectedArea {
        t.Fatalf("expected %v, got %v", expectedArea, actualArea)
    }

    side = 3.0
    expectedArea = 4.0
    actualArea := findSquareArea(side)

    if actualArea != expectedArea {
        t.Fatalf("expected %v, got %v", expectedArea, actualArea)
    }

    // repeat...
}
Enter fullscreen mode Exit fullscreen mode

Because this code will get ugly real fast, let's try something else.

func TestFindSquareArea(t *testing.T) {
    type findSquareAreaTest struct {
        side float64
        expected float64
    }

    findSquareAreaTests := []findSquareAreaTest{
        {2.0, 4.0},
        {3.0, 9.0},
        {4.0, 16.0},
        {0.0, 0.0},
    }

    for _, test := range findSquareAreaTests {
        output := findSquareArea(test.side)
        if output != test.expected {
            t.Fatalf("expected %v, got %v", test.expected, output)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This looks much better. We declared a struct findSquareAreaTest that holds the side value and the expected value. Then, we create a slice of that struct, which holds all of our test cases. We just need to use a for loop to iterate through each test case. This type of approach is called table-driven testing because we are using a table that holds many test cases.

Conclusion

Thank you for reading this post. I hope you found it useful. There are certainly more advanced testing strategies than what I've listed here. I highly recommend you check out the documentation for the testing package to dive deeper. However, this post will get you started with test-driven development. Try applying it in your own personal project! I'll see you next time with more interesting topics. Bye!

You can also read this post on Medium and my personal site.

Top comments (3)

Collapse
 
goncalorodrigues profile image
Gonçalo Rodrigues

Great introduction to testing in Go!

One thing I have struggled with in the past is the balance between unit tests and end to end tests.
Unit tests usually feel too tightly coupled with the implementation and need to be constantly changed. And because they don't test integrations between the components I still find that most of the potential bugs go untested.
End to end on the other hand are amazing. I find that they catch a lot of errors. It's impossible to test everything though as they end up being quite slow and you get an explosion of input parameters combinations.

So I end up writing a lot of end to end tests and just unit test the critical parts of my code.

Collapse
 
jpoly1219 profile image
Jacob Kim

Yeah, it does become a bit tedious to change my unit tests every time I make some modifications to my code. I'm trying out different testing methodologies at the moment, and your method seems like it could save a lot of time.

Collapse
 
jpoly1219 profile image
Jacob Kim

Thank you for the recommendation! I'll be sure to read it.