集成测试Postgres商店(GO)
#postgres #测试 #go #githubactions

这是较早的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。我们将加载来自环境的配置,并初始化databaseHelperPostgresMoviesStorefaker的实例。

我们还将添加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方法。获得结果后,我们将验证是否在PostgresMoviesStoreGetAll方法结果中使用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

参考

没有特定顺序