How To Containerize Golang Unit and Integration Tests


This article is the fifth and final in a series covering all the aspects of implementing a modern REST API microservice step by step:

  1. Defining a SQL first data model with sqlc
  2. Implementing the REST API with Gin
  3. Configuring with Viper
  4. Building and running in a container
  5. Containerized tests

All the code for the series is available here.

What Kind of Tests Should be Implemented?

Now that we have built our API microservice, we want to implement tests to ensure it’s working correctly. We have a two-layered service design: the database layer and the HTTP API on top of it. So how are we going to test it?

We could decide to go complete unit testing and implement unit tests for our database layer by testing each SQL query we have generated and making sure it behaves as expected by mocking the actual database. Then, we would test the HTTP API by mocking the database layer and testing each API handler. This is a common approach and makes a lot of sense.

Another way would be to do integration tests. The idea of integration tests is to test your application only using its public APIs. So in our case, we would:

  1. Start the database and the microservice, exactly like the production stack in the previous article,
  2. Then we would implement a REST API test client called the microservice API to test all the provided methods.

This is also commonly used in the industry to test a microservice-based architecture and ensure the whole application or its subsystems behave as expected.

However, both approaches present some challenges. On the one hand, going full-on unit testing requires writing a lot of test code, and in our case, testing the database layer is testing the Postgres SQL driver and sqlc generated code. In the same spirit, testing the HTTP layer independently, requiring mocking the database layer, is a lot of code that must be maintained.

On the other hand, integration tests which only rely on APIs are tremendous but the checks we can implement are limited to what is publicly exposed by the API. We do not want to implement a private API for testing purposes!

Best of Both Worlds?

So, in this article, we will take the best of both worlds and implement something in-between unit and integration tests:

  • To avoid mocking the database, we will start a containerized database to support our tests,
  • We will not decouple the client and the server to enable fine-grained checks but bypass the network layer and perform requests directly on the server.

Please note that this is a very opinionated approach. It is very efficient and will definitely save you a lot of time.

Grouping Tests under Suites

To write our tests, we will use the stretchr/testify library to group our tests under test suites. Using suites allows us to perform initialization steps before the whole suite and before each test:

package authors

// ...

type ServiceTestSuite struct {
	suite.Suite
	router  *gin.Engine
	queries *database.Queries
}

// In order for 'go test' to run this suite, we need to create
// a normal test function and pass our suite to suite.Run
func TestServiceTestSuite(t *testing.T) {
	suite.Run(t, new(ServiceTestSuite))
}

func (suite *ServiceTestSuite) SetupSuite() {
	cfg, err := config.Read()
	suite.Require().NoError(err)

	postgres, err := database.NewPostgres(cfg.Postgres.Host, cfg.Postgres.User, cfg.Postgres.Password)
	suite.Require().NoError(err)

	suite.queries = database.New(postgres.DB)
	service := NewService(suite.queries)

	suite.router = gin.Default()
	service.RegisterHandlers(suite.router)
}

func (suite *ServiceTestSuite) SetupTest() {
	suite.queries.TruncateAuthor(context.Background())
}

The SetupSuite method is executed once before the execution of the whole suite. We set up the server the same way we do for the main function. The only difference here is that we do not start the server. Everything else is identical: we use the same configuration mechanism, we instantiate the database connection in the same way, and we register the same handlers.

The SetupTest method is executed before every test of the suite. In our case, we truncate the author database to start with a clean slate.

Implementing tests with Golang