Testing in Go: Intermediate Tips and Techniques | Better Stack Community (2024)

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

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

In this article, we'll dive into intermediate Go testing techniques that addressthese 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 followingrequirements:

  • Basic familiarity with the Go programming language.
  • A recent version of Go installed on your localmachine.
  • Familiarity with basic unit testing concepts in Go.
  • A recent version of Dockerinstalled on your system.

Step 1 — Setting up the demo project

To demonstrate the various techniques I'll be introducing in this article, I'vecreated aGitHub repositorywhich you can clone and work with on your local machine. We'll be testing asimple function that takes a JSON string and pretty prints it to make it morehuman-readable.

You can clone the repo to your machine by executing:

Copied!

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

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

Copied!

cd intermediate-go-unit-tests

Copied!

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 testcases, and to enable consistent and repeatable testing. These are called testfixtures, and it's a standard practice to place them within a testdatadirectory alongside your test files.

For instance, consider a simple package designed to format JSON data. Testingthis package will involve using fixtures to ensure the formatter consistentlyproduces the correct output. These fixtures might include various filescontaining JSON strings formatted differently.

The fixtures package in the demo project exports a single function whichformats a JSON string passed to it. The implementation of this function isstraightforward:

fixtures/code.go

Copied!

package fixturesimport ( "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/testdatadirectory. We'll utilize two fixture files:

  • invalid.json: This contains an invalid JSON object to test how thePrettyPrintJSON() function handles errors.

fixtures/testdata/invalid.json

Copied!

{ 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

Copied!

{ "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 theunit tests for the function. To do this, open up the fixtures/code_test.gofile in your editor and populate it as follows:

Copied!

code fixtures/code_test.go

fixtures/code_test.go

Copied!

package fixturesimport ( "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 errorhandling behavior of the PrettyPrintJSON() function by attempting to parseboth correctly formatted and malformed JSON files.

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

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

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

Copied!

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

Output

=== RUN TestPrettyPrintJSON--- PASS: TestPrettyPrintJSON (0.00s)PASSok 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 areparsing 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 anexpected result. This becomes challenging with complex outputs, such as longHTML 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 toassert 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 forformatting. Now, we'll enhance our testing approach by using a golden file toensure that the formatted output from the PrettyPrintJSON function remainsconsistent over time.

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

fixtures/code_test.go

Copied!

package fixturesimport ( "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 utilitythat does the following:

  • Automatically creates a golden file with the expected output of the functionunder 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 flagis used with the go test command.

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

Copied!

go get github.com/sebdah/goldie/v2

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

Copied!

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)FAILFAIL github.com/betterstack-community/intermediate-go-unit-tests/fixtures 0.002sFAIL

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

Copied!

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

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

Output

=== RUN TestPrettyPrintJSON--- PASS: TestPrettyPrintJSON (0.00s)PASSok 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

Copied!

"{\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 forthe corresponding test will be created or updated in the testdata/goldendirectory as shown above.

Before committing your changes, ensure that the contents of the file meets yourexpectations as that is what future test runs (without using -update) will becompared 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 toyour teammates and in your CI/CD pipelines.
  • Never use the -update flag unless you want to update the expected output ofthe function under test.

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

Step 4 — Using test helpers

Just like production code, test code should be maintainable and readable. Ahallmark of well-crafted code is its modular structure, achieved by breakingdown complex tasks into smaller, manageable functions. This principle holds truein test environments as well, where these smaller, purpose-specific functionsare known as test helpers.

Test helpers not only streamline code by abstracting repetitive tasks but alsoenhance re-usability. For instance, if several tests require the same objectconfiguration or database connection setup, it's inefficient and error-prone toduplicate 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, uset.Helper(). This call is best placed at the beginning of the function toensure that any errors are reported in the context of the test that invoked thehelper, rather than within the helper function itself.

fixtures/code_test.go

Copied!

. . .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 witht.Helper(). When a test fails, Go's testing framework will report the errorlocation within the helper function itself, not at the point where the helperwas called. This can obscure which test case failed, especially when multipletest functions use the same helper.

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

Copied!

rm -r fixtures/testdata/golden

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

Copied!

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)FAILFAIL github.com/betterstack-community/intermediate-go-unit-tests/fixtures 0.002sFAIL

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

Copied!

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 failurebut the reported line is different. Now it says code_test:74 which directlypoints to the invoking test:

Output

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

Ensure to fix the test failure with the -update flag once again asdemonstrated 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 beforeexecuting the tests. This setup could range from creating databases and tablesto seeding data, especially when testing database interactions like with aPostgreSQL database.

Implementing setup and teardown routines is essential to streamline this processand avoid repetition across tests. For example, if you want to test yourPostgreSQL database implementation, several preparatory steps are necessary suchas:

  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 ofrepetition when you have to write multiple tests that have to do each steprepeatedly. This is where implementing setup and teardown logic makes sense.

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

  • postgres: Contains the CRUD application code interacting with the PostgreSQLdatabase.
  • postgres/testdata/migrations: Stores the SQL files for setting up databasetables and indexes.
  • postgres/testdata/fixtures: Contains sample data to preload into thedatabase.

Copied!

mkdir -p postgres postgres/migrations postgres/testdata/fixtures

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

Copied!

touch postgres/user.go

Copied!

touch postgres/user_test.go

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

postgres/user.go

Copied!

package postgresimport ( "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 emailaddress.
  • Create(): This method creates a new user in the database.

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

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

Copied!

touch postgres/testdata/fixtures/users.yml

Copied!

touch postgres/migrations/001_create_users_tables.up.sql

Copied!

touch postgres/migrations/001_create_users_tables.down.sql

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

postgres/testdata/fixtures/users.yml

Copied!

---- id: b35ac310-9fa2-40e1-be39-553b07d6235b email: [email protected] 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: [email protected] 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

Copied!

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

Copied!

DROP TABLE users;

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

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

postgres/user_test.go

Copied!

package postgresimport ( "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 functionfunc 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 thetestcontainers-go libraryto create a real database environment in Docker containers. We have thefollowing two functions:

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

  • prepareTestDatabase(): Serves as a helper function to keep thesetupDatabase() function concise. It is responsible for seeding the databasewith sample data using thetestfixtures andgolang-migrate packages.

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

Copied!

go mod tidy

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

Copied!

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 previouslywrote. To do this, update the user_test.go file with the following contents:

postgres/user_test.go

Copied!

package postgresimport ( "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: "[email protected]", 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(), "[email protected]") if err != nil { t.Fatal(err) } email := "[email protected]" 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 taskof inserting a new user into the database.
  • TestUserRepository_Get(): This test case checks the functionality ofretrieving a user from the database. It also tests the retrieval of anon-existent user, followed by the creation of that user and a subsequentretrieval attempt to confirm the operation's success.

In both cases, the setupDatabase() function is called first, and theteardown() 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 torun them with the following command:

Copied!

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

They should all pass successfully:

Output

=== RUN TestUserRepository_Create2024/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-110fcbb52f602024/01/22 19:31:21 🐳 Creating container for image testcontainers/ryuk:0.6.02024/01/22 19:31:21 ✅ Container created: ff74fcae2a702024/01/22 19:31:21 🐳 Starting container: ff74fcae2a702024/01/22 19:31:21 ✅ Container started: ff74fcae2a702024/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:latest2024/01/22 19:31:21 ✅ Container created: 72d02ad6eb2f2024/01/22 19:31:21 🐳 Starting container: 72d02ad6eb2f2024/01/22 19:31:22 ✅ Container started: 72d02ad6eb2f2024/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: 72d02ad6eb2f2024/01/22 19:31:24 🚫 Container terminated: 72d02ad6eb2f--- PASS: TestUserRepository_Create (2.73s)=== RUN TestUserRepository_Get2024/01/22 19:31:24 🐳 Creating container for image postgres:latest2024/01/22 19:31:24 ✅ Container created: 57cfc1711ba12024/01/22 19:31:24 🐳 Starting container: 57cfc1711ba12024/01/22 19:31:24 ✅ Container started: 57cfc1711ba12024/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: 57cfc1711ba12024/01/22 19:31:25 🚫 Container terminated: 57cfc1711ba1--- PASS: TestRepository_Get (1.36s)PASSok 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 onlyafter the previous one has completed. This approach is manageable with fewtests, 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 passbut 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 timefor test results.
  • Detection of flaky tests: Flaky tests are those that produce inconsistentresults, often due to dependencies on external states or interactions withshared resources. Running tests in parallel helps identify these issues earlyby 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 numberindicating the maximum number of tests to run simultaneously, defaulting tothe number of CPUs available on the machine.

Copied!

go test ./.. -v -parallel 2

Output

=== RUN TestPrettyPrintJSON--- PASS: TestPrettyPrintJSON (0.00s)PASSok github.com/betterstack-community/intermediate-go-unit-tests/fixtures 0.002s2024/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-68a9c079267a2024/01/22 19:43:04 🐳 Creating container for image testcontainers/ryuk:0.6.02024/01/22 19:43:04 ✅ Container created: bb751f45f9eb2024/01/22 19:43:04 🐳 Starting container: bb751f45f9eb2024/01/22 19:43:04 ✅ Container started: bb751f45f9eb2024/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:latest2024/01/22 19:43:04 🐳 Creating container for image postgres:latest2024/01/22 19:43:04 ✅ Container created: 24ef76a672f32024/01/22 19:43:04 🐳 Starting container: 24ef76a672f32024/01/22 19:43:04 ✅ Container created: c1c43a4d4d142024/01/22 19:43:04 🐳 Starting container: c1c43a4d4d142024/01/22 19:43:04 ✅ Container started: 24ef76a672f32024/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: c1c43a4d4d142024/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: c1c43a4d4d142024/01/22 19:43:06 🐳 Terminating container: 24ef76a672f32024/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)PASSok github.com/betterstack-community/intermediate-go-unit-tests/postgres 22.331s
  • Within test code: Invoking the t.Parallel() method in your test functioninstructs the test runner to run the test in parallel with others.

Copied!

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 todenote failed and passed tests, getting a detailed summary of all executed testsamong others.

To demonstrate this, we will be using a project calledgotestsum, but there are otherslike gotestfmt you can explore aswell. To install this package, you need to run the following command:

Copied!

go install gotest.tools/gotestsum@latest

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

Copied!

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 canbe used by running the following command:

Copied!

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 testsupon any changes to Go files in the project through --watch flag:

Copied!

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 orsystem, provide inputs, and verify the outputs. However, there are two primaryapproaches to this process: Whitebox testing and Blackbox testing.

1. Whitebox testing

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

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

calc/calc.go

Copied!

package calc// Add adds two integers and returns the sumfunc 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

Copied!

package calcimport ( "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 catchcertain 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 arecoupled to the program's internal structure. For example, if you change thealgorithm used to compute some result, the test can break even if the finaloutput is exactly the same.

2. Blackbox testing

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

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

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

calc/calc_test/calc_test.go

Copied!

package calc_testimport ( "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 stateof the calc package, thus allowing you to focus on ensuring that thefunction being tested produces the correct output.

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

calc/calc_internal_test.go

Copied!

package calc// Test implementation details separately here

Final thoughts

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

Thanks for reading, and happy testing!

Testing in Go: Intermediate Tips and Techniques | Better Stack Community (1)

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

Testing in Go: Intermediate Tips and Techniques | Better Stack Community (2)

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

Testing in Go: Intermediate Tips and Techniques | Better Stack Community (2024)
Top Articles
CorelDRAW Help | Cropping, splitting, and erasing objects
The Top 18 Hottest Real Estate Markets For 2023 | Quicken Loans
Foxy Roxxie Coomer
Kathleen Hixson Leaked
Tmf Saul's Investing Discussions
Hotels
Directions To Franklin Mills Mall
13 Easy Ways to Get Level 99 in Every Skill on RuneScape (F2P)
Insidious 5 Showtimes Near Cinemark Tinseltown 290 And Xd
Pj Ferry Schedule
Shaniki Hernandez Cam
Best Private Elementary Schools In Virginia
Snowflake Activity Congruent Triangles Answers
Nichole Monskey
Richmond Va Craigslist Com
Slmd Skincare Appointment
Charmeck Arrest Inquiry
Walthampatch
Kaomoji Border
Nj State Police Private Detective Unit
Unlv Mid Semester Classes
My.tcctrack
Stardew Expanded Wiki
Nearest Walgreens Or Cvs Near Me
Espn Horse Racing Results
Dr Ayad Alsaadi
Woodmont Place At Palmer Resident Portal
Craigslist Rome Ny
Busted Mugshots Paducah Ky
Korg Forums :: View topic
How Do Netspend Cards Work?
Khatrimmaza
Persona 4 Golden Taotie Fusion Calculator
Composite Function Calculator + Online Solver With Free Steps
#scandalous stars | astrognossienne
Skyrim:Elder Knowledge - The Unofficial Elder Scrolls Pages (UESP)
Is Arnold Swansinger Married
Banana Republic Rewards Login
Wal-Mart 2516 Directory
Riverton Wyoming Craigslist
11526 Lake Ave Cleveland Oh 44102
Wunderground Orlando
Weekly Math Review Q2 7 Answer Key
FREE - Divitarot.com - Tarot Denis Lapierre - Free divinatory tarot - Your divinatory tarot - Your future according to the cards! - Official website of Denis Lapierre - LIVE TAROT - Online Free Tarot cards reading - TAROT - Your free online latin tarot re
Why Are The French So Google Feud Answers
Big Reactors Best Coolant
Gas Buddy Il
Arch Aplin Iii Felony
Makemkv Key April 2023
Wvu Workday
Skyward Login Wylie Isd
Craigslist Psl
Latest Posts
Article information

Author: Pres. Lawanda Wiegand

Last Updated:

Views: 6389

Rating: 4 / 5 (71 voted)

Reviews: 94% of readers found this page helpful

Author information

Name: Pres. Lawanda Wiegand

Birthday: 1993-01-10

Address: Suite 391 6963 Ullrich Shore, Bellefort, WI 01350-7893

Phone: +6806610432415

Job: Dynamic Manufacturing Assistant

Hobby: amateur radio, Taekwondo, Wood carving, Parkour, Skateboarding, Running, Rafting

Introduction: My name is Pres. Lawanda Wiegand, I am a inquisitive, helpful, glamorous, cheerful, open, clever, innocent person who loves writing and wants to share my knowledge and understanding with you.