Fast Setup: Golang and Testcontainers
Most of the time, our scripts set up an entire test environment right on our computers, but why? In the test environment, we have everything, but we need it?
Join the DZone community and get the full member experience.
Join For FreeIn this article, I want to discuss test containers and Golang, how to integrate them into a project, and why it is necessary.
Testcontainers Review
Testcontainers is a tool that enables developers to utilize Docker containers during testing, providing isolation and maintaining an environment that closely resembles production.
Why do we need to use it? Some points:
Importance of Writing Tests
- Ensures code quality by identifying and preventing errors.
- Facilitates safer code refactoring.
- Acts as documentation for code functionality.
Introduction to Testcontainers
- Library for managing Docker containers within tests.
- Particularly useful when applications interact with external services.
- Simplifies the creation of isolated testing environments.
Support Testcontainers-go in Golang
- Port of the Testcontainers library for Golang.
- Enables the creation and management of Docker containers directly from tests.
- Streamlines integration testing by providing isolated and reproducible environments.
- Ensures test isolation, preventing external factors from influencing results.
- Simplifies setup and teardown of containers for testing.
- Supports various container types, including databases, caches, and message brokers.
Integration Testing
- Offers isolated environments for integration testing.
- Convenient methods for starting, stopping, and obtaining container information.
- Facilitates seamless integration of Docker containers into the Golang testing process.
So, the key point to highlight is that we don't preconfigure the environment outside of the code; instead, we create an isolated environment from the code. This allows us to achieve isolation for both individual and all tests simultaneously. For example, we can set up a single MongoDB for all tests and work with it within integration tests. However, if we need to add Redis for a specific test, we can do so through the code.
Let’s explore its application through an example of a portfolio management service developed in Go.
Service Description
The service is a REST API designed for portfolio management. It utilizes MongoDB for data storage and Redis for caching queries. This ensures fast data access and reduces the load on the primary storage.
Technologies
- Go: The programming language used to develop the service.
- MongoDB: Document-oriented database employed for storing portfolio data.
- Docker and Docker Compose: Used for containerization and local deployment of the service and its dependencies.
- Testcontainers-go: Library for integration testing using Docker containers in Go tests.
Testing Using Testcontainers
Test containers allow integration testing of the service under conditions closely resembling a real environment, using Docker containers for dependencies. Let’s provide an example of a function to launch a MongoDB container in tests:
func RunMongo(ctx context.Context, t *testing.T, cfg config.Config) testcontainers.Container {
mongodbContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: mongoImage,
ExposedPorts: []string{listener},
WaitingFor: wait.ForListeningPort(mongoPort),
Env: map[string]string{
"MONGO_INITDB_ROOT_USERNAME": cfg.Database.Username,
"MONGO_INITDB_ROOT_PASSWORD": cfg.Database.Password,
},
},
Started: true,
})
if err != nil {
t.Fatalf("failed to start container: %s", err)
}
return mongodbContainer
}
And a part of the example:
package main_test
import (
"context"
"testing"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
func TestMongoIntegration(t *testing.T) {
ctx := context.Background()
// Replace cfg with your actual configuration
cfg := config.Config{
Database: struct {
Username string
Password string
Collection string
}{
Username: "root",
Password: "example",
Collection: "test_collection",
},
}
// Launching the MongoDB container
mongoContainer := RunMongo(ctx, t, cfg)
defer mongoContainer.Terminate(ctx)
// Here you can add code for initializing MongoDB, for example, creating a client to interact with the database
// Here you can run tests using the started MongoDB container
// ...
// Example test that checks if MongoDB is available
if err := checkMongoAvailability(mongoContainer, t); err != nil {
t.Fatalf("MongoDB is not available: %s", err)
}
// Here you can add other tests in your scenario
// ...
}
// Function to check the availability of MongoDB
func checkMongoAvailability(container testcontainers.Container, t *testing.T) error {
host, err := container.Host(ctx)
if err != nil {
return err
}
port, err := container.MappedPort(ctx, "27017")
if err != nil {
return err
}
// Here you can use host and port to create a client and check the availability of MongoDB
// For example, attempt to connect to MongoDB and execute a simple query
return nil
}
How to run tests:
go test ./… -v
This test will use Testcontainers to launch a MongoDB container and then conduct integration tests using the started container. Replace `checkMongoAvailability`
with the tests you need. Please ensure that you have the necessary dependencies installed before using this example, including the `testcontainers-go`
library and other libraries used in your code.
Now, it is necessary to relocate the operation of the MongoDB Testcontainer into the primary test method. This adjustment allows for the execution of the Testcontainer a single time.
var mongoAddress string
func TestMain(m *testing.M) {
ctx := context.Background()
cfg := CreateCfg(database, collectionName)
mongodbContainer, err := RunMongo(ctx, cfg)
if err != nil {
log.Fatal(err)
}
defer func() {
if err := mongodbContainer.Terminate(ctx); err != nil {
log.Fatalf("failed to terminate container: %s", err)
}
}()
mappedPort, err := mongodbContainer.MappedPort(ctx, "27017")
mongoAddress = "mongodb://localhost:" + mappedPort.Port()
os.Exit(m.Run())
}
And now, our test should be:
func TestFindByID(t *testing.T) {
ctx := context.Background()
cfg := CreateCfg(database, collectionName)
cfg.Database.Address = mongoAddress
client := GetClient(ctx, t, cfg)
defer client.Disconnect(ctx)
collection := client.Database(database).Collection(collectionName)
testPortfolio := pm.Portfolio{
Name: "John Doe",
Details: "Software Developer",
}
insertResult, err := collection.InsertOne(ctx, testPortfolio)
if err != nil {
t.Fatal(err)
}
savedObjectID, ok := insertResult.InsertedID.(primitive.ObjectID)
if !ok {
log.Fatal("InsertedID is not an ObjectID")
}
service, err := NewMongoPortfolioService(cfg)
if err != nil {
t.Fatal(err)
}
foundPortfolio, err := service.FindByID(ctx, savedObjectID.Hex())
if err != nil {
t.Fatal(err)
}
assert.Equal(t, testPortfolio.Name, foundPortfolio.Name)
assert.Equal(t, testPortfolio.Details, foundPortfolio.Details)
}
Ok, but Do We Already Have Everything Inside the Makefile?
Let's figure it out—what advantages do test containers offer now? Long before, we used to write tests and describe the environment in a makefile, where scripts were used to set up the environment. Essentially, it was the same Docker compose and the same environment setup, but we did it in one place and for everyone at once. Does it make sense for us to migrate to test containers?
Let's conduct a brief comparison between these two approaches.
Isolation and Autonomy
Testcontainers ensure the isolation of the testing environment during tests. Each test launches its container, guaranteeing that changes made by one test won’t affect others.
Ease of Configuration and Management
Testcontainers simplifies configuring and managing containers. You don’t need to write complex Makefile scripts for deploying databases; instead, you can use the straightforward Testcontainers API within your tests.
Automation and Integration With Test Suites
Utilizing Testcontainers enables the automation of container startup and shutdown within the testing process. This easily integrates into test scenarios and frameworks.
Quick Test Environment Setup
Launching containers through Testcontainers is swift, expediting the test environment preparation process. There’s no need to wait for containers to be ready, as is the case when using a Makefile.
Enhanced Test Reliability
Starting a container in a test brings the testing environment closer to reality. This reduces the likelihood of false positives and increases test reliability.
In conclusion, incorporating Testcontainers into tests streamlines the testing process, making it more reliable and manageable. It also facilitates using a broader spectrum of technologies and data stores.
Conclusion
In conclusion, it's worth mentioning that delaying transitions from old approaches to newer and simpler ones is not advisable. Often, this leads to the accumulation of significant complexity and requires ongoing maintenance. Most of the time, our scripts set up an entire test environment right on our computers, but why? In the test environment, we have everything — Kafka, Redis, and Istio with Prometheus. Do we need all of this just to run a couple of integration tests for the database? The answer is obviously no.
The main idea of such tests is complete isolation from external factors and writing them as close to the subject domain and integrations as possible. As practice shows, these tests fit well into CI/CD under the profile or stage named e2e, allowing them to be run in isolation wherever you have Docker!
Ultimately, if you have a less powerful laptop or prefer running everything in runners or on your company's resources, this case is for you!
Thank you for your time, and I wish you the best of luck! I hope the article proves helpful!
Code
DrSequence/testcontainer-contest
Read More
Published at DZone with permission of Ilia Ivankin. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments