🔬

GitHub actionsにCypressのCIを実装してみた話

2022/11/05に公開

TL;DR

最近のプロジェクトでCypressを導入し、テストのCIを実装してみました。結構テンプレ的な要素が多いのでメモ用としてこちらに:

name: Testing
on: [pull_request]
jobs:
  e2e:
    if: (contains(github.event.pull_request.title, 'WIP') == false && github.event.pull_request.draft == false)
    
    strategy:
      matrix:
        node-version: [12, 14, 16]
        os: [ubuntu-latest, windows-latest]
    runs-on: ${{ matrix.os }}
    timeout-minutes: 60

    services:
      postgres:
        image: postgres:14.5
        env:
          LANG: en_US.utf8
          POSTGRES_DB: test_db
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
        ports:
          - 5432:5432
        options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
    
    steps:
      - uses: actions/checkout@v3

      - name: Install node ${{ matrix.node-version }}
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}

      - name: Cache client node_modules
        uses: actions/cache@v3
        with:
          path: ./client/node_modules
          key: ${{ runner.os }}-frontend-${{ hashFiles('./client/package-lock.json') }}

      - name: Cache server node_modules
        uses: actions/cache@v3
        with:
          path: ./server/node_modules
          key: ${{ runner.os }}-backend-${{ hashFiles('./server/package-lock.json') }}

      - name: Cache e2e node_modules
        uses: actions/cache@v3
        with:
          path: ./e2e/node_modules
          key: ${{ runner.os }}-e2e-${{ hashFiles('./e2e/package-lock.json') }}

      - name: Cache Cypress Binary
        uses: actions/cache@v3
        with:
          path: ~/.cache/Cypress
          key: ${{ runner.os }}-cy-${{ hashFiles('./e2e/package-lock.json') }}

      - name: Cache npm dir
        uses: actions/cache@v3
        with:
          path: ~/.npm
          key: ${{ runner.os }}-ci-home-npm-${{ hashFiles('./.github/workflows/test.yaml') }}

      - name: Install client dependencies
        uses: bahmutov/npm-install@v1
        with:
          working-directory: client
          install-command: npm install

      - name: Install server dependencies
        uses: bahmutov/npm-install@v1
        with:
          working-directory: server
          install-command: npm install

      - name: Install test dependencies
        uses: bahmutov/npm-install@v1
        with:
          working-directory: e2e
          install-command: npm install

      - name: Import db data
        run: |
          psql -d postgresql://postgres@localhost/test_db -f init_db.sql
        working-directory: ./db/sql
        env:
          PGPASSWORD: postgres

      - name: Run tests
        uses: cypress-io/github-action@v4
        env:
          NODE_ENV: local
          TZ: Asia/Tokyo
          LOG_LEVEL: info
          DB_HOST: localhost 
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          CYPRESS_baseUrl: ${{secrets.FRONTEND_ENDPOINT}}
          CYPRESS_PROJECT_ID: ${{secrets.CYPRESS_PROJECT_ID}}
          CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
        with:
          working-directory: e2e
          browser: chrome
          record: true
          parallel: true
          install: false
          build: |
            npm run --prefix ../server/ build
            npm run --prefix ../client/ build
          start: |
            npm run --prefix ../client/ start
            npm run --prefix ../server/ start
          command: npx cypress run
          wait-on: "http://localhost:3000, http://localhost:3001"
          spec: |
            cypress/**/*cy.{js,ts}
          wait-on-timeout: 300

      - uses: actions/upload-artifact@v3
        if: failure()
        with:
          name: cypress videos
          path: ./e2e/cypress/videos
      - uses: actions/upload-artifact@v3
        if: failure()
        with:
          name: cypress screenshots
          path: ./e2e/cypress/screenshots

次に詳しく説明していきます。

失敗したこと

先に言っておきたいのは、導入がようやくできた前に失敗したことです。やり方の問題かもしれませんが、最終的に諦めて通常のやり方でいきました。

ローカルでactによるテスト

actというツールは、ローカルでdockerを利用して、actionsのワークフローを実行することを可能にしています。ただ、これを使うと、M1のマシンとの相性の問題か、確実に失敗します。詳細はこちらこちらに記録しています。

結局ローカルでのCIのテストを諦めて、自分のフォークでやりまくっていました。もしM1環境でactを使って成功した方がいらっしゃればぜひご経験を共有していただきたいところです。。

Github Actionsでdocker compose

この問題について、一言で言えば、ローカルでdocker compose upで立ち上げたlocalhostサーバーとポートは、actions上の環境ではアクセスできません。毎回localhost:3000がアクセスできないとのタイムアウトエラーで失敗します。

これに関して、おそらく理由はdockerネットワークにおけるホスト名のところにあるのではないかと推測しています。Actions上の環境もコンテナーにあるので、localhostはどれを指しているのか(actionsのubuntuのホストマシンとか)は怪しいところです。一応ワークアラウンドが(こちら)があるようです。

ただ、本来docker composeを使う理由としては、ローカルとほぼ同じやり方でサーバーを立ち上げること=便利、だったので、これだとかなり複雑化してしまうため、actions上でdockerを使うのを諦めました。

テストのジョブ構築

それで最終的に、より一般的なやり方、つまりactionsの環境でビルド+サーバー立ち上げてから、テストを実行する形にしました。最初からこうすれば良くないか、となんか虚しさは感じますが。。

ベース

公式のテンプレからスタートするのが良いでしょう。

name: End-to-end tests
on: [push]
jobs:
  cypress-run:
    runs-on: ubuntu-20.04
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      # Install NPM dependencies, cache them correctly
      # and run all Cypress tests
      - name: Cypress run
        uses: cypress-io/github-action@v4

ここは単純にすべてのプッシュ操作に対して、テストの実行実行することです。これだけでは足りないので、色々と設定を増やしていきます。

コードのビルド

cypressを実行する前に、サーバーを先に立ち上げる必要があります。プロジェクトのフォルダー構造にもよりますが、例えば、今回は次のような構造を例とします。

|- .github
  |- workflows
    |- test.yaml
|- e2e
  |- package.json
|- db
  |- sql
    |- init_db.sql
|- client
  |- package.json
|- server
  |- package.json

フロントエンド、バックエンド、そしてテストのフォルダーはそれぞれ違うところにあります。結構e2eテストのフォルダを、フロントエンドのコードと一緒におく例が見られますが、packageとかコマンドとか混じってしまいますし、見やすさからも分けたほうが良いと思いました。ただこの構造だと、通常のビルドコマンドを少し変更しないといけません。

  cypress-run:
    runs-on: ubuntu-20.04
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Install node 16
        uses: actions/setup-node@v3
        with:
          node-version: 16

      - name: Install frontend dependencies
        uses: bahmutov/npm-install@v1
        with:
          working-directory: client
          install-command: npm install
      - name: Install server dependencies
        uses: bahmutov/npm-install@v1
        with:
          working-directory: server
          install-command: npm install

      - name: Cypress run
        uses: cypress-io/github-action@v4
        with:
          working-directory: e2e
          browser: chrome
          build: |
            npm run --prefix ../server/ build
            npm run --prefix ../client/ build
          start: |
            npm run --prefix ../client/ start
            npm run --prefix ../server/ start
          command: npx cypress run
          spec: |
            cypress/**/*cy.{js,ts}
          wait-on: "http://localhost:3000, http://localhost:3001"
          wait-on-timeout: 300

ここで、cypressのセッションに入る前に、まずフロントエンドとバックエンドのパッケージをインストールしておきます。

そしてcwdをe2eに設定しました。指定しない場合はルートディレクトリーになりますが、cypressデフォルトのcommand: npx cypress runが失敗してしまいますので別途指定が必要になります。その分面倒なのでe2eに設定しておきます。

buildはtsのコードをtscでjsにトランスパイルします。startコマンドで、それぞれのビルドコードでサーバーを立ち上げます。

ちなみに、yarnを使う場合のパス指定は若干違います:

yarn --cwd ../client/ run start

withの内容を見てわかると思いますが、build -> start -> cypress runの順番で実行されます。ただサーバーの立ち上げに時間がかかるので、cypressをその場で始めてしまうと、クライエントサーバーが準備できる前に、リトライの回数上限にヒットして終了になる可能性があります。ここで確実にサーバーを待つためにwait-onを追加しておきます。

これで、actions上でのテストサーバー立ち上げ、テストの実行が可能になりましたが、まだいくつか問題を解決する必要があります。

環境変数を入れる

真先に入ってくるのは環境変数です。プロジェクトの環境変数は必ず存在すると言っても過言ではない。Actions上では、githubのactions secretsとして登録しておくと、ワークフローの中からアクセスすることが可能になります。

      - name: Cypress run
        uses: cypress-io/github-action@v4
        env:
          NODE_ENV: testing
          TZ: Asia/Tokyo
          LOG_LEVEL: info
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          CYPRESS_username: ${{secrets.CYPRESS_USERNAME}}
          CYPRESS_password: ${{secrets.CYPRESS_PASSWORD}}
          CYPRESS_baseUrl: ${{secrets.FRONTEND_ENDPOINT}}
        with:
          working-directory: e2e
          browser: chrome
          build: |
            npm run --prefix ../server/ build
            npm run --prefix ../client/ build
          start: |
            npm run --prefix ../client/ start
            npm run --prefix ../server/ start
          command: npx cypress run
          spec: |
            cypress/**/*cy.{js,ts}
          wait-on: "http://localhost:3000, http://localhost:3001"
          wait-on-timeout: 300

ここで追加しておくと、cypressだけではなく、フロントエンドとバックエンドのサーバーにも読み取ることが可能になります。prefixのCYPRESSREACT_APP, VITEとかあるので同じ名前でぶつかることはないでしょう。どうしてもきれいに分けたい場合は.envファイルをそれぞれ作っておくのもアリかもしれません。

ただ、ローカルでは.envファイルを使ったりすることがあると思いますが、結局actions secretsから取るので、わざわざ.envファイルを作るまでもないと考えました(全部混ぜても)。一応こちらで可能となります。

テストDBセットアップ

これで環境変数も導入され、サーバーとテストは走るはずですが、テスト用のデータも用意したい場合があります。冒頭でdockerの失敗談を話しましたが、一応公式でコンテナーを導入するときに、servicesを利用することが勧められています。ここでは、ポスグレを例とします。

name: Testing
on: [pull_request]
jobs:
  e2e:
    runs-on: ubuntu-20.04
    services:
      postgres:
        image: postgres:14.5
        env:
          # must specify password for PG Docker container image, see: https://registry.hub.docker.com/_/postgres?tab=description&page=1&name=10
          LANG: en_US.utf8
          POSTGRES_DB: test_db
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
        ports:
          - 5432:5432
        # needed because the postgres container does not provide a healthcheck
        options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
    steps:
      - name: Import db data
        run: |
          psql -d postgresql://postgres@localhost/test_db -f init_db.sql
        working-directory: ./db/sql
        env:
          PGPASSWORD: postgres

ここで、stepsに入る前の準備段階で、まずポスグレのコンテナを立ち上げます。イメージはdocker hub上で必要なものを指定するとプルしてくれます。DB名もこの段階で指定しておくと、stepsでデータ導入時はそのままURL使えます。

また、複数のSQLファイルを実行する場合は、-f file1.sql -f file2.sqlとかで可能。ここの実行はパスワード必要となるため、環境変数の形で指定します。

なお、バックエンドサーバーから、こちらのDBコンテナーに接続するときに、localhost:5432で繋がります。BE側の環境変数とかも適宜変更しておくと良いでしょう。

これまでほぼ問題なく、CI上のテスト環境ができたかと思いますが、問題がいくつか残っています。

インストール内容のキャッシング

プロジェクトによって、パッケージインストールのステップで結構時間がかかる可能性があります。そのインストールの内容をどこかでキャッシングすることができれば、ここで時間がだいぶ節約できます。数十秒から1分程度しかないかもしれませんが、CIは高い頻度で実行されるので、回数を重ねていくとかなりの節約になります。

    steps:
      - name: Cache client node_modules
        uses: actions/cache@v3
        with:
          path: ./client/node_modules
          key: ${{ runner.os }}-frontend-${{ hashFiles('./client/package-lock.json') }}

      - name: Cache server node_modules
        uses: actions/cache@v3
        with:
          path: ./server/node_modules
          key: ${{ runner.os }}-backend-${{ hashFiles('./server/package-lock.json') }}

      - name: Cache e2e node_modules
        uses: actions/cache@v3
        with:
          path: ./e2e/node_modules
          key: ${{ runner.os }}-e2e-${{ hashFiles('./e2e/package-lock.json') }}

      - name: Cache Cypress Binary
        uses: actions/cache@v3
        with:
          path: ~/.cache/Cypress
          key: ${{ runner.os }}-cy-${{ hashFiles('./e2e/package-lock.json') }}

      - name: Cache npm dir
        uses: actions/cache@v3
        with:
          path: ~/.npm
          key: ${{ runner.os }}-ci-home-npm-${{ hashFiles('./.github/workflows/test.yaml') }}

キャッシングされた内容に対して、取得するときにキーが必要なので、それぞれのロックファイルに使います。runner.osをつけたのは、後に説明しますが、違うOSで実行する時にキャッシュを混同しないようにするためです。

なお、Cypressのステップでは実は毎回インストールを実行しています。ここでキャッシングするため、 cypressのインストールをオフにして良いでしょう。

      - name: Cypress run
        uses: cypress-io/github-action@v4
        with:
          install: false

複数の環境でテストしたい

同じテストを違うOSの環境、Node.jsのバージョンで実行して、全部パスできるとは限りません。それをすべてカバーするために、バージョン数 x OS数の数のワークフローを作る必要がある、はずがない。

ここでは、複数の環境でテストするための設定を追加します(こちら)。

jobs:
  e2e:
    strategy:
      matrix:
        node-version: [12, 14, 16]
        os: [ubuntu-latest, windows-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}

これでactions上並行して、それぞれのOS x nodejs環境でテストが実行できるようになります。

ワークフローの実行条件

結構近付いてきましたが、実装時に一つ問題を発覚:ワークフロー実行のタイミングです。

on: [push]にするとすべてのプッシュに実行してかなりリソースが要することになって効率的に良くない。すると、on:[pull_request]の方に変更しました。しかし、これでも、WIP・ドラフト状態のPRとかも入ってしまい、失敗してもどうしようもないことです。そもそも、一部のPRをスキップしたいかもしれません。

ワークフローの実行条件をコントロールするために、次のif分を追加しました。

jobs:
  e2e:
    if: (contains(github.event.pull_request.title, 'WIP') == false && github.event.pull_request.draft == false)
    strategy:
      matrix:
        version: [12, 14, 16]
        os: [ubuntu-latest, windows-latest]
    runs-on: ${{ matrix.os }}

つまり、PRがドラフト状態ではないかつタイトルにもWIPが付いていない場合に実行することです。

テスト失敗したら

もう一つ気になるのは、テストが失敗してしまった場合、ログデータもしくはCypressの録画や画像が見られるのか、とのことです。

ここで2つのやり方があります。一つ目はCypress公式のダッシュボードを利用して、テストの録画内容をアップロードすることです。

公式のダッシュボードの利用登録をしておく必要があり、登録後プロジェクトのIDとレコード用のキーが確認できます。こちらのガイダンスに沿っておけば良いのでここでは割愛。

同時に、ワークフローに次の設定を追加します。

      - name: Run tests
        uses: cypress-io/github-action@v4
        env:
          CYPRESS_PROJECT_ID: ${{secrets.CYPRESS_PROJECT_ID}}
          CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
        with:
          record: true

ただこれにはデメリットがあり、無料版では録画の回数制限があります。そのときに録画はアップロードされますが、アップグレードしないと見れなくなります。

見れないと録画の意味がないので、より安定するやり方はこちらとなります。

      - uses: actions/upload-artifact@v3
        if: failure()
        with:
          name: cypress videos
          path: ./e2e/cypress/videos
      - uses: actions/upload-artifact@v3
        if: failure()
        with:
          name: cypress screenshots
          path: ./e2e/cypress/screenshots

これを使うことで、テストが失敗する時だけ、cypressのvideosやscreenshotsに保存されているファイルを、ダウンロードできるようにしてくれます。

終わりに

これで冒頭の設定がようやくできました。最初のローカルでのテストから、最後のCIテストが無事に通るまで他にも色んな細かい問題がありましたが、今回は回り道せずに一通り整理してみました。

Github Actions CIとかで検索すると確かに色々と出てきますが、情報が点在していてまとまったものが少ない印象でした。この内容がどなたに役立つと嬉しいです。

ではでは。

Discussion