4

Using test helpers in Go

 7 months ago
source link: https://eltonminetto.dev/en/post/2024-02-15-using-test-helpers/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Using test helpers in Go

February 15, 2024 - Estimated reading time: 5 minutes  -  Translations: Pt

Recently, in a code review, the great Cassio Botaro gave me a handy tip: refactor some tests to use the test helpers feature from the testing package.

The code became much more readable, so I refactored some examples I had written for a post about automated testing to demonstrate the before and after.

Let’s first look at the original version of the test, in this case, an end-to-end, using testcontainers.

package echo_test

import (
	"context"
	"database/sql"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/eminetto/post-tests-go/internal/http/echo"
	"github.com/eminetto/post-tests-go/person"
	"github.com/eminetto/post-tests-go/person/mysql"
	_ "github.com/go-sql-driver/mysql"
	"github.com/stretchr/testify/assert"
)

func TestGetUserE2E(t *testing.T) {
	ctx := context.Background()
	container, err := person.SetupMysqL(ctx)
	if err != nil {
		t.Fatal(err)
	}
	defer container.Terminate(ctx)
	db, err := sql.Open("mysql", container.URI)
	if err != nil {
		t.Error(err)
	}
	defer db.Close()
	err = person.InitMySQL(ctx, db)
	if err != nil {
		t.Fatal(err)
	}
	
	repo := mysql.NewMySQL(db)
	service := person.NewService(repo)
	_, err = service.Create("Ronnie", "Dio")
	assert.Nil(t, err)

	req, _ := http.NewRequest("GET", "/", nil)
	rec := httptest.NewRecorder()
	c := echo.Handlers(nil, nil, nil).NewContext(req, rec)
	c.SetPath("/hello/:lastname")
	c.SetParamNames("lastname")
	c.SetParamValues("dio")
	h := echo.GetUser(service)

	err = h(c)
	assert.Nil(t, err)
	assert.Equal(t, http.StatusOK, rec.Code)
	assert.Equal(t, "Hello Ronnie Dio", rec.Body.String())
}

The points we will change are:

ctx := context.Background()
container, err := person.SetupMysqL(ctx)
if err != nil {
	t.Fatal(err)
}
defer container.Terminate(ctx)
err = person.InitMySQL(ctx, db)
if err != nil {
	t.Fatal(err)
}

Let’s transform the functions person.SetupMysqL(ctx) and person.InitMySQL(ctx, db) into test helpers.

Their original code is:

func SetupMysqL(ctx context.Context) (*MysqlDBContainer, error) {
	req := testcontainers.ContainerRequest{
		Image:        "mariadb:11.3.1-rc-jammy",
		ExposedPorts: []string{"3306/tcp"},
		WaitingFor:   wait.ForLog("Version: '11.3.1-MariaDB-1:11.3.1+maria~ubu2204'  socket: '/run/mysqld/mysqld.sock'  port: 3306  mariadb.org binary distribution"),
		Env: map[string]string{
			"MARIADB_USER":          dbUser,
			"MARIADB_PASSWORD":      dbPassword,
			"MARIADB_ROOT_PASSWORD": dbRootPassword,
			"MARIADB_DATABASE":      database,
		},
	}
	container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: req,
		Started:          true,
	})
	if err != nil {
		return nil, err
	}
	mappedPort, err := container.MappedPort(ctx, "3306")
	if err != nil {
		return nil, err
	}

	hostIP, err := container.Host(ctx)
	if err != nil {
		return nil, err
	}
	uri := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true", "root", dbRootPassword, hostIP, mappedPort.Port(), database)

	return &MysqlDBContainer{Container: container, URI: uri}, nil
}
func InitMySQL(ctx context.Context, db *sql.DB) error {
	query := []string{
		fmt.Sprintf("use %s;", database),
		"create table if not exists person (id int AUTO_INCREMENT,first_name varchar(100), last_name varchar(100), created_at datetime, updated_at datetime, PRIMARY KEY (`id`)) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=latin1;",
	}
	for _, q := range query {
		_, err := db.ExecContext(ctx, q)
		if err != nil {
			return err
		}
	}

	return nil
}

To transform them into test helpers we must pass a variable that implements the interface testing.TB as the first parameter:

// TB is the interface common to T, B, and F.
type TB interface {
	Cleanup(func())
	Error(args ...any)
	Errorf(format string, args ...any)
	Fail()
	FailNow()
	Failed() bool
	Fatal(args ...any)
	Fatalf(format string, args ...any)
	Helper()
	Log(args ...any)
	Logf(format string, args ...any)
	Name() string
	Setenv(key, value string)
	Skip(args ...any)
	SkipNow()
	Skipf(format string, args ...any)
	Skipped() bool
	TempDir() string

	// A private method to prevent users implementing the
	// interface and so future additions to it will not
	// violate Go 1 compatibility.
	private()
}

As the comment at the beginning of the code points out, testing.T and testing.B both implement this interface, so we shouldn’t have any problems refactoring.

The function SetupMysqL looked like this:

func SetupMysqL(t testing.TB) *MysqlDBContainer {
	t.Helper()
	ctx := context.TODO()
	req := testcontainers.ContainerRequest{
		Image:        "mariadb:11.3.1-rc-jammy",
		ExposedPorts: []string{"3306/tcp"},
		WaitingFor:   wait.ForLog("Version: '11.3.1-MariaDB-1:11.3.1+maria~ubu2204'  socket: '/run/mysqld/mysqld.sock'  port: 3306  mariadb.org binary distribution"),
		Env: map[string]string{
			"MARIADB_USER":          dbUser,
			"MARIADB_PASSWORD":      dbPassword,
			"MARIADB_ROOT_PASSWORD": dbRootPassword,
			"MARIADB_DATABASE":      database,
		},
	}
	container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: req,
		Started:          true,
	})
	if err != nil {
		t.Errorf("error creating container %s", err.Error())
	}
	mappedPort, err := container.MappedPort(ctx, "3306")
	if err != nil {
		t.Errorf("error getting container port %s", err.Error())
	}

	hostIP, err := container.Host(ctx)
	if err != nil {
		t.Errorf("error getting container host address %s", err.Error())
	}
	uri := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true", "root", dbRootPassword, hostIP, mappedPort.Port(), database)
	t.Cleanup(func() {
		container.Terminate(ctx)
	})

	return &MysqlDBContainer{Container: container, URI: uri}
}

The main changes were:

  • The function now only receives a variable that implements testing.TB;
  • The function no longer returns an error because it now fails the test if something wrong happens;
  • We added the call to t.Helper(), which I will explain in more detail in the following paragraphs;
  • We add the call to t.Cleanup, which is executed at the end of the test, whether it is successful or failed. In this case, we are terminating the execution of the container.

The function t.Helper() affects the test results. If the test fails, let’s say in this excerpt:

container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: req,
		Started:          true,
})
if err != nil {
	t.Errorf("error creating container %s", err.Error())
}

When we include the instruction t.Helper(), the error result will be as follows:

mysql_test.go:17: error creating container Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?: failed to create container

Without the t.Helper(), the result is different, showing the error in the helper and not in the test:

test_helper.go:44: error creating container Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?: failed to create container

That way, using t.Helper() is more valuable to ease understanding of possible test failures.

Likewise, the function code InitMySQL looked like this:

func InitMySQL(t testing.TB, db *sql.DB) {
	t.Helper()
	ctx := context.TODO()
	query := []string{
		fmt.Sprintf("use %s;", database),
		"create table if not exists person (id int AUTO_INCREMENT,first_name varchar(100), last_name varchar(100), created_at datetime, updated_at datetime, PRIMARY KEY (`id`)) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=latin1;",
	}
	for _, q := range query {
		_, err := db.ExecContext(ctx, q)
		if err != nil {
			t.Errorf("error executing create query %s", err.Error())
		}
	}
}

And the test that uses the helpers has become cleaner:

package echo_test

import (
	"database/sql"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/eminetto/post-tests-go/internal/http/echo"
	"github.com/eminetto/post-tests-go/person"
	"github.com/eminetto/post-tests-go/person/mysql"
	_ "github.com/go-sql-driver/mysql"
	"github.com/stretchr/testify/assert"
)

func TestGetUserE2E(t *testing.T) {
	container := person.SetupMysqL(t)
	db, err := sql.Open("mysql", container.URI)
	if err != nil {
		t.Error(err)
	}
	defer db.Close()
	person.InitMySQL(t, db)

	repo := mysql.NewMySQL(db)
	service := person.NewService(repo)
	_, err = service.Create("Ronnie", "Dio")
	assert.Nil(t, err)

	req, _ := http.NewRequest("GET", "/", nil)
	rec := httptest.NewRecorder()
	c := echo.Handlers(nil, nil, nil).NewContext(req, rec)
	c.SetPath("/hello/:lastname")
	c.SetParamNames("lastname")
	c.SetParamValues("dio")
	h := echo.GetUser(service)

	err = h(c)
	assert.Nil(t, err)
	assert.Equal(t, http.StatusOK, rec.Code)
	assert.Equal(t, "Hello Ronnie Dio", rec.Body.String())
}

This refactoring made the tests more readable and easier to maintain. Also, now it’s easier to reuse the helpers in different scenarios.

What do you think? Did you already know this feature? Leave your experiences and tips in the comments.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK