Programming isn't an easy task to perform and even the most experienced developers and programmers even aren't able to write code in the exact accurate and perfect format they want to design or intend to create.
Therefore, writing tests is a very important task to increase the efficiency of the code before pushing it to production. Go includes a special program that makes writing tests easier:
go test
A test function in Go starts with Test
and takes *testing.T
is the only parameter. In most cases, you will name the unit test Test[NameOfFunction]
. The testing
package provides tools to interact with the test workflow, such as t.Errorf
, which indicates that the test failed by displaying an error message on the console.
Types of Tests
Generally, there are two types of tests in the Go:--
Unit Tests:-
Unit tests in Go, as in other programming languages, are focused on testing individual units of code in isolation. A unit is the smallest testable part of the software, usually a function, method, or small logical section of code. Unit tests aim to ensure that each unit behaves as expected, regardless of how other parts of the program may interact with it. To isolate the unit under test, mocking or stubbing of dependencies may be employed to avoid unintended side effects.
General tests focus on higher-level functionalities and interactions between different parts of the application. They are useful for testing the overall behavior and integration of the components.
Integration Tests:-
Integration tests in Go refer to tests that cover a broader scope, typically testing the behavior of a complete program or a larger module of the codebase. These tests are also known as General tests or end-to-end tests. General tests focus on testing interactions between multiple components of the software to ensure that they work together correctly. These tests might involve external dependencies, databases, network communications, or any other interactions that the program performs.
Unit tests, on the other hand, focus on small units of code in isolation, ensuring that each piece works correctly and meets its specifications. Unit tests provide rapid feedback during development and help to catch bugs early.
Playing out with The Testing
To create a GO test, create a file with the name say math_test.go
. The GO compiler knows to ignore code in any files that end with _test.go
, so the code defined in this file is only used by the go test.
Then we import the special testing package and define a function that starts with the word Test followed by whatever we want to name our test. We will be testing the Average function we wrote before, so let's name it as TestAverage:
package maths
import "testing"
func TestAverage(t *testing.T){
v := Average([]float64{1, 2})
if v != 1.5{
t.Error("Expected 1.5, got ", v)
}
}
In the provided code, t.Error
is used to report a test failure in the Go testing framework. The t
parameter is of type *testing.T
, which is the testing framework's test context that allows you to manage and report test results.
Here in this function, we are asking if the average of the numbers comes out to be correct or not and if the average doesn't comes write which is 1.5 in this case of 1 and 2, then it should give an error.
In the code snippet, you provided, the TestAverage
function is a unit test that checks whether the Average
function behaves correctly. If the test condition specified in the if
statement is not met, meaning the computed average is not equal to 1.5
, the test will fail, and you want to report that failure.
To actually run the test, run the following in the same directory:
go test
On running the code, if they pass the test you will see the lines below the run command below in the terminal as:
PASS ok golang-program/test 0.32s
The time reported might get returned according to the time complexity and the time took to complete the tests in the code.
The go test command will look for any tests in any of the files in the current folder and run them. Tests are identified by starting a function with the word Test and taking one argument of type *testing.T.
Similarly, in the same way, we can write code to test check ou the average of the numbers in the program as:--
package math
import "testing"
type testpair struct{
values []float64
average float64
}
package math
var tests = []testpair{
{[]float64{1, 2}, 1.5},
{[]float64{1, 4}, 2.5},
{[]float64{5, 8, 10}, 11.5},
{[]float64{-2, 2}, 0}
}
// Average calculates the average of a slice of float64 values.
func Average(values []float64) float64 {
if len(values) == 0 {
return 0.0 // or handle the empty slice case as per your requirements
}
sum := 0.0
for _, v := range values {
sum += v
}
return sum / float64(len(values))
}
func TestAverage(t *testing.T){
for _, pair := range tests{
v := Average(pair.values)
if v != pair.average{
t.Error(
"For", pair.values,
"expected", pair.average
"got", v,
)
}
}
}
So here in this blog, there are certain functions in the code which are:--
for _, pair := range tests
: This is a range-basedfor
loop. It iterates over each element in thetests
slice, where each element is represented by the variablepair
. The underscore_
is used as a "blank identifier" in Go to indicate that we don't need to use the index of the element in the loop. Since the test cases are stored as structs, and we are only interested in the values of the structs, we use_
to ignore the index.if v != pair.average { ... }
: After calculating the average for the current test case, we check if the computed average (v
) is equal to the expected average (pair.average
). If the two values are not equal, it means the test failed, and we want to report an error with relevant information about the test case.t.Error("For", pair.values, "expected", pair.average "got", v,)
: If the test fails (computed average is not equal to the expected average), we uset.Error
to report the failure with a custom error message. The error message includes details such as the input values for the test case (pair.values
), the expected average (pair.average
), and the computed average (v
).
So here in the code, as we can see in the function TestAverage
, the for loop is used to call the slice being made named tests
with the numbers as in the input and the checks are also included in it to check if the average of the number comes out to be equal to the one being asked to have in the test checks.
This is a very common way to set up tests. We create structs to represent the inputs and outputs for the function:
type testpair struct{
values []float64
average float64
}
This function is very practical, but due to its relative newness, many Go developers are unaware of it and still manage temporary directories manually in their tests.
Canonical Way of Writing Unit Tests
While Writing up the tests, you might end up writing a lot of repeated codes which might make your work a lot more tedious as well. You could write one test function per case, but this would lead to a lot of duplication. You could also call the tested function several times in the same test function and validate the output each time, but if the test fails, it can be difficult to identify the point of failure. Instead, you can use a table-driven approach to help reduce repetition. As the name suggests, this involves organizing a test case as a table that contains the inputs and the desired outputs.
This comes with two benefits:
Table tests reuse the same assertion logic, keeping your test DRY.
Table tests make it easy to know what is covered by a test, as you can easily see what inputs have been selected. Additionally, each row can be given a unique name to help identify what’s being tested and express the intent of the test.
func TestNumberTableDriven(t *testing.T) {
// Defining the columns of the table
var tests = []struct {
name string
input int
want string
}{
// the table itself
{"1 is the first", 1, "first"},
{"2 si the second", 2, "second"},
{"3 is not the first ", 3, "3"},
{"4 is the fourth", 4, "fourth"},
}
// The execution loop
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ans := Number(tt.input)
if ans != tt.want {
t.Errorf("got %s, want %s", ans, tt.want)
}
})
}
}
this code will eventually, provide this result as:
--- PASS: TestNumberTableDriven (0.00s)
--- PASS: TestNumberTableDriven/1_is_the_first (0.00s)
--- PASS: TestNumberTableDriven/2_si_the_second (0.00s)
--- PASS: TestNumberTableDriven/3_is_not_the_first (0.00s)
--- PASS: TestNumberTableDriven/4_is_the_fourth (0.00s) PASS
A table-driven test starts by defining the input structure. You can see this as defining the columns of the table. Each row of the table lists a test case to execute. Once the table is defined, you write the execution loop.
The execution loop calls t.Run
()
, which defines a subtest. As a result, each row of the table defines a subtest named [NameOfTheFuction]/[NameOfTheSubTest]
.
This way of writing tests is very popular and considered the canonical way to write unit tests in Go.
The Testing Package
The testing
package plays a pivotal role in Go testing. It enables developers to create unit tests with different types of test functions. The testing.T
type offers methods to control test execution, such as running tests in parallel with Parallel()
, skipping tests with Skip()
, and calling a test teardown function with Cleanup()
.
Errors and Logs
The testing.T
type provides various practical tools to interact with the test workflow, including t.Errorf()
, which prints out an error message and sets the test as failed.
It is important to mention that t.Error*
does not stop the execution of the test. Instead, all encountered errors will be reported once the test is completed. Sometimes it makes more sense to fail the execution; in that case, you should use t.Fatal*
. In some situations, using the Log*()
function to print information during the test execution can be handy:
func Testnumber(t *testing.T) {
input := 5
result := number(5)
t.Logf("The input was %d", input)
if result != "numb" {
t.Errorf("Result was incorrect, got: %s, want: %s.", result, "numb")
}
t.Fatalf("Stop the test now, we have seen enough")
t.Error("This won't be executed")
}
Creating a good set of tests, and in particular, knowing precisely which values to test, takes a bit of practice. For a list of floating-point numbers, it's a good idea to test a variety of cases: an empty list, several random values, repeated or negative numbers, and so on. But even a small set of basic tests is better than none.
Running Parallel Tests
By default, tests are run sequentially; the method Parallel()
signals that a test should be run in parallel. All tests calling this function will be executed in parallel. go test
handles parallel tests by pausing each test that calls t.Parallel()
, and then resuming them in parallel when all non-parallel tests have been completed. The GOMAXPROCS
environment defines how many tests can run in parallel at one time, and by default, this number is equal to the number of CPUs.
You can build a small example running two subtests in parallel. The following code will test number(5)
and number(11)
at the same time:
func TestnumberParallel(t *testing.T) {
t.Run("Test 3 in Parallel", func(t *testing.T) {
t.Parallel()
result := number(5)
if result != "numb" {
t.Errorf("Result was incorrect, got: %s, want: %s.", result, "numb")
}
})
t.Run("Test 7 in Parallel", func(t *testing.T) {
t.Parallel()
result := number(11)
if result != "11" {
t.Errorf("Result was incorrect, got: %s, want: %s.", result, "11")
}
})
}
Skipping Tests
Using the Skip()
method allows you to separate unit tests from integration tests. Integration tests validate multiple functions and components together and are usually slower to execute, so sometimes it’s useful to execute unit tests only. For example, go test
accepts a flag called -test.short
that is intended to run a “fast” test. However, go test
does not decide whether tests are “short” or not. You need to use a combination of testing.Short()
, which is set to true
when -short
is used, and t.Skip()
, as illustrated below:
func TestnumberSkiped(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode.")
}
result := number(5)
if result != "numb" {
t.Errorf("Result was incorrect, got: %s, want: %s.", result, "numb")
}
}
This test will be executed if you run go test -v
, but will be skipped if you run go test -v -test.short
.
Test Teardown and Cleanup
The Cleanup()
method is convenient for managing test tear down. At first glance, it may not be evident why you would need that function when you can use the defer
keyword.
Using the defer
solution looks like this:
func Test_With_Cleanup(t *testing.T) {
// Some test code
defer cleanup()
// More test code
}
While this is simple enough, the main argument against the defer
approach is that it can make test logic more complicated to set up, and can clutter the test function when many components are involved.
The Cleanup()
function is executed at the end of each test (including subtests), and makes it clear to anyone reading the test what the intended behavior is:
func Test_With_Cleanup(t *testing.T) {
// Some test code here
t.Cleanup(func() {
// cleanup logic
})
// more test code here
}
You can read more about the tests clean up with examples in this article.
At that point, it is worth mentioning the Helper()
method. This method exists to improve the logs when a test fails. In the logs, the line number of the helper function is ignored and only the line number of the failing test is reported, which helps figure out which test failed.
func helper(t *testing.T) {
t.Helper()
// do something
}
func Test_With_Cleanup(t *testing.T) {
// Some test code here
helper(t)
// more test code here
}
Finally, TempDir()
is a method that automatically creates a temporary directory for your test and deletes the folder when the test is completed, removing the need to write additional cleanup logic.
func TestnumberrTempDir(t *testing.T) {
tmpDir := t.TempDir()
// your tests
}
Hope you get to learn some from this blog for which you came here 😄
Soon, I will be releasing blogs on various other testing topics as well like writing coverage tests, fuzz tests, benchmarks tests and various other topics as well as defining the importance and the functionalities of Go.�
�
If you like my Article then please react to it and connect with me on Twitter if you are also a tech enthusiast. I would love to collaborate with people and share the experience of tech😄😄.
My Twitter Profile: