Write fast, reliable, and isolated tests for your DB integrations with Testcontainers.
Picture this, you have a critical and reasonably complicated piece of logic in your application that is handled in the database. Despite any change on it (or around it), you must have a 100% guarantee that piece continues to work just fine. So, what do you do?
Back in the ancient days…
Ancient cave art of many hand prints. (Credit: Petr Kratochvila/Shutterstock)
Before the “age of containers”, the solution would be a variant of the following:
Have a database with the schema aligned with the application’s version.
Ensure your CI tool has access to that database, to run the tests in your CI pipeline.
Create a script to arrange the necessary data for the test.
Write your testing using the DB.
Create a script to revert any DB changes performed by the test, such as inserts, updates, etc.
This is a very nice post about the Docker: Life Before and after.
What’s the problem with testing with a shared database?
Simply put, it’s complex, expensive to maintain, and has many other reasons to fail. For example:
The DB’s schema version is not the same as the application’s version you want to test.
There is a change in the network policy that blocks access to the database.
Someone’s test setup corrupted the data that you expected to have, so you arrange script fails, or the test fails because of missing data.
Because of the cost and flakiness of such tests, in many cases, the solution was to move logic from DB to the application’s code when performance was not a problem, or just manually test it from time to time and hope it doesn’t break because of an unforeseen reason (it-never-happened-in-the-last-30-minutes).
Containers everywhere
With the popularization of containers, not only developing, runnings, and deploying became easier, but also integrating and testing your external dependencies became easier, faster, and more dependable.
We can now effortlessly run not only the regular dependencies, like RDBMSs and Message Brokers, but we can emulate many cloud provider services using tools like localstack.
What about testing? Testcontainers to rescue!
Testcontainers makes it simple to create and clean up container-based dependencies for automated integration/smoke tests. The clean, easy-to-use API enables developers to programmatically define containers that should be run as part of a test and clean up those resources when the test is done.
For this example, assume that one of the features of your software is to search for users based on a few criteria. Your users are stored in a PostgreSQL database and you query them using SQL. Something like:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
funcsearchPeople(ctxcontext.Context,db*sql.DB,paramssearchParams)([]person,error){query:=`select first_name, last_name, city
from people
where ($1::text is null or first_name = $1)
and ($2::text is null or last_name = $2)
and ($3::text is null or city = $3)
order by first_name asc`varpeople[]person//Execute query, check errors, populate people variablereturnpeople,nil}
The search criteria will evolve, by adding new filters or adding new features like pagination. Therefore, we need to make sure that whenever we touch this query its existing behavior is not broken. Let’s write a test for it using Testcontainers.
Writing the test
To be able to run these tests against a real database, we need to:
Start the DB
Create the schema
Arrange data, Act, and Assert
Let’s see in detail how to perform each one of these steps.
1. Start the DB
Let’s create a function that will start a DB, return its connection string and a function to terminate it when we are done.
funcstartTestDB(ctxcontext.Context)(string,func(t*testing.T),error){varenvVars=map[string]string{"POSTGRES_USER":"user","POSTGRES_PASSWORD":"super-secret","POSTGRES_DB":"people","PORT":"5432/tcp",}getConnString:=func(hoststring,portnat.Port)string{returnfmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",envVars["POSTGRES_USER"],envVars["POSTGRES_PASSWORD"],host,port.Port(),envVars["POSTGRES_DB"])}req:=testcontainers.ContainerRequest{Image:"postgres:14",ExposedPorts:[]string{envVars["PORT"]},Env:envVars,WaitingFor:wait.ForSQL(nat.Port(envVars["PORT"]),"pgx",getConnString).WithStartupTimeout(time.Second*15),}pgC,err:=testcontainers.GenericContainer(ctx,testcontainers.GenericContainerRequest{ContainerRequest:req,Started:true,})iferr!=nil{return"",nil,fmt.Errorf("failed to start db container :%w",err)}port,err:=pgC.MappedPort(ctx,"5432/tcp")iferr!=nil{return"",nil,fmt.Errorf("failed to get mapped port :%w",err)}host,err:=pgC.Host(ctx)iferr!=nil{return"",nil,fmt.Errorf("failed to get host :%w",err)}connString:=fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable",envVars["POSTGRES_USER"],envVars["POSTGRES_PASSWORD"],host,port.Int(),envVars["POSTGRES_DB"])terminate:=func(t*testing.T){iferr:=pgC.Terminate(ctx);err!=nil{t.Fatalf("failed to terminate container: %s",err.Error())}}returnconnString,terminate,nil}
The code above is self-explanatory if you are familiar with Go, but there are a few aspects that I’d like to highlight:
Testcontainers will map and assign a random port. Therefore, in this case, instead of connecting to 5432, it’s necessary to connect to the assigned port. This is similar to the hostname. Therefore, to get your correct connection string it’s needed to resolve port and host.
The container will automatically shut down when the application is finished. However, it is recommended to defer its termination as soon as it is started. For that, a terminate func is returned by the method above.
So far our test looks like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
funcTestSearchPeople(t*testing.T){ctx:=context.Background()connString,terminate,err:=startTestDB(ctx)iferr!=nil{t.Error(err)}deferterminate(t)db,err:=openDB(connString)iferr!=nil{t.Fatal("fail to open DB",err)}// Continue}
2. Create the schema
With a DB up and running we can initiate it with the expected schema, a.k.a migrate the DB schema.
In the real world, we would use something more sophisticated like go-migrate, but for the sake of simplicity let’s write something simpler.
1
2
3
4
5
6
7
funcmigrateDB(ctxcontext.Context,db*sql.DB)error{_,err:=db.ExecContext(ctx,"create table if not exists people (first_name text,last_name text,city text)")iferr!=nil{returnfmt.Errorf("failed to migrate DB: %w",err)}returnnil}
3. Arrange data, Act, and Assert
The last step before the actual test is to arrange the data. We could accomplish it together with the schema migration, however keeping these two parts separate helps to keep each test isolated, avoiding side effects when the data is tweaked.
For our tests, we need to insert 4 different people with distinct names and cities. Then we can use this data to act and assert the results.
This is what the final test func looks like, testing every variation of our query.
funcTestSearchPeople(t*testing.T){ctx:=context.Background()// Start the DBconnString,terminate,err:=startTestDB(ctx)iferr!=nil{t.Error(err)}// Terminate the container when the func TestSearchPeople finishesdeferterminate(t)// Open the the databasedb,err:=openDB(connString)iferr!=nil{t.Fatal("fail to open DB",err)}// Migrate schemaiferr:=migrateDB(ctx,db);err!=nil{t.Fatal("fail to migrate DB",err)}insert:=`insert into people values
('Flavio', 'Silva', 'Olbia'),
('Joost', 'Van Huis', 'Amsterdam'),
('Aldben', 'Arimeritin', 'Istambul'),
('Nando', 'Pelect', 'Perth');`// Arrange dataif_,err:=db.ExecContext(ctx,insert);err!=nil{t.Fatal("fail to insert initial data",err)}// Several test casest.Run("without filters",func(t*testing.T){people,err:=searchPeople(ctx,db,searchParams{})assert.NoError(t,err)assert.Equal(t,4,len(people))})t.Run("filtering by first name",func(t*testing.T){people,err:=searchPeople(ctx,db,searchParams{firstName:strPtr("Joost")})assert.NoError(t,err)assert.Equal(t,1,len(people))assert.Equal(t,"Joost",people[0].firstName)})t.Run("filtering by last name",func(t*testing.T){people,err:=searchPeople(ctx,db,searchParams{lastName:strPtr("Arimeritin")})assert.NoError(t,err)assert.Equal(t,1,len(people))assert.Equal(t,"Arimeritin",people[0].lastName)})t.Run("filtering by city",func(t*testing.T){people,err:=searchPeople(ctx,db,searchParams{city:strPtr("Olbia")})assert.NoError(t,err)assert.Equal(t,1,len(people))assert.Equal(t,"Olbia",people[0].city)})}
With this test, we have the guarantee the covered scenarios are working. Profit!
Despite Testcontainers reducing the cost of integration tests and making them more reliable, it doesn’t remove the cost entirely. The tests will take more time to run (especially if where you are running the tests doesn’t have the image downloaded yet).
Nevertheless, in my opinion, the price is low compared to the benefit that it yields.
What about you? How do you write integration tests, and what are your main challenges?