Cypress を使った E2E テストを GitHub Actions で実行する

こんにちは!!

vivit で SRE をやっている 宮本 です。

今回は hinata レンタル で行っている E2E テストを GitHub Actions で実行できるように CI パイプラインを構築したので、構成やポイントなどを紹介したいと思います!!

要件とか

  • コミットを GitHub に push したタイミングで E2E テストを実行する
  • E2E テストなので、依存する複数のマイクロサービスも立ち上げる必要がある
  • マイクロサービスのソースコードは CI を実行するリポジトリとは別のリポジトリに存在するものもある
  • 多少実行時間がかかったとしても、なるべくマイクロサービスをモックにすることなく、本番に近い環境でテストすることを優先する
  • vivit は開発環境、本番環境共に k8s を使っているので、CI上も k8s で環境を作れると嬉しい
  • E2E テストのプロセス自体は k8s 上ではなく CI 内で直に実行する
  • バックエンドマイクロサービスが使用するテストデータは簡単に変更できるようにする

vivit の技術スタックについては 、

vivit.hatenablog.com

をご参照ください 🙏

実際の構成

全体の構成を説明するのに不要な部分は削除したり、適宜内容を変えています。
そのままコピペしても動きませんので、ご了承ください。

ざっと以下のような構成となります!

name: E2E test

on:
  push:
    branches:
      - master
  pull_request:
    paths:
      - "aaa/**"

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2

      - id: auth
        uses: google-github-actions/auth@v0.8.0
        with:
          credentials_json: secret

      - name: Set up Cloud SDK
        uses: google-github-actions/setup-gcloud@v0.6.0
        with:
          project_id: secret

      - run: gcloud --quiet auth configure-docker

      - uses: actions/checkout@v3
        name: clone aaa
        with:
          repository: aaa
          ref: master
          path: aaa
          fetch-depth: 3
          token: token

      - name: pull aaa image
        working-directory: aaa
        run: |-
          git checkout master
          tags=($(git rev-parse HEAD | cut -c 1-7) $(git rev-parse HEAD~1 | cut -c 1-7) $(git rev-parse HEAD~2 | cut -c 1-7))
          for tag in "${tags[@]}"; do
            json=$(gcloud container images list-tags asia.gcr.io/secret/aaa --format=json --filter="tags:${tag}")
            if [ "$(echo "$json" | jq '. | length')" -eq 0 ]; then
              echo "docker image(asia.gcr.io/secret/aaa:${tag}) doesn't exists."
            else
              docker pull asia.gcr.io/secret/aaa:"${tag}"
              break
            fi
          done
          image_id=$(docker images --format "{{.Repository}}:{{.Tag}}:{{.ID}}" | grep asia.gcr.io/secret/aaa | cut -f 3 -d ":")
          docker tag $image_id aaa:test

        # 他にも同じ方法で複数マイクロサービスの docker イメージを pull してくる

      - name: start minikube
        id: minikube
        uses: medyagh/setup-minikube@master

      - name: docker build bbb
        uses: docker/build-push-action@v3
        with:
          context: .
          push: false
          load: true
          tags: bbb:test
          file: bbb/Dockerfile
          cache-from: type=local,src=/tmp/.buildx-cache-bbb
          cache-to: type=local,dest=/tmp/.buildx-cache-bbb-new,mode=max

      - name: minikube image load
        run: |
          minikube image load aaa:test
          minikube image load bbb:test

      - name: apply manifest
        run: |
          kubectl apply -k e2e/kubernetes
          sleep 3
          kubectl get pods -o wide

      - name: minikube service mysql
        id: minikube-service-mysql
        run: |
          mysql_address=$(minikube service mysql --url)
          host=$(echo $mysql_address | sed "s/http:\/\///g")
          arr=(`echo $host | tr ':' ' '`)
          echo "::set-output name=mysql_host::${arr[0]}"
          echo "::set-output name=mysql_port::${arr[1]}"

      - name: minikube service bbb
        id: minikube-service-bbb
        run: |
          bbb_url=$(minikube service bbb --url)
          echo "::set-output name=BBB_URL::$bbb_url"

      - name: Use Node.js 13.14.0
        uses: actions/setup-node@v1
        with:
          node-version: 13.14.0

      - name: yarn install
        working-directory: frontend
        run: yarn

      - name: insert test data
        run: |
          mysql \
          -h ${{ steps.minikube-service-mysql.outputs.mysql_host }} \
          -P ${{ steps.minikube-service-mysql.outputs.mysql_port }} \
          -uuser -ppass < e2e/data/bbb.sql

      - name: Run E2E Test
        working-directory: frontend
        env:
          API_URL: ${{ steps.minikube-service-bbb.outputs.BBB_URL }}
        run: |
          yarn build
          yarn test:ci

順番に説明していきます。

1. docker, gcp などが使えるようにセットアップ

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2

      - id: auth
        uses: google-github-actions/auth@v0.8.0
        with:
          credentials_json: secret

      - name: Set up Cloud SDK
        uses: google-github-actions/setup-gcloud@v0.6.0
        with:
          project_id: secret

      - run: gcloud --quiet auth configure-docker

ここは特に言うことありません。
後々、GCP のイメージレジストリである gcr から docker イメージを pull してくる必要があります。
なので GCP プロジェクトに対して認証を行っています。

2. 必要なマイクロサービスのリポジトリを clone してくる

      - uses: actions/checkout@v3
        name: clone aaa
        with:
          repository: aaa
          ref: master
          path: aaa
          fetch-depth: 3
          token: token

依存するマイクロサービスが複数あります。
それらのマイクロサービスが存在する vivit organization の他リポジトリを fetch-deps 3 で clone してきます。

vivit の場合はコミット SHA をタグに使って docker イメージを継続的にビルドしています。
そのため、最新の docker イメージタグを取得するためにリポジトリを clone しています。

3. 必要なマイクロサービスのイメージを gcr から pull してくる

      - name: pull aaa image
        working-directory: aaa
        run: |-
          git checkout master
          tags=($(git rev-parse HEAD | cut -c 1-7) $(git rev-parse HEAD~1 | cut -c 1-7) $(git rev-parse HEAD~2 | cut -c 1-7))
          for tag in "${tags[@]}"; do
            json=$(gcloud container images list-tags asia.gcr.io/secret/aaa --format=json --filter="tags:${tag}")
            if [ "$(echo "$json" | jq '. | length')" -eq 0 ]; then
              echo "docker image(asia.gcr.io/secret/aaa:${tag}) doesn't exists."
            else
              docker pull asia.gcr.io/secret/aaa:"${tag}"
              break
            fi
          done
          image_id=$(docker images --format "{{.Repository}}:{{.Tag}}:{{.ID}}" | grep asia.gcr.io/secret/aaa | cut -f 3 -d ":")
          docker tag $image_id aaa:test

step2 でテストに使用するイメージタグが取得できたので、あとはイメージレジストリから docker pull してくるだけです。

この時、イメージのビルド中であるなどの理由で、gcr 上にイメージが存在せず pull に失敗する可能性があります。
それを防ぐために、力技ですが最新のイメージが存在しない場合には次に新しいイメージを取ってくるようにしています。

pull が完了したらタグをテスト用に書き換えて終了です。

これをテスト実行に必要なマイクロサービスの数だけやっていきます!

4. k8s クラスターを立ち上げる

      - name: start minikube
        id: minikube
        uses: medyagh/setup-minikube@master

いよいよ、CI 内で k8s クラスターを立ち上げます 🎉
今回の場合、シングルノード構成で問題無いので、お手軽にクラスターを構築する為に minikube を利用してみました!

minikube.sigs.k8s.io

公式で紹介されているaction を利用するだけで簡単に構築できました。

5. CI を実行するリポジトリに存在するマイクロサービスをビルドする

      - name: docker build bbb
        uses: docker/build-push-action@v3
        with:
          context: .
          push: false
          load: true
          tags: bbb:test
          file: bbb/Dockerfile
          cache-from: type=local,src=/tmp/.buildx-cache-bbb
          cache-to: type=local,dest=/tmp/.buildx-cache-bbb-new,mode=max

リポジトリで管理されているマイクロサービスに関しては gcr に存在するイメージを pull してきました。

しかし本リポジトリに存在するマイクロサービスに関しては当然、CI 実行時のソースコードでテストを行いたいですよね。
その為、docker イメージの build を行い、後のテストで使えるようにします。

6. 用意した Docker イメージを minikube 上で使えるようにする

      - name: minikube image load
        run: |
          minikube image load aaa:test
          minikube image load bbb:test

ここまでのステップで必要な Docker イメージは CI が動いている環境に用意できました。
しかし minikube は CI の上に VM を立て、その中で k8s クラスターを起動しています。

よってそのままでは CI 上にあるイメージを minikube 側で認識することができません。
minikube image load コマンドを利用して minikube 側でイメージが利用できるようにします。

7. k8s マニフェストを minikube に apply する

      - name: apply manifest
        run: |
          kubectl apply -k e2e/kubernetes
          sleep 3
          kubectl get pods -o wide

いよいよマニフェストファイルを apply してマイクロサービスを全て立ち上げます 😆

これまで用意してきたマイクロサービスのコンテナに加えて、mysql が必要なので起動します。

8. minikube service で E2E テストプロセスから k8s svc にアクセスできるようにする

      - name: minikube service mysql
        id: minikube-service-mysql
        run: |
          mysql_address=$(minikube service mysql --url)
          host=$(echo $mysql_address | sed "s/http:\/\///g")
          arr=(`echo $host | tr ':' ' '`)
          echo "::set-output name=mysql_host::${arr[0]}"
          echo "::set-output name=mysql_port::${arr[1]}"

      - name: minikube service bbb
        id: minikube-service-bbb
        run: |
          bbb_url=$(minikube service bbb --url)
          echo "::set-output name=BBB_URL::$bbb_url"

7 でも説明した通り、 minikube は VM 上で立ち上がるので普通に localhost:{ポート番号}API のエンドポイントとして E2E テストを実行しても疎通できません。。。

minikube service コマンドを使って k8s 内の Service(L4)との通信経路を確立します!
後のステップで使えるように output の処理も行います。

9. mysql にテストデータを入れる

      - name: insert test data
        run: |
          mysql \
          -h ${{ steps.minikube-service-mysql.outputs.mysql_host }} \
          -P ${{ steps.minikube-service-mysql.outputs.mysql_port }} \
          -uuser -ppass < e2e/data/bbb.sql

8 で取得した mysql のアドレスに対してテストデータを INSERT します。

テストデータ自体はシンプルな SQL が書かれたファイルです。
このファイルを変更するだけでテストデータの調整ができるようになっており、アプリケーションエンジニアが頻繁に CI を触る必要が無いようにしています。

10. E2E テストの実行!!

      - name: Run E2E Test
        working-directory: frontend
        env:
          API_URL: ${{ steps.minikube-service-bbb.outputs.BBB_URL }}
        run: |
          yarn build
          yarn test:ci

いよいよ E2E テストを実行します!

その際の API エンドポイントは先ほど minikube service で取得したものです。
環境変数経由で読み込ませることで、 これまでに構築した環境に向けて実行できます。

実行時間

テストの実行までにこれだけやっていると気になるのが実行時間ですよね 🤔
今のところ、トータルで12分程度となっています。

単体テストに比べると明らかに長いですが、ギリギリ許容範囲でしょうか?!
テスト実行の次に時間かかっているのが、 minikube の起動で1分半ほどかかっています。

毎回 k8s をダウンロードしているので、ここをキャッシュ等で早くできると10分程度にできるかもしれません 🙂

E2E テストの場合、実行時間や基盤構築などのコストが上がるのは仕方ないと受け入れているのですが、もっと良い方法があれば知りたいなと思っているところです。

さいごに

長くなってしまいましたが、今回はマイクロサービス構成の E2E テストを Github Actions で実行する方法について解説してみました。

vivit のエンジニアは今回紹介したような新しい取り組みを積極的に行っており、一緒にエンジニアリングできるメンバーを大募集しています!

少しでも興味を持って頂いた方は、是非カジュアル面談にお越しください。

新卒採用

www.wantedly.com

キャリア採用

www.wantedly.com

www.wantedly.com

www.wantedly.com

皆様とお話できるのを楽しみにしております!!