wheatandcatの開発ブログ

React Nativeで開発しているペペロミア & memoirの技術系記事を投稿してます

scenarigoでE2Eを実装

APIのE2Eテストを実装したいなーと思い調べていたところ、以下の記事を見つけてscenarigoが、良さげだなと思ったので実装してみた。

engineering.mercari.com

PR

github.com

実装

scenarigo実行まで

scenarigoはGO製のscenario-based API testingのツール。シンプルな作りで使いやすいツールだったので採用した。

github.com

基本的にな使い方はREADMEを参照して貰うとして、memoirで実装した部分について記載していく。 scenarigoではyamlを作成し、その情報を元にE2Eテストを行う。

以下のyamlファイルを作成

e2e/scenarigo.yaml

schemaVersion: config/v1

scenarios:
- user.yaml

次に実行するE2Eテストを記載する。まずはユーザーの作成を実行する

e2e/user.yaml

title: ユーザー
steps:
- title: login
  include: './login.yaml'
  bind:
    vars:
      userToken: "{{vars.userToken}}"
- title: ユーザーを作成する
  protocol: http
  request:
    method: POST
    url: "http://{{env.HOST}}/query"
    header:
      Authorization: "Bearer {{vars.userToken}}"
      Content-Type: application/json
    body: 
      query: |-
        mutation CreateAuthUser($input: NewAuthUser!) {
          createAuthUser(input: $input) {
            id
          }
        }
      variables: 
        input:
          id: test_id
          isNewUser: true
  expect:
    body:
      data:
        createAuthUser:
          id: test_id

scenarigoではincludeを使用することで別のyamlファイルから設定を取得できる。

- title: login
  include: './login.yaml'
  bind:
    vars:
      userToken: "{{vars.userToken}}"

各APIを実行するためにFirebaseのAuthトークンが必要なので、E2Eテストの実行前にAuthトークンを設定したlogin.yamlを生成する以下のスクリプトを生成して、includeするように修正。

e2e/script/main.go

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io/ioutil"
    "log"
    "net/http"
    "os"

    firebase "firebase.google.com/go"
    "github.com/joho/godotenv"
    "google.golang.org/api/option"
    "gopkg.in/yaml.v2"
)

const verifyCustomTokenURL = "https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyCustomToken?key=%s"

type User struct {
    UserToken string `yaml:"userToken"`
}

type LoginYaml struct {
    Vars User
}

func main() {
    err := godotenv.Load(".env")
    if err != nil {
        log.Fatalf("読み込み出来ませんでした: %v", err)
    }

    ctx := context.Background()
    opt := option.WithCredentialsFile("../serviceAccount.json")
    config := &firebase.Config{ProjectID: os.Getenv("FIREBASE_PROJECT_ID")}
    app, err := firebase.NewApp(ctx, config, opt)
    if err != nil {
        log.Fatalf("error initializing app: %v\n", err)
    }

    client, err := app.Auth(ctx)
    if err != nil {
        log.Fatalf("error getting Auth client: %v\n", err)
    }

    token, err := client.CustomToken(ctx, "some-uid")
    if err != nil {
        log.Fatalf("error minting custom token: %v\n", err)
    }

    req, err := json.Marshal(map[string]interface{}{
        "token":             token,
        "returnSecureToken": true,
    })
    if err != nil {
        log.Fatalf("error minting custom token: %v\n", err)
    }

    apiKey := os.Getenv("FIREBASE_API_KEY")

    resp, err := postRequest(fmt.Sprintf(verifyCustomTokenURL, apiKey), req)
    if err != nil {
        log.Fatalf("error minting custom token: %v\n", err)
    }

    var respBody struct {
        IDToken string `json:"idToken"`
    }
    if err := json.Unmarshal(resp, &respBody); err != nil {
        log.Fatalf("error minting custom token: %v\n", err)
    }

    ly := LoginYaml{}
    ly.Vars.UserToken = respBody.IDToken

    if err := WriteOnFile("./login.yaml", ly); err != nil {
        log.Fatalf("WriteOnFile: %v\n", err)
    }

    os.Chmod("./login.yaml", 0777)
}

func postRequest(url string, req []byte) ([]byte, error) {
    resp, err := http.Post(url, "application/json", bytes.NewBuffer(req))
    if err != nil {
        return nil, err
    }

    defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("unexpected http status code: %d", resp.StatusCode)
    }
    return ioutil.ReadAll(resp.Body)
}

func WriteOnFile(fileName string, data interface{}) error {
    // ここでデータを []byte に変換しています。
    buf, err := yaml.Marshal(data)
    if err != nil {
        return err
    }
    // []byte をファイルに上書きしています。
    err = ioutil.WriteFile(fileName, buf, os.ModeExclusive)
    if err != nil {
        return err
    }
    return nil
}

生成されるlogin.yamlのファイルは以下の通り

■ e2e/login.yaml

vars:
  userToken: *******************************

上記のファイルを変数化することで以下のAPI実行部分で使用できる

- title: ユーザーを作成する
  protocol: http
  request:
    method: POST
    url: "http://{{env.HOST}}/query"
    header:
      Authorization: "Bearer {{vars.userToken}}" // ← ここで使用

これでAPI実行が行える状態になった。
但し、このまま実行すると実行毎にscenarigoを実行毎にユーザーデータがfirestoreに作成されてしまう。
今回はローカルで起動して、E2Eテストを行う想定なので、ローカルでFirestoreのエミュレータを起動して接続する方式で実装。

firebase.google.com

まず、firestoreのエミュレータを起動のための設定ファイルを作成

e2e/simulator/firebase.json

{
  "emulators": {
    "firestore": {
      "port": 3600
    },
    "ui": {
      "enabled": true,
      "port": 3601
    }
  }
}

以下のコマンドでエミュレータを起動

$ npx firebase --config e2e/simulator/firebase.json emulators:start

┌───────────┬────────────────┬─────────────────────────────────┐
│ Emulator  │ Host:Port      │ View in Emulator UI             │
├───────────┼────────────────┼─────────────────────────────────┤
│ Firestore │ localhost:3600 │ http://localhost:3601/firestore │
└───────────┴────────────────┴─────────────────────────────────┘
  Emulator Hub running at localhost:4400
  Other reserved ports: 4500

port=3600でfirestoreが起動、3601で確認用のUIブラウザが起動

http://localhost:3601/firestoreにアクセスする以下のように表示される

f:id:wheatandcat:20211031113339p:plain

次は、以下のコマンドでローカルサーバー起動時のfirestoreの接続先をエミュレータに変更して起動
※ FIRESTORE_EMULATOR_HOSTを指定すると自動でエミュレータに接続するようになる

$ FIRESTORE_EMULATOR_HOST=localhost:3600 air

この状態で以下のコマンドを実行

$ cd e2e
$ cd go run script/main.go  // ← login.yamlを作成
$ HOST=localhost:8080 scenarigo run
ok      user.yaml   0.278s

テストが成功したので、実際にfirestoreのデータを確認すると以下の通り正しくデータが作成されている

f:id:wheatandcat:20211031114316p:plain

これで正常に動作しているのが確認できた。上記ではデータ確認のためfirestoreのUIを起動したが、実際に手元でテストを実行する場合は以下のコマンドを使用すると実行時にエミュレータが起動して、終了後に停止するので便利

$ px firebase --config e2e/simulator/firebase.json emulators:exec 'HOST=localhost:8080 scenarigo run'

実際には、この後ユーザーの取得や更新といったテストを追加するが、この記事では、そこは省略してアイテムの作成→取得の実装を紹介

scenarigoでアイテム作成→取得までの実装

まず、scenarigoのconfigファイルにitem.yamlを追加

e2e/scenarigo.yaml

schemaVersion: config/v1

scenarios:
- user.yaml
- item.yaml

item.yamlは以下の通りに作成

e2e/item.yaml

title: アイテム
steps:
- title: login
  include: './login.yaml'
  bind:
    vars:
      userToken: "{{vars.userToken}}"
- title: アイテムを作成する①
  bind:
    vars:
      itemID: "{{response.data.createItem.id}}"
  protocol: http
  request:
    method: POST
    url: "http://{{env.HOST}}/query"
    header:
      Authorization: "Bearer {{vars.userToken}}"
      Content-Type: application/json
    body: 
      query: |-
        mutation CreateItem($input: NewItem!) {
          createItem(input: $input) {
            id
            title
            date
            categoryID
            like
            dislike
            createdAt
            updatedAt
          }
        }
      variables: 
        input:
          title: 散歩に行った
          categoryID: 1
          date: 2021-01-01T00:00:00+09:00
          like: true
          dislike: false

- title: アイテムを取得する
  protocol: http
  request:
    method: POST
    url: "http://{{env.HOST}}/query"
    header:
      Authorization: "Bearer {{vars.userToken}}"
      Content-Type: application/json
    body: 
      query: |-
        query Item($id: ID!) {
          item(id: $id) {
            id
            title
            categoryID
            date
            like
            dislike
          }
        }
      variables: 
        id: "{{vars.itemID}}"
  expect:
    body:
      data:
        item: 
          id: "{{vars.itemID}}"
          title: 散歩に行った
          categoryID: 1
          date: 2021-01-01T00:00:00+09:00
          like: true
          dislike: false

アイテムを作成する①のResponseで返ってくるidの値を使用して、アイテムを取得するのRequest パラメータに設定する必要がある。

実装方法は以下のbind.varsにResponseで取得できる値をitemIDとして設定

  bind:
    vars:
      itemID: "{{response.data.createItem.id}}"

上記の値を以下のように使用することで、Responseで返ってくる値をRequest パラメータに設定することができる。

      variables: 
        id: "{{vars.itemID}}"

上記を実装してscenarigoを実行すると以下の通りテストは成功し、

$ HOST=localhost:8080 scenarigo run
ok      user.yaml   0.278s
ok      item.yaml   0.100s

アイテムのデータも正しく設定できるのを確認できた。

f:id:wheatandcat:20211031115841p:plain

GitHub Actionsでscenarigoを実行する

最後にGitHub Actionsでのscenarigoの実行方法を紹介。
実装したGitHub Actionsは以下の通り

.github/workflows/scenarigo.yml

name: e2e test
on: [push]
env:
  FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
  SERVICE_ACCOUNT_JSON: ${{ secrets.REVIEW_SERVICE_ACCOUNT_JSON }}
  GCP_SERVICE_ACCOUNT_JSON: ${{ secrets.REVIEW_GCP_SERVICE_ACCOUNT_JSON }}
  APP_LOCAL_EMV: ${{ secrets.APP_LOCAL_EMV }}
  ENVENB_GO: ${{ secrets.ENVENB_GO }}
  AUTH_CONFIG: ${{ secrets.AUTH_CONFIG }}
jobs:
  e2e:
    name: test
    runs-on: ubuntu-latest
    steps:
    - name: Checkout repository
      uses: actions/checkout@v2
    - name: Create serviceAccount.json file
      run: echo $SERVICE_ACCOUNT_JSON | base64 --decode -i > ./serviceAccount.json
    - name: Create gcpServiceAccount.json file
      run: echo $GCP_SERVICE_ACCOUNT_JSON | base64 --decode -i > ./gcpServiceAccount.json
    - name: Create .env file
      run: echo $APP_LOCAL_EMV | base64 --decode -i > ./.env
    - name: Create envenb.go file
      run: echo $ENVENB_GO | base64 --decode -i > ./envenb.go
    - name: Create e2e/.env file
      run: echo $AUTH_CONFIG | base64 --decode -i > ./e2e/.env
    - uses: actions/setup-go@v2
      with:
        go-version: '^1.16'
    - run: go version
    - name: cache
      uses: actions/cache@v2
      with:
        path: ~/go/pkg/mod
        key: ${{ runner.os }}-memoir-backend-${{ hashFiles('**/go.sum') }}
        restore-keys: |
          ${{ runner.os }}-memoir-backend-
    - name: Run server
      run: make ci_setup_server
    - name: create auth token file
      run: make create_login_yaml
      working-directory: ./e2e
    - name: setup gcloud
      uses: google-github-actions/setup-gcloud@master
      with:
        version: "latest"
    - name: setup firestore emulator
      run: |
        gcloud components install beta --quiet
        gcloud components install cloud-firestore-emulator --quiet
    - name: setup e2e
      run: |
        gcloud beta emulators firestore start --host-port localhost:3600 &
        go install github.com/zoncoen/scenarigo/cmd/scenarigo@v0.8.1
      working-directory: ./e2e
    - name: run e2e
      run: make scenarigo
      working-directory: ./e2e

firestoreのエミュレータはgcloudコマンドからも起動可能なので、以下のコマンドで起動

    - name: setup gcloud
      uses: google-github-actions/setup-gcloud@master
      with:
        version: "latest"
    - name: setup firestore emulator
      run: |
        gcloud components install beta --quiet
        gcloud components install cloud-firestore-emulator --quiet
    - name: setup e2e
      run: |
        gcloud beta emulators firestore start --host-port localhost:3600 &

以下のコマンドでローカルサーバーを起動

$ FIRESTORE_EMULATOR_HOST=localhost:3600 go run app.go &

最後にscenarigoを実行

    - name: setup e2e
      run: |
        gcloud beta emulators firestore start --host-port localhost:3600 &

上記でGitHub Actionsでもscenarigoが実行できた

https://github.com/wheatandcat/memoir-backend/runs/4055476705?check_suite_focus=true

f:id:wheatandcat:20211031120836p:plain

まとめ

  • シンプルで使いやすいツールだった。
  • シナリオテストはシステムの保守の簡易化につながるので積極的に追加していきたい