🎥

Cypressで始めるE2Eテスト

2021/03/04に公開

はじめに

cypressを使うととても簡単に E2E テストを自分のプロダクトに導入できた[1]のでそのまとめ
https://www.cypress.io/

記事のターゲット

  • E2E テストが気になってる人

ゴール

公開されている環境(staging で basic 認証してる)に対して CI 上で E2E テストを実行し、テスト結果を slack に通知する

環境

必要そうなのだけ

package.json
{
  "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"
  }
}

導入手順

  1. Cypress のインストールと初期設定
  2. e2e のテストを typescript でかけるようにする
  3. CircleCI で実行する script 作る(E2E 結果を slack へ通知する)
  4. 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 を分ける
https://github.com/cypress-io/cypress-and-jest-typescript-example

プロジェクト直下の tsconfig(test ディレクトリに jest のテストが書かれている)

tsconfig.json
{
  "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

cypress/tsconfig.json
{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "isolatedModules": false,
    "types": ["cypress"]
  },
  "include": ["../node_modules/cypress", "./**/*.ts"]
}

これで cypress ディレクトリ直下は cypress の type 見てくれるし、プロジェクト直下の include にある ts ファイルたちは jest を見てくれる

ルート直下にcypress.jsonを配置するとコンフィグとして cypress 実行時に呼んでくれる
https://docs.cypress.io/guides/references/configuration.html#Options

環境ごとに切り分けたい場合はcli のオプションで指定する

cypress open --config-file tests/cypress-config.json

cypress でのテストの書き方はここ見ればだいたいわかる
今回は該当の画面に遷移した時に 200 を返すかどうかのチェックする E2E テストを書いてみる(テストファイル名をindex.spec.tsのようにしてないのは jest の対象に含まれるのでしてない。多分 cypress ディレクトリだけ対象外にするとかもできるはず)

cypress/integration/index_spec.ts
// 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

cypress.json
{
  "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 へ通知する

  1. ci 上で ts ファイルを実行できるようにts-nodeをインストールする
  2. tsconfig を設定する
  3. slack app の設定(割愛)
  4. slack に通知できるように@slack/web-apiをインストールする
  5. E2E 結果を slack で通知する script かく

tsconfig の設定

CI で使う shell などはルート直下にscriptディレクトリを用意してるのでそこに ts ファイルを格納する
ディレクトリ内にあるファイルの有無や標準入力を受け取るために node.js の標準機能使うので tsconfig を別で作る

module をcommonjsに変えるのと types でts-nodeを指定する

scripts/tsconfig.json
{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "module": "commonjs",
    "isolatedModules": false,
    "types": ["ts-node"]
  },
  "include": ["./**/*.ts"]
}

E2E 結果を slack で通知する script かく

scripts/report_e2e/main.ts
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 に通知されるメッセージ
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"

関係ある部分だけ

.circleci/config.yml
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 周りとか・・

脚注
  1. CI 周りとか独自でやってることもあるので参考程度に ↩︎

  2. cypress/integrationにはディレクトリを作ることができるのですが、そうすると e2e テスト実行後の mp4 や png がより深いディレクトリに生成されることになり、script 側で再起的にそれを取得するのが面倒だった ↩︎

GitHubで編集を提案

Discussion