Back to Testing guides

Testing in Go: Intermediate Tips and Techniques

Lanre Adelowo
Updated on May 3, 2024

In the previous article, I introduced the basics of testing in Go, covering the standard library's testing capabilities, how to run tests and interpret results, and how to generate and view code coverage reports.

While those techniques are a great starting point, real-world code often demands more sophisticated testing strategies. You might face challenges like slow execution, managing dependencies, and making test results easily understandable.

In this article, we'll dive into intermediate Go testing techniques that address these issues by focusing on:

  • Handling dependencies and reusing code in multiple test functions,
  • Speeding up your test runs,
  • Making the test output easy to read.

Ready to improve your Go testing skills? Let's dive in!

Prerequisites

Before proceeding with this tutorial, ensure that you've met the following requirements:

Step 1 β€” Setting up the demo project

To demonstrate the various techniques I'll be introducing in this article, I've created a GitHub repository which you can clone and work with on your local machine. We'll be testing a simple function that takes a JSON string and pretty prints it to make it more human-readable.

You can clone the repo to your machine by executing:

 
git clone https://github.com/betterstack-community/intermediate-go-unit-tests

Then, navigate to the project directory and open it in your preferred text editor:

 
cd intermediate-go-unit-tests
 
code .

In the next section, we'll start by understanding test fixtures in Go.

Step 2 β€” Understanding test fixtures in Go

When writing certain tests, you may need additional data to support the test cases, and to enable consistent and repeatable testing. These are called test fixtures, and it's a standard practice to place them within a testdata directory alongside your test files.

For instance, consider a simple package designed to format JSON data. Testing this package will involve using fixtures to ensure the formatter consistently produces the correct output. These fixtures might include various files containing JSON strings formatted differently.

The fixtures package in the demo project exports a single function which formats a JSON string passed to it. The implementation of this function is straightforward:

fixtures/code.go
package fixtures

import (
    "bytes"
    "encoding/json"
)

func PrettyPrintJSON(str string) (string, error) {
    var b bytes.Buffer
    if err := json.Indent(&b, []byte(str), "", "    "); err != nil {
        return "", err
    }
    return b.String(), nil
}

The next step involves be setting up the fixtures in fixtures/testdata directory. We'll utilize two fixture files:

  • invalid.json: This contains an invalid JSON object to test how the PrettyPrintJSON() function handles errors.
fixtures/testdata/invalid.json
{
  name: "John Doe",
  age: 30,
  isEmployed: true,
  phoneNumbers: [
    123-456-7890,
    987-654-3210
  ]
}
  • valid.json: Contains a valid JSON object that is not well formatted.
fixtures/testdata/valid.json
{
  "name": "John Doe",
                   "age": 30, "isEmployed": true,
                        "phoneNumbers": [
    "123-456-7890",
    "987-654-3210"
  ]
}

Since both JSON files are already set up, let's go ahead and use them in the unit tests for the function. To do this, open up the fixtures/code_test.go file in your editor and populate it as follows:

 
code fixtures/code_test.go
fixtures/code_test.go
package fixtures

import (
    "bytes"
    "io"
    "os"
    "testing"
)

func TestPrettyPrintJSON(t *testing.T) {
    tt := []struct {
        name     string
        filePath string
        hasErr   bool
    }{
        {
            name:     "Invalid json",
            filePath: "testdata/invalid.json",
            hasErr:   true,
        },
        {
            name:     "valid json",
            filePath: "testdata/valid.json",
            hasErr:   false,
        },
    }

    for _, v := range tt {
        f, err := os.Open(v.filePath)
        if err != nil {
            t.Fatal(err)
        }

        defer func() {
            if err := f.Close(); err != nil {
                t.Fatal(err)
            }
        }()

        b := new(bytes.Buffer)
        _, err = io.Copy(b, f)
        if err != nil {
            t.Fatal(err)
        }

        _, err = PrettyPrintJSON(b.String())
        if v.hasErr {
            if err == nil {
                t.Fatal("Expected an error but got nil")
            }
            continue
        }

        if err != nil {
            t.Fatal(err)
        }
    }
}

The TestPrettyPrintJSON() function checks the normal operation and error handling behavior of the PrettyPrintJSON() function by attempting to parse both correctly formatted and malformed JSON files.

For each case in the test table (tt), the JSON file specified in filePath is opened and its contents are read into a buffer, which is then subsequently passed into the PrettyPrintJSON() function.

The outcome is then evaluated based on the hasErr field. If an error is expected, and PrettyPrintJSON does not return an error, the test fails because it indicates a failure in the function's error-handling logic. Conversely, if an error occurs when none is expected, the test also fails.

Running the test is as simple as using the Go command below:

 
go test ./... -v -run=PrettyPrintJSON
Output
=== RUN   TestPrettyPrintJSON
--- PASS: TestPrettyPrintJSON (0.00s)
PASS
ok      github.com/betterstack-community/intermediate-go-unit-tests/fixtures

With these fixtures, you can always be sure that the PrettyPrintJSON() function can parse any JSON files thrown at it and report errors when there are parsing failures.

In the next section, you will verify the format of the prettified JSON.

Step 3 β€” Working with golden files

Testing often involves asserting that the output from a function matches an expected result. This becomes challenging with complex outputs, such as long HTML strings, intricate JSON responses, or even binary data. To address this, we'll use golden files.

A golden file stores the expected output for a test, allowing future tests to assert against it. This helps with detecting unexpected changes in the output, usually a sign of a bug in the program.

In the previous section, we used test fixtures to provide raw JSON data for formatting. Now, we'll enhance our testing approach by using a golden file to ensure that the formatted output from the PrettyPrintJSON function remains consistent over time.

You can go ahead and add the highlighted content below to the code_test.go file:

fixtures/code_test.go
package fixtures

import (
    "bytes"
"encoding/json"
"io" "os" "testing"
"github.com/sebdah/goldie/v2"
)
func verifyMatch(t *testing.T, v interface{}) {
g := goldie.New(t, goldie.WithFixtureDir("./testdata/golden"))
b := new(bytes.Buffer)
err := json.NewEncoder(b).Encode(v)
if err != nil {
t.Fatal(err)
}
g.Assert(t, t.Name(), b.Bytes())
}
func TestPrettyPrintJSON(t *testing.T) { . . . for _, v := range tt { . . .
formattedJSON, err := PrettyPrintJSON(b.String())
if v.hasErr { if err == nil { t.Fatal("Expected an error but got nil") } continue } if err != nil { t.Fatal(err) }
verifyMatch(t, formattedJSON)
} }

The goldie package is a Go testing utility that does the following:

  • Automatically creates a golden file with the expected output of the function under test if it doesn't exist.
  • Asserts that the current test output matches the contents of the golden file.
  • Optionally modifies the golden file with updated data when the -update flag is used with the go test command.

Ensure to download the package with the command below before proceeding:

 
go get github.com/sebdah/goldie/v2

The verifyMatch() function uses the goldie package to assert against the formatted JSON output produced by the PrettyPrintJSON() function, but it will fail initially because there's no golden file present at the moment:

 
go test ./... -v -run=TestPrettyPrintJSON
Output
=== RUN   TestPrettyPrintJSON
    code_test.go:22: Golden fixture not found. Try running with -update flag.
--- FAIL: TestPrettyPrintJSON (0.00s)
FAIL
FAIL    github.com/betterstack-community/intermediate-go-unit-tests/fixtures    0.002s
FAIL

To fix this, you need to include the -update flag to create the golden file for this specific test:

 
go test ./... -update -v -run=TestPrettyPrintJSON

This creates the golden file in fixtures/testdata/golden/TestPrettyPrintJSON.golden, so the test passes:

Output
=== RUN   TestPrettyPrintJSON
--- PASS: TestPrettyPrintJSON (0.00s)
PASS
ok      github.com/betterstack-community/intermediate-go-unit-tests/fixtures    0.002s

Examine the contents of the golden file in your text editor:

fixtures/testdata/golden/TestPrettyPrintJSON.golden
"{\n    \"name\": \"John Doe\",\n    \"age\": 30,\n    \"isEmployed\": true,\n    \"phoneNumbers\": [\n        \"123-456-7890\",\n        \"987-654-3210\"\n    ]\n}\n"

Any time you use the use the -update flag, the contents of the golden file for the corresponding test will be created or updated in the testdata/golden directory as shown above.

Before committing your changes, ensure that the contents of the file meets your expectations as that is what future test runs (without using -update) will be compared against.

It's also important to point out a few things:

  • Only use the -update flag locally. Your CI server should not be using the -update flag.
  • Always commit the golden files to your repository to make them available to your teammates and in your CI/CD pipelines.
  • Never use the -update flag unless you want to update the expected output of the function under test.

With that said, let's now move on to the next section where you'll learn about test helpers in Go.

Step 4 β€” Using test helpers

Just like production code, test code should be maintainable and readable. A hallmark of well-crafted code is its modular structure, achieved by breaking down complex tasks into smaller, manageable functions. This principle holds true in test environments as well, where these smaller, purpose-specific functions are known as test helpers.

Test helpers not only streamline code by abstracting repetitive tasks but also enhance re-usability. For instance, if several tests require the same object configuration or database connection setup, it's inefficient and error-prone to duplicate this setup code across multiple tests.

To illustrate the benefit of test helpers, let's update the verifyMatch() function introduced earlier. To designate a function as a test helper in Go, use t.Helper(). This call is best placed at the beginning of the function to ensure that any errors are reported in the context of the test that invoked the helper, rather than within the helper function itself.

fixtures/code_test.go
. . .
func verifyMatch(t *testing.T, v interface{}) {
t.Helper()
g := goldie.New(t, goldie.WithFixtureDir("./testdata/golden")) b := new(bytes.Buffer) err := json.NewEncoder(b).Encode(v) if err != nil { t.Fatal(err) } g.Assert(t, t.Name(), b.Bytes()) } . . .

Debugging can become more challenging without marking the function with t.Helper(). When a test fails, Go's testing framework will report the error location within the helper function itself, not at the point where the helper was called. This can obscure which test case failed, especially when multiple test functions use the same helper.

To demonstrate this, remove the t.Helper() line you just added above, then delete the entire golden directory within fixtures/testdata like this:

 
rm -r fixtures/testdata/golden

When you execute the tests now, it should fail once again with the following error:

 
go test ./... -v -run=TestPrettyPrintJSON
Output
=== RUN   TestPrettyPrintJSON
    code_test.go:22: Golden fixture not found. Try running with -update flag.
--- FAIL: TestPrettyPrintJSON (0.00s)
FAIL
FAIL    github.com/betterstack-community/intermediate-go-unit-tests/fixtures    0.002s
FAIL

The failure is reported to have occurred on line 22 of the code_test.go file, which is the highlighted line below:

fixtures/code_test.go
func verifyMatch(t *testing.T, v interface{}) {
    g := goldie.New(t, goldie.WithFixtureDir("./testdata/golden"))

    b := new(bytes.Buffer)

    err := json.NewEncoder(b).Encode(v)
    if err != nil {
        t.Fatal(err)
    }
g.Assert(t, t.Name(), b.Bytes())
}

However, when you add the t.Helper() line back in, you get the same failure but the reported line is different. Now it says code_test:74 which directly points to the invoking test:

Output
=== RUN   TestPrettyPrintJSON
    code_test.go:75: Golden fixture not found. Try running with -update flag.
--- FAIL: TestPrettyPrintJSON (0.00s)
FAIL
FAIL    github.com/betterstack-community/intermediate-go-unit-tests/fixtures    0.002s
FAIL

Ensure to fix the test failure with the -update flag once again as demonstrated in Step 3 above before proceeding to the next section.

Step 5 β€” Setting up and tearing down test cases

Testing often involves initializing resources or configuring dependencies before executing the tests. This setup could range from creating databases and tables to seeding data, especially when testing database interactions like with a PostgreSQL database.

Implementing setup and teardown routines is essential to streamline this process and avoid repetition across tests. For example, if you want to test your PostgreSQL database implementation, several preparatory steps are necessary such as:

  1. Creating a new database
  2. Creating the tables in the database
  3. Optionally, add data to the tables

While the steps above can be easily achieved, it becomes a big pile of repetition when you have to write multiple tests that have to do each step repeatedly. This is where implementing setup and teardown logic makes sense.

To demonstrate this, we'll implement a CRUD system where you can fetch a user and add a new user to the database. To do this, you need to create a few new directories:

  • postgres: Contains the CRUD application code interacting with the PostgreSQL database.
  • postgres/testdata/migrations: Stores the SQL files for setting up database tables and indexes.
  • postgres/testdata/fixtures: Contains sample data to preload into the database.
 
mkdir -p postgres postgres/migrations postgres/testdata/fixtures

Go ahead and create the necessary files in the postgres directory:

 
touch postgres/user.go
 
touch postgres/user_test.go

Open the user.go file, and enter the following code:

postgres/user.go
package postgres

import (
    "context"
    "database/sql"

    "github.com/google/uuid"
    _ "github.com/lib/pq"
)

type User struct {
    ID       uuid.UUID
    Email    string
    FullName string
}

type userRepo struct {
    inner *sql.DB
}

func NewUserRepository(db *sql.DB) *userRepo {
    return &userRepo{inner: db}
}

func (u *userRepo) Get(ctx context.Context, email string) (*User, error) {
    sqlStatement := `SELECT id, email, full_name FROM users WHERE email=$1;`
    user := new(User)
    row := u.inner.QueryRow(sqlStatement, email)
    return user, row.Scan(&user.ID, &user.Email, &user.FullName)
}

func (u *userRepo) Create(ctx context.Context, user *User) error {
    sqlStatement := `INSERT INTO users (email, full_name) VALUES ($1, $2)`
    _, err := u.inner.Exec(sqlStatement, user.Email, user.FullName)
    return err
}

In the above code, there are two main functions:

  • Get(): This method retrieves a user from the database through their email address.
  • Create(): This method creates a new user in the database.

Before we can write the corresponding tests, let's create the migration files that will contain the logic to set up the database tables and also make sense of the data we want to load the database with.

To do that, you need to create a few more files through the commands below:

 
touch postgres/testdata/fixtures/users.yml
 
touch postgres/migrations/001_create_users_tables.up.sql
 
touch postgres/migrations/001_create_users_tables.down.sql

In the fixtures/users.yml file, add a list of a few sample users to populate the database:

postgres/testdata/fixtures/users.yml
---
- id: b35ac310-9fa2-40e1-be39-553b07d6235b
  email: john.doe@gmail.com
  full_name: John Doe
  created_at: '2024-01-20 14:26:13.237292+00'
  updated_at: '2024-01-20 14:26:13.237292+00'
  deleted_at:
- id: df1f03c9-1831-442a-9035-0f77bc413ec1
  email: linus@torvalds.com
  full_name: Linus Torvalds
  created_at: '2024-01-20 14:25:28.301043+00'
  updated_at: '2024-01-20 14:25:28.301043+00'
  deleted_at:

Next, create the SQL migration for the users table like this:

postgres/migrations/001_create_users_tables.up.sql
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

CREATE TABLE IF NOT EXISTS users(
    id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
    email VARCHAR (100) UNIQUE NOT NULL,
    full_name VARCHAR (100) NOT NULL,

    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP WITH TIME ZONE
);
postgres/migrations/001_create_users_tables.down.sql
DROP TABLE users;

We now have both our migrations and sample data ready. The next step is to implement the setup function which will be called for each test function. We have two methods in the postgres/user.go file so this ideally means we will write two tests. Having a setup function means we can easily reuse the setup logic for both tests.

To get started with creating a setup function, enter the following code in the postgres/user_test.go file:

postgres/user_test.go
package postgres

import (
    "context"
    "database/sql"
    "fmt"
    "testing"

    testfixtures "github.com/go-testfixtures/testfixtures/v3"
    "github.com/golang-migrate/migrate/v4"
    "github.com/golang-migrate/migrate/v4/database/postgres"
    _ "github.com/golang-migrate/migrate/v4/source/file"
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/wait"
)

func prepareTestDatabase(t *testing.T, dsn string) {
    t.Helper()

    var err error

    db, err := sql.Open("postgres", dsn)
    if err != nil {
        t.Fatal(err)
    }

    err = db.Ping()
    if err != nil {
        t.Fatal(err)
    }

    driver, err := postgres.WithInstance(db, &postgres.Config{})
    if err != nil {
        t.Fatal(err)
    }

    migrator, err := migrate.NewWithDatabaseInstance(
        fmt.Sprintf("file://%s", "migrations"), "postgres", driver)
    if err != nil {
        t.Fatal(err)
    }

    if err := migrator.Up(); err != nil && err != migrate.ErrNoChange {
        t.Fatal(err)
    }

    fixtures, err := testfixtures.New(
        testfixtures.Database(db),
        testfixtures.Dialect("postgres"),
        testfixtures.Directory("testdata/fixtures"),
    )
    if err != nil {
        t.Fatal(err)
    }

    err = fixtures.Load()
    if err != nil {
        t.Fatal(err)
    }
}

// setupDatabase spins up a new Postgres container and returns a closure
// please always make sure to call the closure as it is the teardown function
func setupDatabase(t *testing.T) (*sql.DB, func()) {
    t.Helper()

    var dsn string

    containerReq := testcontainers.ContainerRequest{
        Image:        "postgres:latest",
        ExposedPorts: []string{"5432/tcp"},
        WaitingFor:   wait.ForListeningPort("5432/tcp"),
        Env: map[string]string{
            "POSTGRES_DB":       "betterstacktest",
            "POSTGRES_PASSWORD": "betterstack",
            "POSTGRES_USER":     "betterstack",
        },
    }

    dbContainer, err := testcontainers.GenericContainer(
        context.Background(),
        testcontainers.GenericContainerRequest{
            ContainerRequest: containerReq,
            Started:          true,
        })
    if err != nil {
        t.Fatal(err)
    }

    port, err := dbContainer.MappedPort(context.Background(), "5432")
    if err != nil {
        t.Fatal(err)
    }

    dsn = fmt.Sprintf(
        "postgres://%s:%s@%s/%s?sslmode=disable",
        "betterstack",
        "betterstack",
        fmt.Sprintf("localhost:%s", port.Port()),
        "betterstacktest",
    )

    prepareTestDatabase(t, dsn)

    db, err := sql.Open("postgres", dsn)
    if err != nil {
        t.Fatal(err)
    }

    err = db.Ping()
    if err != nil {
        t.Fatal(err)
    }

    return db, func() {
        err := dbContainer.Terminate(context.Background())
        if err != nil {
            t.Fatal(err)
        }
    }
}

The above code defines a setup for testing with a PostgreSQL database in Go, using the testcontainers-go library to create a real database environment in Docker containers. We have the following two functions:

  • setupDatabase(): Acts as the main setup function that initializes a new PostgreSQL container, sets up the database, loads sample data, and returns a closure for tearing down the environment. This closure should be invoked at the completion of each test to properly clean up and shut down the database container.

  • prepareTestDatabase(): Serves as a helper function to keep the setupDatabase() function concise. It is responsible for seeding the database with sample data using the testfixtures and golang-migrate packages.

Ensure to download all the third-party packages used in the file by running:

 
go mod tidy

Putting this together, an example of how to use the above code would be:

 
func TestXxx(t *testing.T) {
  client, teardownFunc := setupDatabase(t)

  defer teardownFunc()

  . . .
}

The next step is to write the tests to validate the CRUD logic you previously wrote. To do this, update the user_test.go file with the following contents:

postgres/user_test.go
package postgres

import (
    "context"
    "database/sql"
"errors"
"fmt"
"strings"
"testing" testfixtures "github.com/go-testfixtures/testfixtures/v3" "github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4/database/postgres" _ "github.com/golang-migrate/migrate/v4/source/file" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" ) . . . func TestUserRepository_Create(t *testing.T) { client, teardownFunc := setupDatabase(t) defer teardownFunc() userDB := NewUserRepository(client) err := userDB.Create(context.Background(), &User{ Email: "ken@unix.org", FullName: "Ken Thompson", }) if err != nil { t.Fatal(err) } } func TestUserRepository_Get(t *testing.T) { client, teardownFunc := setupDatabase(t) defer teardownFunc() userDB := NewUserRepository(client) // take a look at testdata/fixtures/users.yml // this email exists there so we must be able to fetch it _, err := userDB.Get(context.Background(), "john.doe@gmail.com") if err != nil { t.Fatal(err) } email := "test@test.com" firstName := "Ken Thompson" // email does not exist here _, err = userDB.Get(context.Background(), email) if err == nil { t.Fatal(errors.New("expected an error here. Email should not be found")) } if !errors.Is(err, sql.ErrNoRows) { t.Fatalf("Unexpected database error. Expected %v got %v", sql.ErrNoRows, err) } err = userDB.Create(context.Background(), &User{ Email: email, FullName: firstName, }) if err != nil { t.Fatal(err) } // fetch the same email again user, err := userDB.Get(context.Background(), email) if err != nil { t.Fatal(err) } if !strings.EqualFold(email, user.Email) { t.Fatalf("retrieved values do not match. Expected %s, got %s", email, user.Email) } if !strings.EqualFold(firstName, user.FullName) { t.Fatalf("retrieved values do not match. Expected %s, got %s", firstName, user.FullName) } }

You defined the following test cases in the file above:

  • TestUserRepository_Create(): This test case handles the straightforward task of inserting a new user into the database.
  • TestUserRepository_Get(): This test case checks the functionality of retrieving a user from the database. It also tests the retrieval of a non-existent user, followed by the creation of that user and a subsequent retrieval attempt to confirm the operation's success.

In both cases, the setupDatabase() function is called first, and the teardown() function is deferred so that each test runs with a clean slate.

Our test suite for the postgres package is now complete so you can go ahead to run them with the following command:

 
go test ./... -v -run=TestUser

They should all pass successfully:

Output
=== RUN   TestUserRepository_Create
2024/01/22 19:31:21 github.com/testcontainers/testcontainers-go - Connected to docker:
  Server Version: 24.0.6
  API Version: 1.43
  Operating System: Docker Desktop
  Total Memory: 7844 MB
  Resolved Docker Host: unix:///var/run/docker.sock
  Resolved Docker Socket Path: /var/run/docker.sock
  Test SessionID: fd45df1e4f42f1f730acac7e01c3077b80d36e0875f33ecaeb03ed1ed0128f29
  Test ProcessID: e23dc38a-3263-462c-aaeb-110fcbb52f60
2024/01/22 19:31:21 🐳 Creating container for image testcontainers/ryuk:0.6.0
2024/01/22 19:31:21 βœ… Container created: ff74fcae2a70
2024/01/22 19:31:21 🐳 Starting container: ff74fcae2a70
2024/01/22 19:31:21 βœ… Container started: ff74fcae2a70
2024/01/22 19:31:21 🚧 Waiting for container id ff74fcae2a70 image: testcontainers/ryuk:0.6.0. Waiting for: &{Port:8080/tcp timeout:<nil> PollInterval:100ms}
2024/01/22 19:31:21 🐳 Creating container for image postgres:latest
2024/01/22 19:31:21 βœ… Container created: 72d02ad6eb2f
2024/01/22 19:31:21 🐳 Starting container: 72d02ad6eb2f
2024/01/22 19:31:22 βœ… Container started: 72d02ad6eb2f
2024/01/22 19:31:22 🚧 Waiting for container id 72d02ad6eb2f image: postgres:latest. Waiting for: &{Port:5432/tcp timeout:<nil> PollInterval:100ms}
2024/01/22 19:31:23 🐳 Terminating container: 72d02ad6eb2f
2024/01/22 19:31:24 🚫 Container terminated: 72d02ad6eb2f
--- PASS: TestUserRepository_Create (2.73s)
=== RUN   TestUserRepository_Get
2024/01/22 19:31:24 🐳 Creating container for image postgres:latest
2024/01/22 19:31:24 βœ… Container created: 57cfc1711ba1
2024/01/22 19:31:24 🐳 Starting container: 57cfc1711ba1
2024/01/22 19:31:24 βœ… Container started: 57cfc1711ba1
2024/01/22 19:31:24 🚧 Waiting for container id 57cfc1711ba1 image: postgres:latest. Waiting for: &{Port:5432/tcp timeout:<nil> PollInterval:100ms}
2024/01/22 19:31:25 🐳 Terminating container: 57cfc1711ba1
2024/01/22 19:31:25 🚫 Container terminated: 57cfc1711ba1
--- PASS: TestRepository_Get (1.36s)
PASS
ok      github.com/betterstack-community/intermediate-go-unit-tests/postgres    23.034s

Step 6 β€” Running Go tests in parallel

Go tests are executed serially by default, meaning that each test runs only after the previous one has completed. This approach is manageable with few tests, but as your suite grows, the total execution time can become significant.

The end goal is to have a lot of tests, run them, and be confident they all pass but not at the expense of the developer's time waiting for them to pass or fail. To accelerate the testing process, Go can execute tests in parallel.

Here are a few benefits of running tests in parallel:

  • Increased speed: Parallel testing can significantly reduce waiting time for test results.
  • Detection of flaky tests: Flaky tests are those that produce inconsistent results, often due to dependencies on external states or interactions with shared resources. Running tests in parallel helps identify these issues early by isolating tests from shared states.

You can enable parallel test execution in Go using the following methods:

  • From the command line: When running the go test command, you can use the -parallel flag to enable parallel test execution. This flag accepts a number indicating the maximum number of tests to run simultaneously, defaulting to the number of CPUs available on the machine.
 
go test ./.. -v -parallel 2
Output
=== RUN   TestPrettyPrintJSON
--- PASS: TestPrettyPrintJSON (0.00s)
PASS
ok      github.com/betterstack-community/intermediate-go-unit-tests/fixtures    0.002s
2024/01/22 19:43:04 github.com/testcontainers/testcontainers-go - Connected to docker:
  Server Version: 24.0.6
  API Version: 1.43
  Operating System: Docker Desktop
  Total Memory: 7844 MB
  Resolved Docker Host: unix:///var/run/docker.sock
  Resolved Docker Socket Path: /var/run/docker.sock
  Test SessionID: bf8be00dd11df5712f7cde8e63c4e98679d6956b2943323f346aa0d3ca43764b
  Test ProcessID: 3dca0955-e549-4406-b3da-68a9c079267a
2024/01/22 19:43:04 🐳 Creating container for image testcontainers/ryuk:0.6.0
2024/01/22 19:43:04 βœ… Container created: bb751f45f9eb
2024/01/22 19:43:04 🐳 Starting container: bb751f45f9eb
2024/01/22 19:43:04 βœ… Container started: bb751f45f9eb
2024/01/22 19:43:04 🚧 Waiting for container id bb751f45f9eb image: testcontainers/ryuk:0.6.0. Waiting for: &{Port:8080/tcp timeout:<nil> PollInterval:100ms}
2024/01/22 19:43:04 🐳 Creating container for image postgres:latest
2024/01/22 19:43:04 🐳 Creating container for image postgres:latest
2024/01/22 19:43:04 βœ… Container created: 24ef76a672f3
2024/01/22 19:43:04 🐳 Starting container: 24ef76a672f3
2024/01/22 19:43:04 βœ… Container created: c1c43a4d4d14
2024/01/22 19:43:04 🐳 Starting container: c1c43a4d4d14
2024/01/22 19:43:04 βœ… Container started: 24ef76a672f3
2024/01/22 19:43:04 🚧 Waiting for container id 24ef76a672f3 image: postgres:latest. Waiting for: &{Port:5432/tcp timeout:<nil> PollInterval:100ms}
2024/01/22 19:43:04 βœ… Container started: c1c43a4d4d14
2024/01/22 19:43:04 🚧 Waiting for container id c1c43a4d4d14 image: postgres:latest. Waiting for: &{Port:5432/tcp timeout:<nil> PollInterval:100ms}
2024/01/22 19:43:06 🐳 Terminating container: c1c43a4d4d14
2024/01/22 19:43:06 🐳 Terminating container: 24ef76a672f3
2024/01/22 19:43:06 🚫 Container terminated: c1c43a4d4d14
--- PASS: TestUserRepository_Create (2.21s)
2024/01/22 19:43:06 🚫 Container terminated: 24ef76a672f3
--- PASS: TestUserRepository_Get (2.34s)
PASS
ok      github.com/betterstack-community/intermediate-go-unit-tests/postgres    22.331s
  • Within test code: Invoking the t.Parallel() method in your test function instructs the test runner to run the test in parallel with others.
 
func TestUserRepository_Get(t *testing.T) {
    t.Parallel() // instructs `go test` to run this test in parallel
    . . .
}

Step 7 β€” Improving go test output

While Go's test runner produces output that can be easily read and understood, there are ways to make it much more readable. For example, using colors to denote failed and passed tests, getting a detailed summary of all executed tests among others.

To demonstrate this, we will be using a project called gotestsum, but there are others like gotestfmt you can explore as well. To install this package, you need to run the following command:

 
go install gotest.tools/gotestsum@latest

The gotestsum package includes a few different ways to format the output of the executed tests. The first one is testdox. This can be used by running the following command:

 
gotestsum --format testdox
Output

ayinke-llc/betterstack-articles/advanced-unittest/configuration:

ayinke-llc/betterstack-articles/advanced-unittest/fixtures:
 βœ“ Pretty print JSON (0.00s)

ayinke-llc/betterstack-articles/advanced-unittest/postgres:
 βœ“ UserRepository create (3.12s)
 βœ“ UserRepository get (1.49s)


DONE 3 tests in 0.503s

Another popular option is to list the packages that have been tested. This can be used by running the following command:

 
gotestsum --format pkgname
Output
βœ“  fixtures (765ms)
βœ“  postgres (3.638s)

DONE 3 tests in 5.487s

An added advantage of using gotestsum is that it can automatically rerun tests upon any changes to Go files in the project through --watch flag:

 
gotestsum --watch --format testname
Output
Watching 2 directories. Use Ctrl-c to to stop a run or exit.

Step 8 β€” Understanding Blackbox and Whitebox testing

Testing in Go is generally a straightforward process: invoke a function or system, provide inputs, and verify the outputs. However, there are two primary approaches to this process: Whitebox testing and Blackbox testing.

1. Whitebox testing

Throughout this tutorial, we've primarily engaged in Whitebox testing. This approach involves accessing and inspecting the internal implementations of the functions under test by placing the test file in the same package as the code under test.

For example, if you have a package calc with the following code:

calc/calc.go
package calc

// Add adds two integers and returns the sum
func Add(x, y int) int {
  return x + y
}

The test for the Add() method will be in the same package like this:

calc/calc_test.go
package calc

import (
    "testing"
)

func TestAdd(t *testing.T) {
    tests := []struct {
        a, b int
        want int
    }{
        {1, 2, 3},
        {5, -3, 2},
        {0, 0, 0},
    }

    for _, tt := range tests {
        t.Run("", func(t *testing.T) {
            got := Add(tt.a, tt.b) // You can access any public or private function defined in the `calc` package directly
            if got != tt.want {
                t.Errorf(
                    "Add(%d, %d) want %d, got %d",
                    tt.a,
                    tt.b,
                    tt.want,
                    got,
                )
            }
        })
    }
}

Since Whitebox testing allows you to access internal state, you can often catch certain bugs by asserting against the internal state of the function under test.

Its main disadvantage is that such tests can be more brittle since they are coupled to the program's internal structure. For example, if you change the algorithm used to compute some result, the test can break even if the final output is exactly the same.

2. Blackbox testing

Blackbox testing involves testing a software system without any knowledge of the application's internal workings. The test does not assert against the underlying logic of the function but merely checks if the software behaves as expected from an external viewpoint.

To implement Blackbox testing in Go, place your tests in an inner package by appending _test to the package name, which effectively restricts access to internal-only states and functions.

With the calc example, the test will be placed in a calc/calc_test directory like this:

calc/calc_test/calc_test.go
package calc_test

import (
    "github.com/betterstack-community/intermediate-go-unit-tests/calc"
    "testing"
)

func TestAdd(t *testing.T) {
    tests := []struct {
        a, b int
        want int
    }{
        {1, 2, 3},
        {5, -3, 2},
        {0, 0, 0},
    }

    for _, tt := range tests {
        t.Run("", func(t *testing.T) {
            got := calc.Add(tt.a, tt.b) // You can only access public functions that are exported from the `calc` package
            if got != tt.want {
                t.Errorf(
                    "Add(%d, %d) want %d, got %d",
                    tt.a,
                    tt.b,
                    tt.want,
                    got,
                )
            }
        })
    }
}

This method of testing prevents you from being able to access the internal state of the calc package, thus allowing you to focus on ensuring that the function being tested produces the correct output.

If you're practicing Blackbox testing, and you also need to test implementation details, a common pattern is to create an _internal_test.go within the package under test:

calc/calc_internal_test.go
package calc

// Test implementation details separately here

Final thoughts

As your test suite expands, the complexity can also increase. However, by applying the patterns and techniques discussed in this article, you can keep your tests organized and manageable.

Thanks for reading, and happy testing!

Author's avatar
Article by
Lanre Adelowo
Lanre is a senior Go developer with 7+ years of experience building systems, APIs and deploying at scale. His expertise lies between Go, Javascript, Kubernetes and automated testing. In his free time, he enjoy writing technical articles or reading Hacker news and Reddit.
Got an article suggestion? Let us know
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.

Make your mark

Join the writer's program

Are you a developer and love writing and sharing your knowledge with the world? Join our guest writing program and get paid for writing amazing technical guides. We'll get them to the right readers that will appreciate them.

Write for us
Writer of the month
Marin Bezhanov
Marin is a software engineer and architect with a broad range of experience working...
Build on top of Better Stack

Write a script, app or project on top of Better Stack and share it with the world. Make a public repository and share it with us at our email.

community@betterstack.com

or submit a pull request and help us build better products for everyone.

See the full list of amazing projects on github