Testing in Go: Intermediate Tips and Techniques
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:
- Basic familiarity with the Go programming language.
- A recent version of Go installed on your local machine.
- Familiarity with basic unit testing concepts in Go.
- A recent version of Docker installed on your system.
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:
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 thePrettyPrintJSON()
function handles errors.
{
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.
{
"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
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
=== 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:
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 thego 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
=== 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:
=== 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:
"{\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.
. . .
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
=== 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:
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:
=== 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:
- Creating a new database
- Creating the tables in the database
- 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:
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:
---
- 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:
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
);
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:
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 thesetupDatabase()
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:
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:
=== 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
=== 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
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
β 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
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:
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:
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:
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:
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!
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 usBuild 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.comor submit a pull request and help us build better products for everyone.
See the full list of amazing projects on github