这是较早的REST API with Go, Chi, Postgres and sqlx后期的延续。在本教程中,我将扩展示例以添加集成测试以验证我们对postgres_movies_store
的实现。
为什么集成测试
根据Wikipedia集成测试的定义,是将单个软件模块组合和测试的阶段。
在我们的情况下,这很重要,因为我们使用外部系统来存储我们的数据,并且在我们宣布它已经准备就绪之前,我们需要确保它按预期工作。
我们的选择是
- 一种方法是运行数据库服务器和我们的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
metohd。我们将加载来自环境的配置,并初始化databaseHelper
,PostgresMoviesStore
和faker的实例。
我们还将添加2种助手方法来使用伪造者和一个辅助方法来确保2个kude11的插入量相等,我们将将时间字段比较到最接近的第二个。
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)
}
测试
我将通过该方法进行组测试,然后在测试MEHTOD中使用t.Run
来运行单个方案。我们还可以使用基于表的测试来运行各个方案。例如如果对GetAll
进行了2个测试,则这些测试将在TestGetAll
方法中,然后我将在该方法中使用t.Run
进行单个测试。
也需要在运行测试之前,我们需要启动数据库服务器并应用迁移。运行以下命令执行此操作。
docker-compose -f docker-compose.dev-env.yml up -d
GetAll测试
对于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
方法。获得结果后,我们将验证是否在PostgresMoviesStore
的GetAll
方法结果中使用dbHelper
之前添加了每个记录。我们还将调用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
创建新记录,然后我们将使用助手从数据库中加载记录并断言记录正确保存。<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< br>
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)
})
}
第二个测试是检查方法是否已经存在,该方法是否返回错误。我们将使用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)
})
}
更新测试
要测试更新,首先,我们将创建一个记录,然后调用Update
商店方法以更新RERSOD。更新记录后,我们将使用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
加载记录并断言它将用字符串no rows in result set
返回错误。
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 service containers来启动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
来源
演示应用程序的源代码托管在blog-code-samples存储库的GitHub上。
Integration Test Postgres (Go)
工作流的来源在integration-test-postgres-go.yml中。
Integration Test Postgres (Go) with docker-compose
工作流的来源位于integration-test-postgres-go-docker-compose.yml。
参考
没有特定顺序