包阅导读总结
1. 关键词:Integration Tests、Testcontainers、Dependencies、Go、URL Shortener
2. 总结:本文主要介绍了集成测试的概念和重要性,探讨了集成测试环境的不同处理方式,包括使用临时数据库等。重点通过 Go 语言中的简单 URL 短缩器服务示例,分别展示了使用模拟依赖的单元测试、真实依赖和 Testcontainers 的集成测试,还介绍了 Testcontainers 的工作原理和优势。
3. 主要内容:
– 集成测试概述
– 定义和目的
– 在测试金字塔中的地位
– 集成测试的运行方式
– 选项 1:使用一次性数据库和依赖,需提前准备和事后销毁
– 选项 2:使用现有共享数据库和依赖,存在数据不一致等问题
– 选项 3:使用内存或嵌入式服务变体,并非所有依赖都适用
– 选项 4:使用 Testcontainers 在测试代码中管理依赖
– 示例:简单 URL 短缩器服务
– 服务结构和接口定义
– 单元测试:使用模拟依赖的测试
– 集成测试
– 与真实依赖
– 与 Testcontainers
– Testcontainers 工作原理
– 依赖 Docker 运行时
– 支持多种语言和预配置模块
– 结论:强调集成测试重要性,Testcontainers 可简化依赖管理
思维导图:
文章地址:https://www.freecodecamp.org/news/integration-tests-using-testcontainers/
文章来源:freecodecamp.org
作者:Alex Pliutau
发布时间:2024/8/14 19:55
语言:英文
总字数:1432字
预计阅读时间:6分钟
评分:86分
标签:集成测试,测试容器,Go,Docker,MongoDB
以下为原文内容
本内容来源于用户推荐转载,旨在分享知识与观点,如有侵权请联系删除 联系邮箱 media@ilingban.com
What is Integration Testing?
The purpose of integration tests is to validate that different software components, subsystems, or applications work well together combined as a group.
It’s an important step in the testing pyramid that can help you identify any issues that arise when components are combined – for example compatibility issues, data inconsistence, or communication issues.
This article is a hands-on guide to integration Tests in Go using Testcontainers. We’ll define integrations tests as tests of communication between a backend application and external components such as the database and cache.
Table of Contents
Different Ways to Run Integration Tests
This diagram shows only 3 types of tests – but there are other kinds as well: components tests, system tests, load testing, and so on.
While unit tests are easy to run (you just execute tests as you would execute your code), integration tests usually require some scaffolding (spin up temporary testing environment with databases and other dependencies). In the companies where I’ve worked, I’ve seen the following approaches to address the integration testing environment problem.
Option 1: Using throwaway databases and other dependencies, which must be provisioned before the integration tests start and destroyed afterwards.
Depending on your application complexity, the effort involved in this option can be quite high, as you must ensure that the infrastructure is up and running and data is pre-configured in a specific desired state.
Option 2: Using the existing shared databases and other dependencies. You may create a separate environment for integration tests or even use the existing one (staging for example) that integration tests can use.
But there are many disadvantages here, and I would not recommend it. Because it is a shared environment, multiple tests can run in parallel and modify the data simultaneously. So you may end up with inconsistent data state for multiple reasons.
Option 3: Using in-memory or embedded variations of the required services for integration testing. While this is a good approach, not all dependencies have in-memory variations, and even if they do, these implementations may not have the same features as your production database.
Option 4: Using Testcontainers to bootstrap and manage your testing dependencies right inside your testing code. This ensures a full isolation between test runs, reproducibility and better CI experience. We will dive into that in a second.
Our Guinea Pig Service: a Simple URL Shortener
To demonstrate the tests, we’ll use a super simple URL shortener API written in Go. It uses MongoDB for data storage and Redis as a read-through cache. It has two endpoints which we’ll be testing in our tests:
We won’t delve into the details of the endpoints much, but you can find the full code in this Github repository. Still, let’s see how we define our “server“ struct:
type server struct { DB DB Cache Cache}func NewServer(db DB, cache Cache) (*server, error) { if err := db.Init(); err != nil { return nil, err } if err := cache.Init(); err != nil { return nil, err } return &server{DB: db, Cache: cache}, nil}
The NewServer function allows us to initialize a server with the database and cache instances that implement DB and Cache interfaces.
type DB interface { Init() error StoreURL(url string, key string) error GetURL(key string) (string, error)}type Cache interface { Init() error Set(key string, val string) error Get(key string) (string, bool)}
Unit Tests with Mocked Dependencies
Because we had all dependencies defined as interfaces, we can easily generate mocks for them using mockery and use them in our unit tests.
mockery --all --with-expectergo test -v ./...
With the help of unit tests, we can cover quite well the low level components of our application: endpoints, hash key logic, and so on. All we need is to mock the function calls of database and cache dependencies.
unit_test.go:
func TestServerWithMocks(t *testing.T) { mockDB := mocks.NewDB(t) mockCache := mocks.NewCache(t) mockDB.EXPECT().Init().Return(nil) mockDB.EXPECT().StoreURL(mock.Anything, mock.Anything).Return(nil) mockDB.EXPECT().GetURL(mock.Anything).Return("url", nil) mockCache.EXPECT().Init().Return(nil) mockCache.EXPECT().Get(mock.Anything).Return("url", true) mockCache.EXPECT().Set(mock.Anything, mock.Anything).Return(nil) s, err := NewServer(mockDB, mockCache) assert.NoError(t, err) srv := httptest.NewServer(s) defer srv.Close() testServer(srv, t)}
mocks.NewDB(t)
and mocks.NewCache(t)
have been auto-generated by mockery and we use EXPECT()
to mock the functions. Notice that we created a separate function testServer(srv, t)
that we will use later in other tests as well, but providing a different server struct.
As you may already understand, these unit tests are not testing the communications between our application and our database/cache, and we may easily miss some very critical bugs.
To be more confident with our application, we should write integration tests along with unit tests to ensure that our application is fully functional.
Integration Tests with Real Dependencies
As Option 1 and 2 mention above, we can provision our dependencies beforehand and run our tests against these instances. One option would be to have a Docker Compose configuration with MongoDB and Redis, which we start before the tests and shutdown after. The seed data could be a part of this configuration, or done separately.
compose.yaml:
services: mongodb: image: mongodb/mongodb-community-server:7.0-ubi8 restart: always ports: - "27017:27017" redis: image: redis:7.4-alpine restart: always ports: - "6379:6379"
realdeps_test.go:
package mainfunc TestServerWithRealDependencies(t *testing.T) { os.Setenv("MONGO_URI", "mongodb://localhost:27017") os.Setenv("REDIS_URI", "redis://localhost:6379") s, err := NewServer(&MongoDB{}, &Redis{}) assert.NoError(t, err) srv := httptest.NewServer(s) defer srv.Close() testServer(srv, t)}
Now these tests don’t use mocks, but simply connect to the already provisioned database and cache. Note: we added a “realdeps“ build tag so these tests should be executed by specifying this tag explicitly.
docker-compose up -dgo test -tags=realdeps -v ./...docker-compose down
Integration Tests with Testcontainers
However, creating reliable service dependencies using Docker Compose requires good knowledge of Docker internals and how to best run specific technologies in a container. For example, creating a dynamic integration testing environment may result in port conflicts, containers not being fully running and available, and so on.
With Testcontainers, we can now do the same – but inside our test suite, using our language API. This means we can control our throwaway dependencies better and make sure they’re isolated per each test run. You can run pretty much anything in Testcontainers, as long as it has a Docker-API compatible container runtime.
integration_test.go:
package mainimport ( "context" "net/http/httptest" "os" "testing" "github.com/stretchr/testify/assert" "github.com/testcontainers/testcontainers-go/modules/mongodb" "github.com/testcontainers/testcontainers-go/modules/redis")func TestServerWithTestcontainers(t *testing.T) { ctx := context.Background() mongodbContainer, err := mongodb.Run(ctx, "docker.io/mongodb/mongodb-community-server:7.0-ubi8") assert.NoError(t, err) defer mongodbContainer.Terminate(ctx) redisContainer, err := redis.Run(ctx, "docker.io/redis:7.4-alpine") assert.NoError(t, err) defer redisContainer.Terminate(ctx) mongodbEndpoint, _ := mongodbContainer.Endpoint(ctx, "") redisEndpoint, _ := redisContainer.Endpoint(ctx, "") os.Setenv("MONGO_URI", "mongodb://"+mongodbEndpoint) os.Setenv("REDIS_URI", "redis://"+redisEndpoint) s, err := NewServer(&MongoDB{}, &Redis{}) assert.NoError(t, err) srv := httptest.NewServer(s) defer srv.Close() testServer(srv, t)}
This is very similar to the previous test: we just initialized two containers at the top of our test.
The first run may take a while to download the images. But the subsequent runs are almost instant.
How Testcontainers Work
To run tests with Testcontainers, you need a Docker-API compatible container runtime or to install Docker locally. Try stopping your Docker engine and it won’t work.
But this should not be an issue for most developers, because having a Docker runtime in your CI/CD or locally is a very common practice nowadays. You can easily have this environment in Github Actions, for example.
When it comes to supported languages, Testcontainers support a big list of popular languages and platforms including Java, .NET, Go, NodeJS, Python, Rust, Haskell, and others.
There is also a growing list of preconfigured implementations (called modules) which you can find here. But as I mentioned earlier, you can run any Docker image.
In Go, you could use the following code to provision Redis instead of using a preconfigured module:
redisContainer, err := redis.Run(ctx, "redis:latest")req := testcontainers.ContainerRequest{ Image: "redis:latest", ExposedPorts: []string{"6379/tcp"}, WaitingFor: wait.ForLog("Ready to accept connections"),}redisC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true,})
Conclusion
While the development and maintenance of integration tests require significant effort, they are crucial part of the SDLC ensuring that components, subsystems, or applications work well together combined as a group.
Using Testcontainers, we can simplify the provisioning and de-provisioning of throwaway dependencies for testing, making the test runs fully isolated and more predicatble.