經過 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模擬器
相關文章
相關標籤/搜索