Spanner 是 Google Cloud 提供的高度可伸缩和可靠的全球分布式数据库,非常适合需要高性能和持续运行的业务关键型应用。
作为开发者,您需要进行全面的测试,以确保将 Spanner 无缝集成到您的应用中。集成测试的重点是验证在对系统的不同组件进行更改后,这些组件仍然能协同工作。对于 Spanner,通过集成测试可确保应用的数据操作(例如事务处理和错误处理)能正确使用该数据库进行运作。
本文演示了如何使用 GitHub Actions 和 Spanner Emulator 来设置针对 Spanner 的集成测试。Spanner Emulator 可在 Google Cloud 外部模仿 Spanner 的行为,这有助于快速开发由 Spanner 数据库提供支持的应用。
我们将进行测试的示例应用是一项 Golang 后端服务,用于管理一款虚构游戏的玩家个人资料。不过,我们用到的准则也可用于其他语言和行业的不同应用和服务。
我们在这里测试的是 Profile Service 与 Spanner 之间的“集成”,目的是为了构建快速反馈环,确保对服务做出的代码修改能正常运行。这不是堆栈中所有服务之间的完整端到端测试。端到端测试需要在部署到生产环境之前,使用 Spanner 的预演环境进行测试。
如需了解详情,请参阅这篇博文,其中简洁明了地介绍了为验证某个软件版本是否合格,应执行哪些类型的测试。
该集成测试涉及以下组件:
GitHub Actions,内置于我们的代码所在的平台中,负责自动执行测试。其他 CI/CD 平台的运行方式与之类似。
Spanner Emulator,轻量级模拟器,可在离线状态下模拟 Spanner 数据库。
Profile Service,需要使用 Spanner 的应用。
下文提供了有关这些组件的更多详细信息,具体架构如下所示:
使用 Spanner Emulator 对 Profile Service 进行集成测试
GitHub Actions:自动执行工作流
我们的服务代码存储在 GitHub 代码库中,因此 GitHub Actions 是自动进行集成测试的最佳选择。
GitHub Actions 是持续集成和持续交付 (CI/CD) 平台上的一项工具,这种平台可实现软件开发工作流自动化。它与 GitHub 代码库无缝集成,让您可以定义和执行由代码更改或预定事件触发的自动化任务。
Spanner Emulator:本地测试环境
Spanner Emulator 是一个轻量级工具,可以完全离线运行。使用此工具,开发者能够在不产生任何云费用也不依靠实际的 Spanner 实例的情况下,针对 Spanner 测试应用。这有助于缩短开发周期和及早发现集成问题。
与您所熟悉的实际 Spanner 数据库相比,Spanner Emulator 存在一些差异和限制。
设置针对 Profile Service 的集成测试
示例游戏应用的代码可以在 GitHub 上找到。首先,我们将先看一下针对 Profile Service 的集成测试,然后再看一下使用 GitHub Actions 自动进行集成测试的工作流。
Profile Service 集成测试的代码可以在 Profile Service 的 main_test.go 文件中找到。该文件包含以下几部分内容:
启动 Spanner Emulator 的代码。
使用架构(schema)和任何需要的测试数据设置 Spanner 实例和数据库的代码。
设置 Profile Service。
测试代码。
在测试完成后执行清理的代码。
Spanner Emulator 是以容器形式进行部署的,因此我们使用 testcontainers-go 库。这样编写启动模拟器的代码就很简单:
var TESTNETWORK = "game-sample-test" type Emulator struct { testcontainers.Container Endpoint string Project string Instance string Database string } *snip* func setupSpannerEmulator(ctx context.Context) (*Emulator, error) { req := testcontainers.ContainerRequest{ Image: "gcr.io/cloud-spanner-emulator/emulator:1.5.0", ExposedPorts: []string{"9010/tcp"}, Networks: []string{ TESTNETWORK, }, NetworkAliases: map[string][]string{ TESTNETWORK: []string{ "emulator", }, }, Name: "emulator", WaitingFor: wait.ForLog("gRPC server listening at"), } spannerEmulator, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true, }) if err != nil { return nil, err } // Retrieve the container IP ip, err := spannerEmulator.Host(ctx) if err != nil { return nil, err } // Retrieve the container port port, err := spannerEmulator.MappedPort(ctx, "9010") if err != nil { return nil, err } |
上面的代码为模拟器容器设置了一个映射端口 9010,我们可以与其进行通信。至于网络连接,我们使用的是 Docker 网络,因此任何有权访问该网络的容器或进程都可以与“模拟器”容器进行通信。
借助 testcontainers-go 库,我们可以安心等待容器准备就绪,然后继续进行后续操作。
准备就绪后,我们需要捕获主机信息,并将其作为操作系统环境变量公开,同时定义一个 Golang 结构体。稍后,我们将使用这两项来设置实例和数据库。
// 设置实例和数据库时所需的操作系统环境 os.Setenv("SPANNER_EMULATOR_HOST", fmt.Sprintf("%s:%d", ip, port.Int())) var ec = Emulator{ Container: spannerEmulator, Endpoint: "emulator:9010", Project: "test-project", Instance: "test-instance", Database: "test-database", } |
一切就绪后,我们就可以创建 Spanner 实例和数据库了:
// 创建实例 err = setupInstance(ctx, ec) if err != nil { return nil, err } // 定义数据库和架构 err = setupDatabase(ctx, ec) if err != nil { return nil, err } return &ec, nil } |
设置 Spanner 实例和数据库
模拟器运行后,我们需要设置测试实例和数据库。首先,我们来设置实例:
func setupInstance(ctx context.Context, ec Emulator) error { instanceAdmin, err := instance.NewInstanceAdminClient(ctx) if err != nil { log.Fatal(err) } defer instanceAdmin.Close() op, err := instanceAdmin.CreateInstance(ctx, &instancepb.CreateInstanceRequest{ Parent: fmt.Sprintf("projects/%s", ec.Project), InstanceId: ec.Instance, Instance: &instancepb.Instance{ Config: fmt.Sprintf("projects/%s/instanceConfigs/%s", ec.Project, "emulator-config"), DisplayName: ec.Instance, NodeCount: 1, }, }) if err != nil { return fmt.Errorf("could not create instance %s: %v", fmt.Sprintf("projects/%s/instances/%s", ec.Project, ec.Instance), err) } |
以上代码会利用 Spanner 实例的 Golang 库来创建实例。之所以可以这么做,是因为我们在前面设置了 SPANNER_EMULATOR_HOST 环境变量。否则,Spanner 库将查找在您 Google Cloud 项目上运行的实际 Spanner 实例。
现在,我们等待实例准备就绪,然后再继续测试。
// Wait for the instance creation to finish. i, err := op.Wait(ctx) if err != nil { return fmt.Errorf("waiting for instance creation to finish failed: %v", err) } // The instance may not be ready to serve yet. if i.State != instancepb.Instance_READY { fmt.Printf("instance state is not READY yet. Got state %v\\n", i.State) } fmt.Printf("Created emulator instance [%s]\\n", ec.Instance) return nil } |
若要设置数据库,我们需要一个架构文件。该架构文件的来源取决于您的进程。在本例中,我在 Makefile 的“make profile-integration”指令中复制了主实例的架构文件。这样一来,我就能获得与玩家个人资料相关的最新架构。
为了设置数据库,我们需要使用 Spanner 的数据库 Golang 库。
//go:embed test_data/schema.sql var SCHEMAFILE embed.FS func setupDatabase(ctx context.Context, ec Emulator) error { // get schema statements from file schema, _ := SCHEMAFILE.ReadFile("test_data/schema.sql") // TODO: remove this when the Spanner Emulator supports 'DEFAULT' syntax // This is still a problem when using SQL to insert data. see: https://github.com/GoogleCloudPlatform/cloud-spanner-emulator/issues/101 schemaStringFix := strings.Replace(string(schema), "account_balance NUMERIC NOT NULL DEFAULT (0.00),", "account_balance NUMERIC,", 1) schemaStatements := strings.Split(schemaStringFix, ";") adminClient, err := database.NewDatabaseAdminClient(ctx) if err != nil { return err } defer adminClient.Close() op, err := adminClient.CreateDatabase(ctx, &databasepb.CreateDatabaseRequest{ Parent: fmt.Sprintf("projects/%s/instances/%s", ec.Project, ec.Instance), CreateStatement: "CREATE DATABASE `" + ec.Database + "`", ExtraStatements: schemaStatements, }) if err != nil { fmt.Printf("Error: [%s]", err) return err } if _, err := op.Wait(ctx); err != nil { fmt.Printf("Error: [%s]", err) return err } fmt.Printf("Created emulator database [%s]\\n", ec.Database) return nil } |
在此函数中,我们可以处理对架构的修改,以便于模拟器理解。我们必须将架构文件转换为结尾不带分号的一组语句。
数据库设置完成后,我们就可以启动 Profile Service 了。
启动 Profile Service
在这里,我们将 Profile Service 作为另一个可以与模拟器通信的容器(使用 testcontainers-go)启动。
type Service struct { testcontainers.Container Endpoint string } *snip* func setupService(ctx context.Context, ec *Emulator) (*Service, error) { var service = "profile-service" req := testcontainers.ContainerRequest{ Image: fmt.Sprintf("%s:latest", service), Name: service, ExposedPorts: []string{"80:80/tcp"}, // Bind to 80 on localhost to avoid not knowing about the container port Networks: []string{TESTNETWORK}, NetworkAliases: map[string][]string{ TESTNETWORK: []string{ service, }, }, Env: map[string]string{ "SPANNER_PROJECT_ID": ec.Project, "SPANNER_INSTANCE_ID": ec.Instance, "SPANNER_DATABASE_ID": ec.Database, "SERVICE_HOST": "0.0.0.0", "SERVICE_PORT": "80", "SPANNER_EMULATOR_HOST": ec.Endpoint, }, WaitingFor: wait.ForLog("Listening and serving HTTP on 0.0.0.0:80"), } serviceContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true, }) if err != nil { return nil, err } |
Profile Service 准备就绪后,我们需要捕获端点信息,并将其作为结构体公开,以供测试使用:
// Retrieve the container endpoint endpoint, err := serviceContainer.Endpoint(ctx, "") if err != nil { return nil, err } return &Service{ Container: serviceContainer, Endpoint: endpoint, }, nil } |
Spanner Emulator 和 Profile Service 都开始运行后,我们就可以运行测试了。
运行测试
我们的集成测试会使用 testify assert 库,并访问 Profile Service 端点。我们的服务会测试以下行为:
func TestAddPlayers(t *testing.T) { *snip* } func TestGetPlayers(t *testing.T) { *snip* } func TestPlayerLogin(t *testing.T) { *snip* } func TestPlayerLogout(t *testing.T) { *snip* } |
清理
测试运行完毕后,就该清理我们创建的容器了。为此,我们需要运行一个 teardown 函数:
func teardown(ctx context.Context, emulator *Emulator, service *Service) { emulator.Terminate(ctx) service.Terminate(ctx) } |
testcontainers-go 又为我们带来了方便,让我们可以轻松完成清理!
GitHub Actions 工作流
设置 GitHub Actions 非常简单,只需在代码库的 .github/workflows 目录中添加工作流文件即可。
相应操作的行为取决于每个文件的指令。操作是在收到推送请求时触发,还是在收到拉取请求时触发?是更改所有文件才触发操作,还是只更改部分文件就触发操作?运行相应操作需要哪些依赖项?
以下是为 Profile Service 定义的 YAML 操作:
name: Backend Profile Service code tests on: pull_request: paths: - 'backend_services/profile/**' jobs: test: name: Profile Service Tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-go@v3 with: go-version: '^1.19.2' - name: Check go version run: go version - name: Lint files uses: golangci/golangci-lint-action@v3 with: working-directory: ./backend_services/profile args: --timeout 120s --verbose - name: Run unit tests run: | make profile-test - name: Run integration tests run: | make profile-test-integration |
这个简单的 YAML 文件定义了一些任务,这些任务仅在收到包含 backend_services/profile 目录更改的拉取请求时运行。该操作会安装 Go 依赖项,并且会在进行单元测试和集成测试之前运行一些 lint 检查。
用于单元测试和集成测试的 make 命令在代码库的 Makefile 中进行定义。
.PHONY: profile-test profile-test: echo "Running unit tests for profile service" cd backend_services/profile && go test -short ./... .PHONY: profile-test-integration profile-test-integration: echo "Running integration tests for profile service" cd backend_services/profile \\ && docker build . -t profile-service \\ && mkdir -p test_data \\ && grep -v '^--*' ../../schema/players.sql >test_data/schema.sql \\ && go test --tags=integration ./... |
请注意,集成测试设置了 test_data/schema.sql 文件。
完成这一步后,当系统打开带有 Profile Service 更改的拉取请求时,集成测试大致如下所示:
总结
利用 Spanner Emulator 和 GitHub Actions,您可以为使用 Spanner 的应用打造一个强大的集成测试环境。通过这种方法,您可以在开发流程早期发现并解决集成问题,确保 Spanner 与应用顺畅集成。
若要进一步探索 Spanner 的功能,请使用免费试用实例。这样,您就能够在 90 天内免费试用 Spanner,亲身体验其特性和功能。
文章信息
相关推荐
