Testing in Go: Increasing efficiency of Code

Testing in Go: Increasing efficiency of Code

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:--

  1. 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.

  2. 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-based for loop. It iterates over each element in the tests slice, where each element is represented by the variable pair. 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 use t.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:

Aryan_2407

Did you find this article valuable?

Support Aryan Parashar by becoming a sponsor. Any amount is appreciated!