Integration Tests in Go

Posted on | ~7 mins

In this piece of article I’m going to go through how to write extensive integration tests for our Go applications. The purpose of this exercise is to be able to write integration tests without mocking too much dependencies but also avoid connecting to other services, ie. remote servers, remote databases, etc. Everything should exist locally on the host running the test, and the test should be able to setup testing environment without external tools.

Yet another post about integration tests?

Most of the articles discussing integration tests are either

  1. Missing key elements, like setting up a database for testing, or http requests are going to a server running in a production or dev environment.

    In case of the former, the article is assuming that we have a database setup somewhere but this was not discussed in the article or addressed in the test code either.

    The problem with the latter is that integration tests should usually run segregated from any environment and should not depend on the state of a server or the network connection of the host. If we have unstable network we cannot run our tests and couldn’t finish our work?! Or even worse, someone else also running their tests against the same database in the dev environment could conflict with our testing eventually failing the tests.

  2. Using external components to orchestrate the integration test environment. A typical example is using docker-compose to spin-up the containers where our application should connect to. This is usually done in a Makefile so the test can be executed with one command. I don’t like this approach because the test should be self-contained, and shouldn’t depend on if I ran a command before starting the tests or not. I just want go test ./... and let the test do its job.

So how will all this be different? First, we will start the containers for our dependencies from the test code using testcontainers. Our code will configure the database and populate it with the necessary test data. Second, outgoing http requests will be handled by our test server using the httptest package

Defining the problem scope

Let’s assume we have a REST API where a client can save their favorite book by it’s ISBN. Our API will query said book from another API called books to verify its validity and then store it in the database with a timestamp when it was added. Below the sequence diagram for that scenario.

CClliiePenOntStT/fa2v0o0riOtKeF<FaIavSvoBorNri>itteGesEsTAAPPIvIIa2Nl0Si0EdR?OTqK=<<IISBSBBoBNooN>ok>,kssnAoAPwPI(I)DDaattaabbaassee

For the sake of simplicity of this example, our API will return a HTTP 500 Internal Server Error if the Books API returns an error or the insertion into the database fails for any reason. Our API will return a HTTP 400 Bad Request if the the Books API returns non-200 status.

Implementing the the REST API

First, we need an http client that will call the Books API to validate an ISBN. You can see that the baseUrl of the server is saved in a field of the BaseClient struct, so we can configure it in our testing as well.

func (bc *BookClient) Validate(isbn string) (bool, error) {
	res, err := http.
        Get(fmt.Sprintf("%s/%s?q=%s", bc.BaseUrl, BookValidationPath, isbn))

	// handle error

	return res.StatusCode == http.StatusOK, nil
}

Next we need a database connection and a method to insert entries into the database.

func InitDatabase(connUrl string) (*Database, error) {
	db, err := sql.Open("pgx", connUrl)

	// handle error and check connection

	return &Database{
		db: db,
	}, nil
}

const insertCmd string = `insert into "favorites"("isbn","created_at") values($1, $2)`

func (d *Database) SaveFavorite(favorite *FavoriteEntry) error {
	_, err := d.db.Exec(insertCmd, favorite.ISBN, favorite.Created)
	if err != nil {
		return errors.New("failed to insert into db")
	}
	return nil
}

Finally we need to implement our endpoint to handle the favorite requests. We need to validate the ISBN and then store it in the database

func (f *Favorite) AddFavorite(w http.ResponseWriter, r *http.Request) {
	// handle request, deserialize request payload

	valid, err := f.Bc.Validate(req.ISBN)

	// handle result from `Books API`

	err = f.Db.SaveFavorite(&FavoriteEntry{ISBN: req.ISBN, Created: time.Now()})
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	w.WriteHeader(http.StatusCreated)
}

Adding the integration test

The database

As I mentioned in the introduction we use testcontainers to spin up the test database to use in our integration test. With testcontainers we can use any docker image and completely customize how the container is started, what envrionment variables are set, and when we consider the container started.

    cfg := &config{
		name:     "favorite",
		username: "user",
		password: "password",
	}

	port := "5432/tcp"
	req := testcontainers.ContainerRequest{
		Image:        "postgres:alpine",
		ExposedPorts: []string{port},
		Env: map[string]string{
			"POSTGRES_DB":       cfg.name,
			"POSTGRES_PASSWORD": cfg.password,
			"POSTGRES_USER":     cfg.username,
		},
		WaitingFor: wait.ForSQL(nat.Port(port), "pgx", func(port nat.Port) string {
			return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",
				cfg.username,
				cfg.password,
				"localhost",
				port.Port(),
				cfg.name)
		}).Timeout(time.Second * 60),
	}

	db, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: req})
	require.NoError(t, err, "creating container")

We inject the db name, the password, and the username. When the container starts, the postgres server read these variables and configures the database automatically. These values are coming from our config object so we can later use them to configure our database connection.

After creating the container we can start it and add the host and mapped port to our config object.

    cfg.host, err = db.Host(ctx)
	require.NoError(t, err, "getting host")

	require.NoError(t, db.Start(ctx), "starting container")

	p, err := db.MappedPort(ctx, nat.Port(port))
	require.NoError(t, err, "getting port")

	cfg.port = p.Port()

Books API

Let’s setup the fake Books API now. The httptest.Server allows us to use a custom handler to handle the requests. We will mimic a real Books API in this handler to test our application.

    ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method == http.MethodGet && r.RequestURI == fmt.Sprintf("/%s?q=%s", BookValidationPath, isbn) {
			w.WriteHeader(http.StatusOK)
			return
		}

		w.WriteHeader(http.StatusNotFound)
	}))

For this test we simply return HTTP 200 OK if the server was called with the expected method and requestURI, and return HTTP 404 Not Found for any other case. Of course the handler could be more complex, or even providing a different one for each of your test cases.

Putting it together

After setting everything up and creating the handler for our favorites endpoint the rest is very similar to a unit test.

    connUrl := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
		cfg.host,
		cfg.port,
		cfg.username,
		cfg.password,
		cfg.name)
    db, err := InitDatabase(connUrl)
	require.NoError(t, err, "init database")

	f := Favorite{
		Bc: &BookClient{
			BaseUrl: ts.URL,
		},
		Db: db,
	}

	favoriteRequest := FavoriteRequest{
		ISBN: isbn,
	}
	body, err := json.Marshal(favoriteRequest)
	require.NoError(t, err, "marshal favoriteRequest")

	req := httptest.NewRequest(http.MethodPost, "/favorite", bytes.NewReader(body))

	rec := httptest.NewRecorder()

	f.AddFavorite(rec, req)

	assert.Equal(t, http.StatusCreated, rec.Result().StatusCode)

We initialize our db connection with the config based on the postgres container. Then, we set the base url of the BookClient to the url of our test server. After everything is ready we create a new request and a new recorder and call the handler method to test our endpoint. You can check the complete code here.

Wrapping it up

In this article we created integration test for a simple REST API using testcontainers and a test server provided by the httptest package. We saw how to create and configure a postgres database running in a container, and how to use that for our tests. We also created a fake http server to mimic the behavior of an upstream service. We used no mocking in our tests, every component was real and made network requests toward its dependencies.