背景

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

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

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

Spanner Emulator

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

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

本文仅介绍 cloud-spanner-emulator。

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

单元测试流程

初始化 Emulator

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

  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:

1
2
3
4
5
6
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 环境。

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

1
2
3
4
5
6
# 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实例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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 操作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
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:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    
    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:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    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 数据:

    1
    2
    3
    4
    5
    6
    
    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 数据:

    1
    2
    3
    4
    
    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 数据:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    
    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模拟器