- Lab
- Core Tech

Guided: Getting Started with Go Unit Testing
In this lab, you will learn how to write unit tests for applications written in Go. You will go over the unit testing tools built into the Go programming language. After completion of this lab, you will have learned the basic tools required to test your own code.

Path Info
Table of Contents
-
Challenge
Introduction
Nobody likes bugs. No, we are not talking about spiders or butterflies; many people actually do like those. We're talking about software bugs. Whenever you write code, you are trying to make a computer perform a specific task. You are trying to transform an idea into instructions that implement that idea, and if you want to succeed at that task, those instructions need to be accurate. If you fail to do so, your program has a bug.
As a programmer, you have multiple tools at your disposal to ensure that you are writing "good" code, i.e. that it has no bugs. Your editor may warn you about syntax errors. The type system may tell you that parts of your code don't match up. The language's compiler (if it has one) may refuse to compile the program if it encounters issues. These tools tell you something about the correctness of the program from a structural and syntactical point of view.
However, they have their shortcomings. For one, depending on the language (and editor, and compiler) you are using, they may have blind spots; there are issues they can miss. Secondly, although these tools may tell you whether a program will compile and run, they have nothing to say about whether it will do the right thing. In other words, they don't validate the behavior of your program. What you intend the code to do and what it actually does when you run it, under varying conditions and with varying input, can vary wildly.
As you gain experience, you will get better at understanding all the nuances and intricacies of the languages, frameworks, and programs you work with. You will be able to look at code and 'grok' its behavior. Mostly. But as the complexity of your applications increases - and software does tend to increase rapidly in complexity - this task will become more and more difficult. What you need is another tool to validate the correctness of your code. What you need are tests.
Although there are many types of tests (API tests, UI tests, integration tests, etc.), we will focus here on the most fundamental kind of test: the unit test. Unit tests generally do not validate the behavior of a program as a whole, but instead of its parts (or units). A unit can be a single expression, or a function, or a class (or struct, in the case of Go); and a unit test validates such a unit by passing it different kinds of input and checking that the output is as expected, ignoring the rest of the application.
By focusing on isolated units, unit tests help you ignore some of the complexity of your program. Instead of saying "this program behaves as I expect", they allow you to say "all the parts of the program behave as I expect". And due to their nature, unit tests are generally fast, allowing you to write many of them, and run them often. As you start writing unit tests more and more, you will see that this kind of feedback is invaluable, and will let you write your software with confidence.
In this lab, you will learn how to write, run, and manage unit tests for the Go programming language. You will practice with various aspects of unit tests, and you will learn how to use them effectively. By the end of the lab, you are ready to start adding unit tests to your own software, and you will have a good foundation to start learning more about this fundamental tool in your programmer's toolbox.
Each step has a folder containing the source files for that step (
step2
,step3
, etc.). Throughout each step, you'll make your changes to these files. Whenever the validation of a task fails, you can find more detailed feedback in the test output. To find this feedback, click on the small arrow next to the label 'incomplete'. If you ever get stuck, take a look at thesolutions
folder for ideas on how to continue. -
Challenge
The basics
Let's start with the basics. In this step, you'll write your first test, and learn how to make fail or pass. The files for this step can be found in
step2
.Unit tests in Go
As a Go developer, you are fortunate enough to work with a language that has good developer tooling. Go comes with many tooling out of the box, including built-in support for unit testing.
Any file that has a name ending in
_test.go
is considered a test file by the Go tooling. Within these files, any function that starts withTest
is considered a test function. Test functions take the shapefunc TestXXX(t *testing.T) {}
. They don't return anything.For example, your codebase may contain a file
area.go
, containing a function calledArea
. If you wanted to write tests for theArea
function, you would create a file calledarea_test.go
. Within this file, you would then define one or more test function, such asTestAreaSquare
andTestAreaRectangle
.The names of code files and test files (
area
andarea_test
, in this case) do not need to match up. However, in order to keep your codebase easy to understand, it is good practice to do stick to this convention, generally. You can now go ahead and run your (empty) test by executinggo test ./step*
in the console. This should print out the result of your tests, along with the time it took to execute them. You can also executego test -v ./step*
(v
stands for verbose) to see a bit more detail; this will prove useful as your test suite grows.The run button in the bottom right of the screen executes
go test ./step*
. If you want to run your tests with verbose output, you will need to typego test -v ./step*
in the terminal and press enter. If you want to run all the tests (including the ones in the solutions folder), rungo test ./...
.Making a test pass or fail
At their core, tests can either pass or fail. By default, a test function will pass. It is up to you, as the programmer, to write test code that determines when a test should fail. You do so by calling one of several methods available on the
t *testing.T
struct that's passed to your test function, such as.Fail()
or.Error()
.| Method | Description | |----------------------|-------------------------------------------------------------------------------| | t.Fail() | Marks the test as 'failed', but continues with the rest of the test function. | | t.FailNow() | Marks the test as 'failed' and stops execution of the current test function. | | t.Error(args ...any) | Logs the 'args' parameters to the console and then calls .Fail() | | t.Fatal(args ...any) | Logs the 'args' parameters to the console and then calls .FailNow() | Later on, you'll see that tests can convey much more information than a simple
pass
orfail
, but for now, let's keep it simple.Right now, your test does not do anything useful yet, so let's change that.
Anatomy of a test
Unit tests, and tests in general, are all about validating expectations or, in other words, making assertions. A unit test executes a piece of your code (a function), passing in specific values, and checking that the result is what you expected.
-
In all but the simplest of unit tests, this involves a little (or a lot of) prep work; you may need to instantiate some values, set up mocks (we'll get to that later), or otherwise set up the specific conditions under which you want to validate your code.
-
You then execute the code that you want to test, passing in the values that you prepared.
-
After that, you make some assertions about the result.
The most basic assertion is to check whether your function returned an error or not. However, as you'll see later, there are many more types of assertions available to you.
Although we are simplifying a lot here, most tests that you will encounter (or write) follow the structure outlined above. This structure is often referred to as
Arrange/Act/Assert
:Arrange
: set up the right preconditions for your test caseAct
: execute the code you want to testAssert
: check the results and validate your assumptions about the outcome
Alternatively, this structure is referred to as
Given/When/Then
, as in:Given
<these parameters>
, When<I execute this function>
, Then<I expect the following outcome>
With this structure in mind, let's fill in your first test. The
Area
function calculates the area of a rectangle, given a width and a height. Go ahead and make sure that it does so correctly. When you run the test suite, you should see your test pop up, with a nice big 'PASS' in front of it. Congratulations on writing your first successful test. Move on to the next step to see how to deal with failing tests, instead. -
-
Challenge
Dealing with failing tests
As a developer, you always want to see your tests pass after you made some changes. But the fact is, sometimes they'll fail. That's not a bad thing; tests are there to help us out when we make a mistake.
In this step, you'll take a closer look at the geometry code ('step3/area.go'). You'll expand the tests suite, look for mistakes, and make sure that the tests give the right information to correct them.
Testing all code paths
So far, you only wrote a test for the happy path, where you pass positive values to the function and get the correct result. But how do you know the function handles different kinds of input well? For example, what if you would pass negative numbers?
Calculating an area based on two dimensions (width and length) is a matter of multiplying them together. However, there is no such thing as a negative area, so the code must return an area if either of the two numbers is negative. This means there are multiple potential 'paths' that the code can take, and you must test them all. Let's start with negative width. So far so good. When you pass a negative width, the code returns
0
and an error, which is as expected. The fact that the function returns an error is a good thing, in this case. But things are about to take a bad turn. If you did everything right, this task was marked as complete, but you'll see in the console output that the test actually failed. You found a bug in the code! Even though you passed a negative number as the second parameter, theCalculateArea
function did not return an error. You'll need to fix this, but first you're going to use this as an opportunity to improve the information you get from the tests.Output and errors
So far, the feedback you got from the tests has been limited; the tests either passed or failed. If a test only contains one assertion, this can be enough. But as you saw in the last test, a test might fail for multiple reasons. If a test like that fails, it is not immediately obvious why it failed.
To aid in debugging, you can provide an error message when failing. This error message aids the developer in determining exactly when and where the test failed.
For example, in the last test (
TestCalculateAreaNegativeLength
), there are two points at which the test could fail:- after you check whether
area
is not equal to0
; and - after you check whether
err
is equal tonil
Instead of using the method
t.Fail()
, you could use two other useful methods:t.Errorf("wrong value for 'area', expected: %v, got %v", 0, area)
t.Error("expected an error")
These methods are analogous to the methods
fmt.Print()
andfmt.Printf()
that you may already be familiar with. They mark the test as failed, and print the provided error message to the test output.While these methods will mark the test as failed, the test does keep running. If you want to abort the test right away, you can use
t.Fatal()
andt.Fatalf()
, instead. With these error messages in place, future you will have a much easier time in debugging any failing tests. You can go a step further, by also logging informative messages in other places within the test code without failing the tests. This becomes more and more useful as your tests grow in complexity, e.g. when you require complex steps in the 'arrange' part of your tests.To log a message without failing the test, you can call either
t.Log()
ort.Logf()
; they work in a similar fashion as the methods you've seen above, without marking the test as failed.Fixing the code
With the error messages in place, let's go ahead and fix the code. You can use the information provided by the error messages to analyze the problem. The messages indicate that the code followed the happy path, when it shouldn't have.
Can you spot the problem? Congratulations on going through your first dev/test cycle. Let's move on to more advanced topics.
- after you check whether
-
Challenge
Adding structure to your tests
In this step, you'll look into ways to improve the structure and the 'expressiveness' of your tests, i.e. the extent to which your tests give you proper feedback. You'll work based on the code in
step4/calculator.go
, with its tests instep4/calculator_test.go
. The code implements a simple calculate, implemented by a struct with multiple methods. The test file contains tests for all these methods.Preparation and cleanup
If you start noticing that you're writing a lot of recurring boilerplate for each test, or if you need to do certain cleanup tasks after all the tests in a file have run, it may be time to extract that code in one central location. Remember that we said Go automatically recognizes and executes any function starting with
Test
as a test case? Well, there is one exception; if you add a functionfunc TestMain(m *testing.M)
, then only that function is executed, and no others. You would use it like this:func TestMain(m *testing.M) { log.Println("Preparation") exitVal := m.Run() log.Println("Cleanup") os.Exit(exitVal) // Required to indicate whether the test run passed or failed }
The magic is in the call to
m.Run()
; this executes all the tests in your current package. But now that you trigger the tests manually from insideTestMain
, you are free to any preparation or cleanup as you see fit.Even with the simple test cases in
step4/calculator_test.go
, you can see that a calculator is initialized in every test. Sometimes, that's a good thing; if the calculator were stateful, i.e. that the values of its members changed during execution, you would need a new instance every single time to avoid one test from affecting the tests that came after it. But in some cases, like in this example, it is safe to instantiate the calculator only once. You didn't save a lot in terms of lines of code, but you did deduplicate boilerplate code. This can help with readability and maintainability of your tests. As you write more tests and your tests grow more complex, the benefits will increase.Subtests
Speaking of writing more tests: you'll start noticing that the amount of test code grows quickly. It is not uncommon for test code to outgrow the code it's testing. This is generally not a problem, because test code tends to be simpler than the code under test. However, at one point or another, you'll feel the need to bring more structure to your tests, to keep the test code as well as the test output easily understandable.
Let's take another look at
step4/calculator_test.go
. You may notice that these tests check multiple distinct methods, and that some methods are even covered by multiple tests. However, the structure of the tests and their output does not reflect this. What this test file lacks is some structure.If you could group the tests according to the function they're testing, the readability of the test file and the test output would improve a lot. Fortunately, Go makes it easy to do so, by letting you define subtests. You can define subtests by inserting calls to
t.Run()
inside your test functions, so thatfunc TestMyFunctionWithValidInput(t *testing.T) { // arrange result := MyFunction("valid input") // act // assert }
becomes
func TestMyFunction(t *testing.T) { t.Run("WithValidInput", func (t *testing.T) { // arrange result := MyFunction("valid input") // act // assert }) }
This gives you an easy way to group related tests, and allows you to write any initialization code for the tests in each group only once (instead of repeating it in every test). It is possible to nest subtests further, if needed, by nesting calls to
t.Run()
inside anothert.Run()
: In the example, theDivide
method has two tests: "DivideValid" and "DivideByZero". Using nested subtests, you can group these two tests as well. -
Challenge
Controlling test execution
Now that we have covered the basics of creating and structuring unit tests in Go, let's look at ways to control how your tests are executed. In this step, you're using a slightly adjusted version of the calculator code; you'll find it in
step5/calculator.go
.Skipping tests
There can be situations in which you do not want to run all tests. Examples include (but are not limited to):
- during development, if you want to focus on one or more specific tests
- if a test is not finished
- if a certain condition is met
- in different environments, such as in your CI environment vs locally
Go provides a few ways to control exactly which tests are executed. We will look at a few of them.
The simplest way to skip execution of a test is by using one of the following methods:
| Method | Description | |-------------------------------------|----------------------------------------------------------------------------------------------------| | t.SkipNow() | Marks the test as 'skipped' and stops execution of the current test function. | | t.Skip(args ...any) | Logs the 'args' parameters to the console and then calls .SkipNow() | | t.Skipf(format string, args ...any) | Logs the parameters to the console (similar to how
fmt.Printf()
works) and then calls .SkipNow() |These methods can be called at any point during execution of your test, but if the test was already marked as
failed
earlier in the test, it will still be marked asfailed
in the output. Skipping tests using the methods listed above can be useful, but the downside is that you actually have to adjust the test functions (by introducing these method calls). If you simply want to reduce the set of executed tests during development, you will have to resort to the command line. There are several options that you can pass to thego test
command to select specific tests.To zoom in on a particular package, you can pass the package path instead of
./...
, e.g.:go test getting-started-with-go-unit-testing/step5
To run specific tests matching a specific name, you can pass
-run `MyFunction`
. This will run all tests that contain the textMyFunction
in their function name.Actually, the parameter after
-run
is a regular expression, but explaining regular expressions is beyond the scope of this lab.Both techniques can be combined to zoom in on specific tests with surgical precision.
Running tests in parallel
Although unit tests are generally fast, it can still take a while to execute all tests in a large code base. Luckily, Go has two tricks up its sleeve to speed things up:
- It skip tests for code that has not changed; and
- Tests in different packages are executed in parallel
That said, Go does not run tests within a package in parallel by default. The reason for this is that it's not uncommon for tests within a package to influence each other. It is up to you as the developer to mark tests within a package as safe for manual execution. To do so, the
t *testing.T
struct has one more useful method:t.Parallel()
. This method must be called in each test that you want to run in parallel, preferably at the start of the test.You may have noticed that this version of the calculator code is slightly different. In this version, the calculator keeps track of the last calculated value, and operations ('Add', 'Subtract', etc.) only require one parameter. We pretend that its methods perform heavy calculations by calling
time.Sleep()
. Lastly, it now has a methodReset()
, which initializes the calculator's state to the provided value. This method gets called at the start of each of the tests, to make sure it operates on a known state. Because all tests share a single instance of the Calculator struct, and because the tests are no longer executed one by one, they started interfering with each other. Between the moment that one test resets the calculator and runs the operation, another test function has changed the current value of the calculator, leading to a different result than we expected. This is a case where it would have been better to initialize a new constructor within every test, instead of a single calculator withinTestMain()
. -
Challenge
Next steps
Congratulations on completing your first steps towards learning how to write effective unit tests using Go. You now have all the tools you need to start writing unit tests to your own applications. As you practice more, writing tests will hopefully become a habit -- second nature maybe, even.
When you're ready to dive deeper, there is plenty more to explore. You'll find a range of additional topics, along with links to relevant material, in the list below.
The following topics are covered in the "Testing in Go" course, which is part of the "Go" path:
- Benchmark testing: Identify and analyze differences in performance between multiple implementations.
- Fuzz testing: Automate the generation of input parameters to find edge cases.
Although Go comes with good testing support out-of-the-box, there are many testing libraries/frameworks that can help you become even more productive. One of the most widely known is "stretchr/testify", giving you a wide range of powerful assertions and mocking capabilities.
For more background on the concept and theory of unit testing, check out the excellent "Unit Testing Fundamentals" course.
What's a lab?
Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.
Provided environment for hands-on practice
We will provide the credentials and environment necessary for you to practice right within your browser.
Guided walkthrough
Follow along with the author’s guided walkthrough and build something new in your provided environment!
Did you know?
On average, you retain 75% more of your learning if you get time for practice.