Cypressで始めるE2Eテスト
はじめに
cypress
を使うととても簡単に E2E テストを自分のプロダクトに導入できた[1]のでそのまとめ
記事のターゲット
- E2E テストが気になってる人
ゴール
公開されている環境(staging で basic 認証してる)に対して CI 上で E2E テストを実行し、テスト結果を slack に通知する
環境
必要そうなのだけ
{
"dependencies": {
"next": "10.0.7",
"react": "17.0.1",
"react-dom": "17.0.1",
},
"devDependencies": {
"@slack/web-api": "6.0.0",
"@types/node": "14.14.31",
"cypress": "6.6.0",
"ts-node": "9.1.1"
}
}
導入手順
- Cypress のインストールと初期設定
- e2e のテストを typescript でかけるようにする
- CircleCI で実行する script 作る(E2E 結果を slack へ通知する)
- CircleCI の設定
Cypress のインストールと初期設定
yarn add -D cypress
してyarn run cypress open
すると自動で cypress ディレクトリが自分のプロジェクトに生成される
中身は下記のような感じ
-
fixtures
テスト時のテストデータを json 形式で定義できる -
integration
結合テストの定義ファイル置くところ -
plugins
なんか plugin 作れるんでしょう -
support
カスタムコマンドを定義できるところ
いずれのファイルの使い方や定義の仕方も json や js ファイルがあり、sample が実装されているのでなんとなくイメージできる
cypress open
が GUI 上で E2E テストを確認する
cypress run
がヘッドレスモードで E2E テストを確認する
e2e のテストを typescript で書けるようにする
生成されたファイルは js なので ts に変更すると自分の環境では下記のような ts エラーが出た
プロジェクト直下の tsconfig.json のルールに乗っ取ってないわよと怒られてる
'integrationにあるテストファイル名.ts' cannot be compiled under '--isolatedModules' because it is considered a global script file. Add an import, export, or an empty 'export {}' statement to make it a module.
さらに jest で書いてるテストファイルでもエラーが発生した
cypress をインストールした際に jest も使ってると jest と cypress で expect がのようなグローバルな物が競合してしまうので cypress と jest を書きたい test フォルダで tsconfig を分ける
プロジェクト直下の tsconfig(test ディレクトリに jest のテストが書かれている)
{
"compilerOptions": {
"baseUrl": "./src",
"target": "es5",
"module": "esnext",
"jsx": "preserve",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"noEmit": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"types": ["jest"]
},
"exclude": ["node_modules", "deployments", ".next", "out"],
"include": [
"next-env.d.ts",
"globals.d.ts",
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.js",
"test/**/*.ts"
]
}
cypress ディレクトリ直下の tsconfig
{
"extends": "../tsconfig.json",
"compilerOptions": {
"isolatedModules": false,
"types": ["cypress"]
},
"include": ["../node_modules/cypress", "./**/*.ts"]
}
これで cypress ディレクトリ直下は cypress の type 見てくれるし、プロジェクト直下の include にある ts ファイルたちは jest を見てくれる
ルート直下にcypress.json
を配置するとコンフィグとして cypress 実行時に呼んでくれる
環境ごとに切り分けたい場合はcli のオプションで指定する
cypress open --config-file tests/cypress-config.json
cypress でのテストの書き方はここ見ればだいたいわかる
今回は該当の画面に遷移した時に 200 を返すかどうかのチェックする E2E テストを書いてみる(テストファイル名をindex.spec.ts
のようにしてないのは jest の対象に含まれるのでしてない。多分 cypress ディレクトリだけ対象外にするとかもできるはず)
// stagingはbasic認証かかってるのでenvによって切り替えてる
describe('/', () => {
it('visit page', () => {
if (Cypress.env('name') === 'staging') {
cy.visit('/', {
auth: {
username: 'name',
password: 'password',
},
})
} else {
cy.visit('/upload')
}
// 初回表示時にローディングアニメーションがあるので若干待機してる
cy.wait(5000)
})
})
config
{
"baseUrl": "http://localhost:3000",
"env": {
"name": "local"
}
}
ヘッドレスモード(cypress run
)でテストを実行すると E2E テストの様子がcypress/videos
に mp4 ファイルで格納される
テスト実行中に障害が発生するとcypress/screenshots
に障害発生時の png ファイルが格納される
CircleCI で実行する script 作る(E2E 結果を slack へ通知する)
E2E テストを CircleCI で実行して、実行結果の mp4 と png をいい感じに slack へ通知する
- ci 上で ts ファイルを実行できるように
ts-node
をインストールする - tsconfig を設定する
- slack app の設定(割愛)
- slack に通知できるように
@slack/web-api
をインストールする - E2E 結果を slack で通知する script かく
tsconfig の設定
CI で使う shell などはルート直下にscript
ディレクトリを用意してるのでそこに ts ファイルを格納する
ディレクトリ内にあるファイルの有無や標準入力を受け取るために node.js の標準機能使うので tsconfig を別で作る
module をcommonjs
に変えるのと types でts-node
を指定する
{
"extends": "../tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"isolatedModules": false,
"types": ["ts-node"]
},
"include": ["./**/*.ts"]
}
E2E 結果を slack で通知する script かく
import { Block, KnownBlock, WebClient } from '@slack/web-api'
import { createReadStream, existsSync, readdirSync } from 'fs'
// NOTE: circleCI上の環境変数
const {
CIRCLE_PROJECT_REPONAME,
CIRCLE_WORKFLOW_ID,
SLACK_ACCESS_TOKEN, // うちのプロジェクトではデプロイ周りを通知するslack appのtokenをcircleciのcontextに入れてる
} = process.env
const CHANNEL = 'channel-name' // 通知したいチャンネル名
const SLACK = new WebClient(SLACK_ACCESS_TOKEN)
const sendMessage = async (
blocks: (KnownBlock | Block)[],
text: string,
): Promise<void> => {
await SLACK.chat.postMessage({
channel: CHANNEL,
blocks: blocks,
text: text,
})
}
// /cypress/screenshotsにあるファイル名を取得する
const getScreenShots = (): string[] => {
const basePath = `${process.cwd()}/cypress/screenshots`
if (!existsSync(basePath)) return []
const dirNames = readdirSync(basePath)
const fileNames = dirNames.map((dirName) => {
const fs = readdirSync(`${basePath}/${dirName}`)
return fs.map((f) => `${dirName}/${f}`)
})
return fileNames.flat()
}
const getVideos = (): string[] => {
const basePath = `${process.cwd()}/cypress/videos`
if (!existsSync(basePath)) return []
return readdirSync(basePath)
}
// どんなメッセージになるかはスクショ見てください
const sendHeaderMessage = async (event: 'success' | 'fail'): Promise<void> => {
const message = event === 'success' ? 'E2Eテスト成功!' : 'E2Eテスト失敗!!'
// ここら辺は自分のワークスペースにあるemoji使ってください(なくてもいい)
const emoji = event === 'success' ? ':circleci-pass:' : ':circleci-fail:'
const headerBlocks: (KnownBlock | Block)[] = [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `${emoji} *<https://circleci.com/workflow-run/${CIRCLE_WORKFLOW_ID}|${message}>*`,
},
},
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `repository: *<https://github.com/nus3/${CIRCLE_PROJECT_REPONAME}| ${CIRCLE_PROJECT_REPONAME}>*`,
},
],
},
{
type: 'divider',
},
{
type: 'section',
text: {
type: 'plain_text',
text: '下記はE2E時のスクショたち',
emoji: true,
},
},
]
await sendMessage(headerBlocks, 'header message')
}
// pngをそのままアップロードしてる
const sendScreenShots = (fileNames: string[]) => {
fileNames.forEach((f) => {
SLACK.files.upload({
channels: CHANNEL,
title: f,
file: createReadStream(`${process.cwd()}/cypress/screenshots/${f}`),
})
})
}
// mp4をそのままアップロードしてる
const sendVideos = (fileNames: string[]) => {
fileNames.forEach((f) => {
SLACK.files.upload({
channels: CHANNEL,
title: f,
file: createReadStream(`${process.cwd()}/cypress/videos/${f}`),
})
})
}
const main = async () => {
const screenshots = getScreenShots()
const videoNames = getVideos()
if (!screenshots.length && !videoNames.length) return
if (process.argv.length < 3) return
// 下記のようなコマンドでe2eテストが成功したか失敗したかを引数に受け取る(success | fail)
// ts-node --project scripts/tsconfig.json scripts/report_e2e/main.ts success
const event = process.argv[2] as 'success' | 'fail'
await sendHeaderMessage(event)
if (screenshots.length) {
sendScreenShots(screenshots)
}
if (videoNames.length) {
sendVideos(videoNames)
}
}
main()
slack に通知されるメッセージ
CircleCI の設定
yarn から実行できるコマンドをいくつか登録しとく
"cypress:install": "cypress install",
"cypress:staging:run": "cypress run --config-file cypress.staging.json",
"cypress:success-report": "ts-node --project scripts/tsconfig.json scripts/report_e2e/main.ts success",
"cypress:fail-report": "ts-node --project scripts/tsconfig.json scripts/report_e2e/main.ts fail"
関係ある部分だけ
executors:
with_browsers:
working_directory: /home/circleci/src/
docker:
- image: circleci/node:14.16.0-browsers
# ・・・・
e2e:
# cypressを実行するには必要なdependenciesをいれる
# https://docs.cypress.io/guides/getting-started/installing-cypress.html#Ubuntu-Debian
executor: with_browsers
steps:
- checkout
- attach_workspace:
at: .
- run:
# yarnだけでなくcypressのキャッシュがないとci上で動かないのでcypress installしてる
name: install cypress
command: yarn cypress:install
- run:
name: e2e test
command: yarn cypress:staging:run
- run:
name: notify slack
command: yarn cypress:success-report
# stepが成功したかどうかで実行するスクリプトの引数を変えてる
when: on_success
- run:
name: notify slack
command: yarn cypress:fail-report
when: on_fail
# 今回は必要ないけど万が一slack apiからmp4やpngがアップロードできなかったときのためにartifactsに保存してる
- store_artifacts:
path: cypress/videos
- store_artifacts:
path: cypress/screenshots
# ・・・・
# 今回はstaging→mainへプルリクを生成した時にstagingでe2eを実行する想定
- e2e:
name: e2e testing for stage
context: staging
requires:
- build
filters:
tags:
ignore: /.*/
branches:
only:
- /staging\/.*/
その他
node.js でのファイル操作とか標準入力とかそこらへん調べるのに時間かかったよね・・
あと ts ファイルを簡単に実行したいのに ts-node と tsconfig 周りとか・・
Discussion