百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 编程字典 > 正文

集成测试 Postgres 存储 (Go语言版)

toyiye 2024-06-21 12:23 14 浏览 0 评论

这是之前发布的带有 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

相关推荐

为何越来越多的编程语言使用JSON(为什么编程)

JSON是JavascriptObjectNotation的缩写,意思是Javascript对象表示法,是一种易于人类阅读和对编程友好的文本数据传递方法,是JavaScript语言规范定义的一个子...

何时在数据库中使用 JSON(数据库用json格式存储)

在本文中,您将了解何时应考虑将JSON数据类型添加到表中以及何时应避免使用它们。每天?分享?最新?软件?开发?,Devops,敏捷?,测试?以及?项目?管理?最新?,最热门?的?文章?,每天?花?...

MySQL 从零开始:05 数据类型(mysql数据类型有哪些,并举例)

前面的讲解中已经接触到了表的创建,表的创建是对字段的声明,比如:上述语句声明了字段的名称、类型、所占空间、默认值和是否可以为空等信息。其中的int、varchar、char和decimal都...

JSON对象花样进阶(json格式对象)

一、引言在现代Web开发中,JSON(JavaScriptObjectNotation)已经成为数据交换的标准格式。无论是从前端向后端发送数据,还是从后端接收数据,JSON都是不可或缺的一部分。...

深入理解 JSON 和 Form-data(json和formdata提交区别)

在讨论现代网络开发与API设计的语境下,理解客户端和服务器间如何有效且可靠地交换数据变得尤为关键。这里,特别值得关注的是两种主流数据格式:...

JSON 语法(json 语法 priority)

JSON语法是JavaScript语法的子集。JSON语法规则JSON语法是JavaScript对象表示法语法的子集。数据在名称/值对中数据由逗号分隔花括号保存对象方括号保存数组JS...

JSON语法详解(json的语法规则)

JSON语法规则JSON语法是JavaScript对象表示法语法的子集。数据在名称/值对中数据由逗号分隔大括号保存对象中括号保存数组注意:json的key是字符串,且必须是双引号,不能是单引号...

MySQL JSON数据类型操作(mysql的json)

概述mysql自5.7.8版本开始,就支持了json结构的数据存储和查询,这表明了mysql也在不断的学习和增加nosql数据库的有点。但mysql毕竟是关系型数据库,在处理json这种非结构化的数据...

JSON的数据模式(json数据格式示例)

像XML模式一样,JSON数据格式也有Schema,这是一个基于JSON格式的规范。JSON模式也以JSON格式编写。它用于验证JSON数据。JSON模式示例以下代码显示了基本的JSON模式。{"...

前端学习——JSON格式详解(后端json格式)

JSON(JavaScriptObjectNotation)是一种轻量级的数据交换格式。易于人阅读和编写。同时也易于机器解析和生成。它基于JavaScriptProgrammingLa...

什么是 JSON:详解 JSON 及其优势(什么叫json)

现在程序员还有谁不知道JSON吗?无论对于前端还是后端,JSON都是一种常见的数据格式。那么JSON到底是什么呢?JSON的定义...

PostgreSQL JSON 类型:处理结构化数据

PostgreSQL提供JSON类型,以存储结构化数据。JSON是一种开放的数据格式,可用于存储各种类型的值。什么是JSON类型?JSON类型表示JSON(JavaScriptO...

JavaScript:JSON、三种包装类(javascript 包)

JOSN:我们希望可以将一个对象在不同的语言中进行传递,以达到通信的目的,最佳方式就是将一个对象转换为字符串的形式JSON(JavaScriptObjectNotation)-JS的对象表示法...

Python数据分析 只要1分钟 教你玩转JSON 全程干货

Json简介:Json,全名JavaScriptObjectNotation,JSON(JavaScriptObjectNotation(记号、标记))是一种轻量级的数据交换格式。它基于J...

比较一下JSON与XML两种数据格式?(json和xml哪个好)

JSON(JavaScriptObjectNotation)和XML(eXtensibleMarkupLanguage)是在日常开发中比较常用的两种数据格式,它们主要的作用就是用来进行数据的传...

取消回复欢迎 发表评论:

请填写验证码