GitHub actionsにCypressのCIを実装してみた話
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のCYPRESS
やREACT_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