经过 Cloud Spanner Emulator 进行单元测试

背景

以前没有针对 Spanner 的数据层单元测试框架,后端服务在完成编码后 DAO 层单测的数据,基本经过 Mock 的方式,或对测试数据库的读写来实现。这种单测手段存在一些问题:node

  1. 使用 mock 数据, 在接口和 Service 层代码不变的状况下, 内部的重构须要改写大量的单元测试用例。
  2. 使用测试数据库, 随意修改数据致使的单元测试不稳定, 考虑测试完成后手动清理数据的低效问题。
  3. 使用 mock 数据, 跟落盘到 Spanner 的区别,没法验证 DAO 层的代码是正确的。

目前公司部分服务的单元测试依赖以上两种方式, 本文介绍一种经过模拟 Spanner 的方式, 来解决以上问题。linux

Spanner Emulator

目前有两个业界用得比较多的 Spanner 的模拟器:git

  • handy-spanner: 一个非官方 Spanner 模拟器。内部的存储用到 sqlite3 。 初始化简单,能够在 Go 中做为内置服务器运行。
  • cloud-spanner-emulator:官方 Spanner 模拟器。仅将数据存储在内存中,专门用于针对 Cloud Spanner 应用程序的本地单元测试。

本文仅介绍 cloud-spanner-emulator。github

注:使用 handy-spanner 做为单测和 BDD 测试 的数据存储也是一个不错的选择, 关于 BDD 测试 , 可参考 Go 项目的 BDD 实践

单元测试流程

初始化 Emulator

cloud-spanner-emulator 支持如下几种方式完成初始化:sql

  1. gcloud 内置emulator命令的支持
  2. 预编译的 docker 镜像
  3. 预编译的 linux 二进制文件 (在 Ubuntu 16.04/18.04, CentOS 8, RHEL 8, and Debian 9/10 作过测试)
  4. bazel
  5. 自定义的 docker 镜像

本地能够经过 gcloud 的方式调用 spanner emulator:docker

gcloud config configurations create emulator
gcloud config set auth/disable_credentials true
gcloud config set project your-project-id
gcloud config set api_endpoint_overrides/spanner http://localhost:9010/
gcloud spanner instances create test-instance \
   --config=emulator-config --description="Test Instance" --nodes=1

线上经过预编译 linux 二进制程序 + docker 镜像提供 spanner emulator 环境。shell

对于线上和线下环境 spanner 模拟器初始化步骤的差别,可在项目中的 unit_test.sh 脚本作处理:数据库

# export spanner emulator env
if [ "x_$NODE_ENV" != "x_local" ]; then
  /spanner/emulator/init.sh
  /spanner/emulator/start.sh
fi
export SPANNER_EMULATOR_HOST=localhost:9010

初始化 DB Client

以 Go 项目为例, 咱们的开发脚手架封装有一个 spannerClient,对 DB 的 CURD 操做经过同一个全局的 spannerClient 完成。因此要接入 Cloud Spanner Emulator 仅仅须要在初始化 spannerClient 连上测试的 DB实例:segmentfault

var db = "projects/emulator-project/instances/emulator-instance/databases/example-db"
func InitTestingSpannerClient() {
    cli, err := spanner.NewClient(context.TODO(), db, spanner2.ClientConfig{SessionPoolConfig: spanner2.SessionPoolConfig{
        MaxOpened: 200,
        MinOpened: 5,
        MaxIdle:   10,
    }})
    if err != nil {
        logger.Critical(context.TODO(), "Connecting to spanner emulator failed: %v!",
            zap.Error(err))
    }
    spannerClient = cli
}

同时咱们须要初始化一个 AdminClient, 用于调用Cloud Spanner数据库管理API, 进行 DDL 操做:后端

func InitTestingSpannerAdminClient() {
  emulatorAddr := os.Getenv("SPANNER_EMULATOR_HOST")
    var opts []option.ClientOption
    opts = append(
        opts,
        option.WithEndpoint(emulatorAddr),
        option.WithGRPCDialOption(grpc.WithInsecure()), option.WithoutAuthentication(),
    )
    var err error
    TestingSpannerAdminClient, err = dbadmin.NewDatabaseAdminClient(context.Background(), opts...)
    if err != nil {
        panic(fmt.Sprintf("Setting up testing's Spanner admin client failed: %v", err))
    }
}

操做测试数据

初始化 spannerClient 和 spannerAdminClient 以后, 在作单元测试以前建立数据表,并灌数据到 Emulator。 封装了如下函数,经过原生 Spanner SDK 直接操做数据:

  1. 建立 Tables:

    func CreateTables(ctx context.Context, statements []string) error {
        matches := regexp.MustCompile("^(.*)/databases/(.*)$").FindStringSubmatch(db)
        if matches == nil || len(matches) != 3 {
            return fmt.Errorf("Invalid database id %s", db)
        }
        // if db.State == "READY"
        _, err := TestingSpannerAdminClient.GetDatabase(ctx, &dbadminpb.GetDatabaseRequest{
            Name: db,
        })
        if err == nil {
            return nil
        }
        op, err := TestingSpannerAdminClient.CreateDatabase(ctx, &adminpb.CreateDatabaseRequest{
            Parent:          matches[1],
            CreateStatement: "CREATE DATABASE `" + matches[2] + "`",
            ExtraStatements: statements,
        })
        if err != nil {
            return err
        }
        if _, err := op.Wait(ctx); err != nil {
            return err
        }
        return nil
    }
  2. 更新 Schema:

    func UpdateMockData(ctx context.Context, statements ...string) error {
        op, err := TestingSpannerAdminClient.UpdateDatabaseDdl(ctx, &dbadminpb.UpdateDatabaseDdlRequest{
            Database:   dbName,
            Statements: statements,
        })
        if err != nil {
            return err
        }
        return op.Wait(mockCtx)
    }
  3. 删除 Mock 数据:

    func DeleteMockData(ctx context.Context, table string, key interface{}) (err error) {
        _, err = spannerClient.NativeClient().Apply(ctx, []*spanner2.Mutation{
            spanner2.Delete(table, spanner2.Key{key}),
        })
        return
    }
  4. 新增 Mock 数据:

    func InsertMockData(table string, keys []string, vals []interface{}) (err error) {
        _, err = spannerClient.NativeClient().Apply(context.TODO(), []*spanner2.Mutation{spanner2.Insert(table, keys, vals)})
        return
    }
  5. 查询 Mock 数据:

    func GetMockData(query string, params map[string]interface{}) ([]interface, error) {
        stmt := spanner2.NewStatement(query)
        stmt.Params = params
        rows := spannerClient.NativeClient().Single().Query(context.TODO(), stmt)
        var ret []interface{}
    
        err := rows.Do(func(row *spanner2.Row) error {
            var meta string
            if err := row.Columns(&meta); err != nil {
                return fmt.Errorf("decode Colunms error: %v", err)
            }
            ret = append(ret, meta)
            return nil
        })
      if err != nil {
        return ret, err
      }
      return ret, nil

总结

本文主要介绍了官方的 Cloud Spanner Emulator: 如何初始化环境、进行单元测试以及对 Mock 数据的处理 。经过 Spanner Emulator, 来弥补 GCP Spanenr 在 DAO 层单元测试上的缺失。

参考

  1. cloud-spanner-emulator
  2. 有赞单元测试实践
  3. 使用Cloud Spanner模拟器