Structuring Integration Testing in Golang
Integration testing test case structure with a long flow on a HTTP server could be hard to maintain. This is how I structure it as another form of a documentation for the application flow.
Writing integration testing for a HTTP server could end up to be a long line of code in Golang. This case is going to happen for a test case that has several steps to achieve a certain state of data. The longer your test case the harder it is to read for understanding the application flow and maintaining it.
The hard part from writing an integration test is how easy we can understand what the test does. With this post I would like to share how I organize it and give my opinion on it when it is implemented to a team of 3–5 persons.
In a high level the integration test that I wrote is running these steps:
- Starting your dependency applications (RDBMS, Redis, etc)
- Setup the dependencies (do database migration for RDBMS)
- Starting the HTTP server
- Execute the integration test cases
Starting dependency applications
The first step is preparing dependencies for your HTTP server. The dependency application can be a RDBMS or Redis server. Creating those dependencies on top of containers is going to help to build and destroy it multiple times whenever we want to run the integration testing. Hence, put this step before we run the integration testing in our CI/CD.
Setup the dependency application
Once the dependency application is up and running we can go to the next setup. The setup here is more into preparing the application to replicate the actual system in production. In this case we start to do a database migration to set up the database schema, do database seeding or anything that is predefined stuff for your application. Keeping the migration and seed files the same with the production environment to actually replicate the behavior is a good start.
Separate the build or not to separate
In unit tests we can simply simulate the parameter and mock some func that have another responsibility. In integration tests, we want to make a test that is actually integrated with a dependency application like a database. Spin up a database application and setup database schema is not required in order to run a unit tests. Doing so is going to add the running time for your unit test, and you have to think more about data consistency since your unit test might run in parallel.
Using build tag is one way to separate the scope of unit tests and integration tests. By adding the build tag to all the integration tests files helps us to set up a constraint. When we run go test ./...
, go tools only run the unit test and when we run go test ./... — tag={build-tag}
it is only runs the integration tests that might take extra steps.
However, separating the build comes with cons to your project. When you implement build tag to integration testing, failing build won’t be detected on those test files when you simply do go build
. This causes you to have a late feedback from your code changes since you only knew it when the integration testing is running. Imagine if you put the step for unit test and integration test in different pipelines in your CI/CD, then it costs you more time to get the failing feedback.
Starting the HTTP Server
In this step, run the application which is a HTTP server where basically the application is going to read application configurations, make a connection to dependency applications and start to listen and serve for any HTTP requests. For this step, you have to make sure that the configuration is aiming to the dummy dependencies application that you set earlier and not actually connected to the production environment you have.
Structuring Test Case
From the struct successLoginTest
, it has all the methods that are relevant to the test case and it contains references to be used for a state preparation step, assertion and for clean up step.
To understand the flow, you can directly go to the TestSuccessLogin()
. This func already calls each method from successLoginTest
step by step that also shows how the flow of your Login API implicitly. By separating all the steps into a method, it helps us to know the high level flow for a particular test case. Hence, a good method name will help to understand the flow clearly.
When the test case grew into a more complex case, practically I still found this approach helped the team to understand the flow. That condition only grows the struct for the reference to have more fields and methods, and for the test case it is still a clear flow since each step is separated into a method and good method naming helps so much.
I found a table driven test is not a good practice when it comes to structuring an integration test. The long test step for preparing the data might end up to be a unique case until you can’t fit it into a struct for the test data. Reading the t.Run("name", func() {}
part is also not that clear since each step will be renamed with the struct field names.
Hope this story gives you some more perspective on structuring your integration test in Go. :)