这是之前发布的带有 Go、Chi、Postgres 和 sqlx 的 REST API 的延续。 在本教程中,我将扩展示例以添加集成测试来验证 postgres_movies_store 的实现。
为什么集成测试
根据维基百科的定义,集成测试是将各个软件模块组合起来并作为一个组进行测试的阶段。
这在我们的例子中很重要,因为我们使用外部系统来存储数据,在我们声明它可以使用之前,我们需要确保它按预期工作。
我们的选择是
一种方法是运行数据库服务器和我们的 api 项目,并使用定义的数据从 Swagger UI、curl 或 Postman 调用端点,然后验证我们的服务是否正确存储和检索数据。每次我们这样做都很乏味。 进行更改,向域模型添加或删除属性,为新用例添加新端点。
将一组集成测试添加到我们的源代码中,并在每次进行更改时运行,这将确保我们所做的任何更改都不会破坏任何现有的功能和场景。 要记住的重要一点是,这并不是一成不变的,这些应该随着功能的发展而更新,新功能将导致添加新的测试用例。
本文的重点是为我们之前实现的 PostgresMoviesStore 实现自动化集成测试。
测试设置
让我们首先添加一个新文件夹integrationtests。
database_helper.go
我将首先添加database_helper.go,这将与postgres_movies_store.go紧密匹配,但将为CRUD操作提供自己的方法,并且它将跟踪创建的记录以在测试完成后进行清理。
这是完整的清单
package integrationtests
import (
"context"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/kashifsoofi/blog-code-samples/integration-test-postgres-go/store"
_ "github.com/lib/pq"
)
const driverName = "pgx"
type databaseHelper struct {
databaseUrl string
dbx *sqlx.DB
trackedIDs map[uuid.UUID]any
}
func newDatabaseHelper(databaseUrl string) *databaseHelper {
return &databaseHelper{
databaseUrl: databaseUrl,
trackedIDs: map[uuid.UUID]any{},
}
}
func (s *databaseHelper) connect(ctx context.Context) error {
dbx, err := sqlx.ConnectContext(ctx, driverName, s.databaseUrl)
if err != nil {
return err
}
s.dbx = dbx
return nil
}
func (s *databaseHelper) close() error {
return s.dbx.Close()
}
func (s *databaseHelper) GetMovie(ctx context.Context, id uuid.UUID) (store.Movie, error) {
err := s.connect(ctx)
if err != nil {
return store.Movie{}, err
}
defer s.close()
var movie store.Movie
if err := s.dbx.GetContext(
ctx,
&movie,
`SELECT
id, title, director, release_date, ticket_price, created_at, updated_at
FROM movies
WHERE id = $1`,
id); err != nil {
return store.Movie{}, err
}
return movie, nil
}
func (s *databaseHelper) AddMovie(ctx context.Context, movie store.Movie) error {
err := s.connect(ctx)
if err != nil {
return err
}
defer s.close()
if _, err := s.dbx.NamedExecContext(
ctx,
`INSERT INTO movies
(id, title, director, release_date, ticket_price, created_at, updated_at)
VALUES
(:id, :title, :director, :release_date, :ticket_price, :created_at, :updated_at)`,
movie); err != nil {
return err
}
s.trackedIDs[movie.ID] = movie.ID
return nil
}
func (s *databaseHelper) AddMovies(ctx context.Context, movies []store.Movie) error {
for _, movie := range movies {
if err := s.AddMovie(ctx, movie); err != nil {
return err
}
}
return nil
}
func (s *databaseHelper) DeleteMovie(ctx context.Context, id uuid.UUID) error {
err := s.connect(ctx)
if err != nil {
return err
}
defer s.close()
return s.deleteMovie(ctx, id)
}
func (s *databaseHelper) CleanupAllMovies(ctx context.Context) error {
ids := []uuid.UUID{}
for id := range s.trackedIDs {
ids = append(ids, id)
}
return s.CleanupMovies(ctx, ids...)
}
func (s *databaseHelper) CleanupMovies(ctx context.Context, ids ...uuid.UUID) error {
err := s.connect(ctx)
if err != nil {
return err
}
defer s.close()
for _, id := range ids {
if err := s.deleteMovie(ctx, id); err != nil {
return err
}
}
return nil
}
func (s *databaseHelper) deleteMovie(ctx context.Context, id uuid.UUID) error {
_, err := s.dbx.ExecContext(ctx, `DELETE FROM movies WHERE id = $1`, id)
if err != nil {
return err
}
delete(s.trackedIDs, id)
return nil
}
postgres_movies_store_test.go
该文件将包含 PostgresMoviesStore 提供的每种方法的测试。 但首先让我们从添加 TestMain 方法开始。 我们将从环境中加载配置并初始化databaseHelper、PostgresMoviesStore 和faker 的实例。
我们还将添加 2 个辅助方法来使用 faker 生成测试数据,并添加一个辅助方法来断言 store.Movie 的 2 个实例相等,我们会将时间字段与最接近的秒数进行比较。
package integrationtests
import (
"context"
"math"
"testing"
"time"
"github.com/google/uuid"
"github.com/jaswdr/faker"
"github.com/kashifsoofi/blog-code-samples/integration-test-postgres-go/config"
"github.com/kashifsoofi/blog-code-samples/integration-test-postgres-go/store"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var dbHelper *databaseHelper
var sut *store.PostgresMoviesStore
var fake faker.Faker
func TestMain(t *testing.T) {
cfg, err := config.Load()
require.Nil(t, err)
dbHelper = newDatabaseHelper(cfg.DatabaseURL)
sut = store.NewPostgresMoviesStore(cfg.DatabaseURL)
fake = faker.New()
}
func createMovie() store.Movie {
m := store.Movie{}
fake.Struct().Fill(&m)
m.TicketPrice = math.Round(m.TicketPrice*100) / 100
m.CreatedAt = time.Now().UTC()
m.UpdatedAt = time.Now().UTC()
return m
}
func createMovies(n int) []store.Movie {
movies := []store.Movie{}
for i := 0; i < n; i++ {
m := createMovie()
movies = append(movies, m)
}
return movies
}
func assertMovieEqual(t *testing.T, expected store.Movie, actual store.Movie) {
assert.Equal(t, expected.ID, actual.ID)
assert.Equal(t, expected.Title, actual.Title)
assert.Equal(t, expected.Director, actual.Director)
assert.Equal(t, expected.ReleaseDate, actual.ReleaseDate)
assert.Equal(t, expected.TicketPrice, actual.TicketPrice)
assert.WithinDuration(t, expected.CreatedAt, actual.CreatedAt, 1*time.Second)
assert.WithinDuration(t, expected.UpdatedAt, actual.UpdatedAt, 1*time.Second)
}
测试
我将按方法对测试进行分组,然后在测试方法中使用 t.Run 来运行单个场景。 我们还可以使用基于表的测试来运行各个场景。 例如 如果 GetAll 有 2 个测试,这些测试将在 TestGetAll 方法中,然后我将在该方法中使用 t.Run 运行单独的测试。
此外,在运行测试之前,我们需要启动数据库服务器并应用迁移。 运行以下命令来执行此操作。
docker-compose -f docker-compose.dev-env.yml up -d
获取所有测试
对于GetAll,我们将实施2个测试。 第一个测试很简单,如果没有添加记录,GetAll 应该返回一个空数组。 它看起来像以下
func TestGetAll(t *testing.T) {
ctx := context.Background()
t.Run("given no records, should return empty array", func(t *testing.T) {
storeMovies, err := sut.GetAll(ctx)
assert.Nil(t, err)
assert.Empty(t, storeMovies)
assert.Equal(t, len(storeMovies), 0)
})
}
对于第二个测试,我们首先创建测试电影,然后使用 dbHelper 将这些记录插入数据库,然后再调用 PostgresMoviesStore 上的 GetAll 方法。 获得结果后,我们将验证之前使用 dbHelper 添加的每条记录是否存在于 PostgresMoviesStore 的 GetAll 方法结果中。 我们还将调用 defer 函数从数据库中删除测试数据。
func TestGetAll(t *testing.T) {
...
t.Run("given records exist, should return array", func(t *testing.T) {
movies := createMovies(3)
err := dbHelper.AddMovies(ctx, movies)
assert.Nil(t, err)
defer func() {
ids := []uuid.UUID{}
for _, m := range movies {
ids = append(ids, m.ID)
}
err := dbHelper.CleanupMovies(ctx, ids...)
assert.Nil(t, err)
}()
storeMovies, err := sut.GetAll(ctx)
assert.Nil(t, err)
assert.NotEmpty(t, storeMovies)
assert.GreaterOrEqual(t, len(storeMovies), len(movies))
for _, m := range movies {
for _, sm := range storeMovies {
if m.ID == sm.ID {
assertMovieEqual(t, m, sm)
continue
}
}
}
})
}
GetByID 测试
GetByID 的第一个测试是尝试获取具有随机 id 的记录并验证它是否返回 RecordNotFoundError。
func TestGetByID(t *testing.T) {
ctx := context.Background()
t.Run("given record does not exist, should return error", func(t *testing.T) {
id, err := uuid.Parse(fake.UUID().V4())
assert.NoError(t, err)
_, err = sut.GetByID(ctx, id)
var targetErr *store.RecordNotFoundError
assert.ErrorAs(t, err, &targetErr)
})
}
在我们的下一个测试中,我们将首先使用 dbHelper 插入一条记录,然后使用我们的 sut(被测系统)加载该记录,最后验证插入的记录是否与加载的记录相同。
func TestGetByID(t *testing.T) {
...
t.Run("given record exists, should return record", func(t *testing.T) {
movie := createMovie()
err := dbHelper.AddMovie(ctx, movie)
assert.Nil(t, err)
defer func() {
err := dbHelper.DeleteMovie(ctx, movie.ID)
assert.Nil(t, err)
}()
storeMovie, err := sut.GetByID(ctx, movie.ID)
assert.Nil(t, err)
assertMovieEqual(t, movie, storeMovie)
})
}
创建测试
Create 的第一个测试很简单,我们将为 createMovieParam 生成一些假数据,使用 sut 创建一条新记录,然后我们将使用我们的帮助程序从数据库加载该记录并断言该记录已正确保存。
func TestCreate(t *testing.T) {
ctx := context.Background()
t.Run("given record does not exist, should create record", func(t *testing.T) {
p := store.CreateMovieParams{}
fake.Struct().Fill(&p)
p.TicketPrice = math.Round(p.TicketPrice*100) / 100
p.ReleaseDate = fake.Time().Time(time.Now()).UTC()
err := sut.Create(ctx, p)
assert.Nil(t, err)
defer func() {
err := dbHelper.DeleteMovie(ctx, p.ID)
assert.Nil(t, err)
}()
m, err := dbHelper.GetMovie(ctx, p.ID)
assert.Nil(t, err)
expected := store.Movie{
ID: p.ID,
Title: p.Title,
Director: p.Director,
ReleaseDate: p.ReleaseDate,
TicketPrice: p.TicketPrice,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
assertMovieEqual(t, expected, m)
})
}
第二个测试是检查如果 id 已经存在,该方法是否返回错误。 我们将首先使用 dbHelper 添加新记录,然后尝试使用 PostgresMoviesStore 创建新记录。
func TestCreate(t *testing.T) {
...
t.Run("given record with id exists, should return DuplicateKeyError", func(t *testing.T) {
movie := createMovie()
err := dbHelper.AddMovie(ctx, movie)
assert.Nil(t, err)
defer func() {
err := dbHelper.DeleteMovie(ctx, movie.ID)
assert.Nil(t, err)
}()
p := store.CreateMovieParams{
ID: movie.ID,
Title: movie.Title,
Director: movie.Director,
ReleaseDate: movie.ReleaseDate,
TicketPrice: movie.TicketPrice,
}
err = sut.Create(ctx, p)
assert.NotNil(t, err)
var targetErr *store.DuplicateKeyError
assert.ErrorAs(t, err, &targetErr)
})
}
更新测试
为了测试更新,首先我们将创建一条记录,然后调用store的Update方法来更新记录。 更新记录后,我们将使用 dbHelper 加载保存的记录并断言保存的记录已更新值。
func TestUpdate(t *testing.T) {
ctx := context.Background()
t.Run("given record exists, should update record", func(t *testing.T) {
movie := createMovie()
err := dbHelper.AddMovie(ctx, movie)
assert.Nil(t, err)
defer func() {
err := dbHelper.DeleteMovie(ctx, movie.ID)
assert.Nil(t, err)
}()
p := store.UpdateMovieParams{
Title: fake.RandomStringWithLength(20),
Director: fake.Person().Name(),
ReleaseDate: fake.Time().Time(time.Now()).UTC(),
TicketPrice: math.Round(fake.RandomFloat(2, 1, 100)*100) / 100,
}
err = sut.Update(ctx, movie.ID, p)
assert.Nil(t, err)
m, err := dbHelper.GetMovie(ctx, movie.ID)
assert.Nil(t, err)
expected := store.Movie{
ID: movie.ID,
Title: p.Title,
Director: p.Director,
ReleaseDate: p.ReleaseDate,
TicketPrice: p.TicketPrice,
CreatedAt: movie.CreatedAt,
UpdatedAt: time.Now().UTC(),
}
assertMovieEqual(t, expected, m)
})
}
删除测试
为了测试删除,首先我们将使用 dbHelper 添加一条新记录,然后在 sut 上调用 Delete 方法。 为了验证记录是否已成功删除,我们将再次使用 dbHelper 加载记录并断言它返回错误,字符串结果集中没有行。
func TestDelete(t *testing.T) {
ctx := context.Background()
t.Run("given record exists, should delete record", func(t *testing.T) {
movie := createMovie()
err := dbHelper.AddMovie(ctx, movie)
assert.Nil(t, err)
defer func() {
err := dbHelper.DeleteMovie(ctx, movie.ID)
assert.Nil(t, err)
}()
err = sut.Delete(ctx, movie.ID)
assert.Nil(t, err)
_, err = dbHelper.GetMovie(ctx, movie.ID)
assert.NotNil(t, err)
assert.ErrorContains(t, err, "sql: no rows in result set")
})
}
运行集成测试
运行以下 go test 命令来运行集成测试。 请记住,运行这些测试的先决条件是启动数据库服务器并应用迁移。
DATABASE_URL=postgresql://postgres:Password123@localhost:5432/moviesdb?sslmode=disable go test ./integrationtests
CI 中的集成测试
我还添加了 2 个 GitHub Actions 工作流程来运行这些集成测试作为 CI 的一部分。
使用 GitHub 服务容器设置 Postgres
在此工作流程中,我们将使用 GitHub 服务容器来启动 Postgres 服务器。 我们将构建迁移容器并将其作为构建过程的一部分运行,以在运行集成测试之前应用迁移。 这是完整的列表。
name: Integration Test Postgres (Go)
on:
push:
branches: [ "main" ]
paths:
- 'integration-test-postgres-go/**'
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: integration-test-postgres-go
services:
postgres:
image: postgres:14-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: Password123
POSTGRES_DB: moviesdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.20'
- name: Build
run: go build -v ./...
- name: Build migratinos Docker image
run: docker build --file ./db/Dockerfile -t movies.db.migrations ./db
- name: Run migrations
run: docker run --add-host=host.docker.internal:host-gateway movies.db.migrations "postgresql://postgres:Password123@host.docker.internal:5432/moviesdb?sslmode=disable" up
- name: Run integration tests
run: DATABASE_URL=postgresql://postgres:Password123@localhost:5432/moviesdb?sslmode=disable go test ./integrationtests
使用 docker-compose 设置 Postgres
在此工作流程中,我们将使用 docker-compose.dev-env.yml 启动 Postgres 并应用迁移,作为检查代码后工作流程的第一步。 这是完整的列表。
name: Integration Test Postgres (Go) with docker-compose
on:
push:
branches: [ "main" ]
paths:
- 'integration-test-postgres-go/**'
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: integration-test-postgres-go
steps:
- uses: actions/checkout@v3
- name: Start container and apply migrations
run: docker compose -f "docker-compose.dev-env.yml" up -d --build
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.20'
- name: Build
run: go build -v ./...
- name: Run integration tests
run: DATABASE_URL=postgresql://postgres:Password123@localhost:5432/moviesdb?sslmode=disable go test ./integrationtests
- name: Stop containers
run: docker compose -f "docker-compose.dev-env.yml" down --remove-orphans --rmi all --volumes