TestCafe で E2E テストを始めよう #3 - よりプログラマブルな作りにする

前回 ではベーシック認証とユーザーロールという二つの認証をご紹介しました。

今回はもう少し実務に寄り添ったお話ということで、よりプログラマブルな作りにする方法についてご紹介します。

シリーズ一覧

環境変数を読み込む

例えば TestCafe を使ってベーシック認証(HTTP Authentication)が効いているサイトへアクセスする際は、 httpAuth メソッドを使って認証情報を宣言的に記述します。

fixture('My Fixture')
  .page('https://www.example.com')
  .httpAuth({
    username: 'username',
    password: 'password',
  });

また、アカウント認証が必要な web アプリケーションのテストをする際は、User Role 機能を認証処理部分を切り出して定義します。

import { Role, TestController } from 'testcafe';
const regularUser = Role(
  'http://example.com/login',
  async (t: TestController) => {
    await t
      .typeText('#email', 'test-user@example.com')
      .typeText('#password', 'user-password')
      .click('#sign-in');
  }
);

しかしこれら認証情報(ID, Password)を直接テストコード内に記述するのはセキュリティの観点からすべきではありません。解決策として考えられるのは、テスト実行時に外部からデータを読み込むことでしょうか。

web アプリケーションが参照できる外部データといえば Cookie や LocalStorage が一般的です。しかし TestCafe は専用のサーバー上でテストコードを解釈し、そこから対象のサイトへアクセスする仕様です。また、過去のテスト結果に干渉されないよう実行時にブラウザの Cookie やストレージをリセットするため、事前に仕込んでおくことも出来ません。

解決策: 必要な値を環境変数に持たせてテストコードから読み込む

TestCafe は Node.js から実行するため、環境変数やコマンドライン引数を読み込めます。つまり認証情報の類はそこで管理しておけばよいのです。

環境変数は process.env オブジェクトに格納されており、普通に TypeScript ( JavaScript ) コードからも参照可能です。

// ベーシック認証情報
const { BASIC_AUTH_USERNAME, BASIC_AUTH_PASSWORD } = process.env;
fixture('My Fixture')
  .page('https://www.example.com')
  .httpAuth({
    username: BASIC_AUTH_USERNAME,
    password: BASIC_AUTH_PASSWORD,
  });

TestCafe コマンドを実行するマシンに BASIC_AUTH_USERNAME , BASIC_AUTH_PASSWORD という環境変数名でベーシック認証情報が格納されていれば、実行時にそれらの値がテストコードに読み込まれます。

アカウント認証につきましても全く同じ手法でいけます。

import { Role, TestController } from 'testcafe';
const { MEMBER_EMAIL, MEMBER_PASSWORD } = process.env;
const regularUser = Role(
  'http://example.com/login',
  async (t: TestController) => {
    await t
      .typeText('#email', MEMBER_EMAIL)
      .typeText('#password', MEMBER_PASSWORD)
      .click('#sign-in');
  }
);

process.env の型を最適化したい

環境変数は process.env オブジェクトに格納されていますが、TypeScript( @types/node )では下記のように型付けされています。

interface ProcessEnv {
  [key: string]: string | undefined;
}

実質 Object 型です。どんなプロパティが入るかは完全に利用者次第なので当然といえばそうなのですが、どうせならもっと TypeScript らしくより用途に適した型付けしたいものです。

方法はとても簡単で、プロジェクト配下で Ambient 宣言をして元の interface を overload してしまえばよいのです。

declare namespace NodeJS {
  interface ProcessEnv {
    // ベーシック認証情報
    readonly BASIC_AUTH_USERNAME: string;
    readonly BASIC_AUTH_PASSWORD: string;
    // 通常 ユーザ アカウント情報
    readonly MEMBER_EMAIL: string;
    readonly MEMBER_PASSWORD: string;
  }
}

こうすればプロジェクト配下ではこちらの型定義が優先されるので、定義したプロパティ以外は参照できなくなり IDE 補完の恩恵も受けられます。

環境変数は direnv で管理するのがおすすめ

Mac で開発されている方であれば、環境変数の管理に direnv を使うのがおすすめです。 .envrc ファイルに必要な値と変数を記述し、 .gitignore 等でバージョン管理下から除外しておけば安心ですね。

# ベーシック認証情報
export BASIC_AUTH_USERNAME= username
export BASIC_AUTH_PASSWORD=password
# Member ユーザ アカウント情報
export MEMBER_EMAIL=test-user@example.com
export MEMBER_PASSWORD=user-password

コマンドライン引数を活用する

例えば同じ web アプリケーションの E2E テストでも開発用環境/ステージング環境/本番環境と複数ある対象を動的に切り替えたいことがあります。そのようにテストを実行する対象(URL)を動的に選択したい時は、コマンドライン引数を利用すると良いでしょう。具体的には下記のようなコマンドです。

# 引数 variant に dev | stg | prod を渡して実行対象を指定する
$ testcafe chrome ./tests/test1.ts --variant [dev | stg | prod]

コマンドライン引数は process.argv オブジェクトに格納されていますが、 process.env と違ってこちらは極めて扱いにくいので yargs という Node.js ライブラリを使うのがおすすめです。

コマンドライン引数にも型付けしたい

いささか冗長ですが、 yargs#argv (コマンドライン引数)にも下記の方法で TypeScript の型付けが可能です。

import { option } from 'yargs';
type Variant = 'dev' | 'stg' | 'prod';
const variants: ReadonlyArray<Variant> = ['dev', 'stg', 'prod'];
const { variant } = option('variant', {
  choices: variants,
  demandOption: true,
}).argv;

こうすることで variant 変数の型が付与されます。あとは variant の値を key にして各対象の URL をマップしたものを定義すれば OK です。

export const baseUrl = {
  dev: 'https://dev-app.example.com',
  stg: 'https://stg-app.example.com',
  prod: 'https://app.example.com',
}[variant];
// --variant dev なら https://dev-app.example.com となる。

TestCafe を Node.js モジュールから操作する

TestCafe はコマンドラインからだけでなく Node.js モジュールから実行することも可能です。これにより、テスト成功時やエラー時に追加で実行したい処理を入れ込むなどといったことが可能となります。今回は基本的な書き方をご紹介します。

1. テスト実行対象(URL)と実行ブラウザをコマンドライン引数から指定できるようにする

先ほどご紹介したのに加え、実行ブラウザもコマンドライン引数から指定できるようにしてみます。

const { argv } = require('yargs');
/**
 * テストを実行する対象(環境)を指定します。
 *
 * @type {'dev' | 'stg' | 'prod'}
 * @default 'dev'
 */
const variant = argv.variant || 'dev';
/**
 * テストを実行するブラウザを指定します。
 *
 * 'all' を指定するとそのマシンにインストール済みの全ブラウザで実行します。
 * @type {'all' | 'chrome' | 'chrome:headless' | 'firefox' | 'firefox:headless' | 'safari' | 'ie' | 'edge' | 'browserstack:{Chrome|IE|Edge}:Windows 10'}
 * @default 'chrome'
 */
const browsers = argv.browsers || 'chrome';
if (!['dev', 'stg', 'prod'].includes(variant)) {
  console.error('🙅‍♀️ variant must be "dev", "stg" or "prod"');
  process.exit(1);
}
console.info(`variant: ${variant}`);
console.info(`browsers: ${browsers}`);

ひとまず引数を抽出するところまで書きました。

2. TestCafe サーバインスタンスを作成してテストの設定・実行する

下記をコードに落とし込みます。

  1. E2E テスト実施の大元となる TestCafe サーバインスタンスを作成する
  2. テスト起動に使用されるテストランナーを作成する
  3. テストタスクを設定する
  4. 全てのテストケース実施後、失敗した数を表示して終了(正常系)
  5. 実施中に予期せぬエラーが発生したらそこで終了(異常系)
const path = require('path');
const { argv } = require('yargs');
const createTestCafe = require('testcafe');
...(中略)...
let testcafe = null;
// 1.
createTestCafe('localhost', 1337, 1338)
  .then(tc => {
    testcafe = tc;
    const runner = testcafe.createRunner();  // 2.
    // 3.
    return runner
      .screenshots({
        path: './reports/screenshots',
        takeOnFails: true,
      })
      .src(path.resolve(__dirname, `../tests/**/*.ts`))
      .browsers([browsers])
      .run();
  })
  .then(failedCount => {
    // 4.
    console.info(`Tests failed: ${failedCount}`);
    testcafe.close();
  })
  .catch(error => {
    // 5.
    console.error('Error', error);
    testcafe.close();
  });

createRunner() で作成した Runner Class オブジェクトに対してテストタスクを設定します。設定項目ごとに用意されたメソッドを呼び出して設定するわけですが、各種メソッドの戻り値は this となっているため、上記のようにメソッドチェーンが可能となっています。これらは .testcaferc を書くのとほぼ同じ感覚です1)Configuration File | TestCafe。最後に run() を実行してテストを実施します。

run() の戻り値は Promise 型となっており、失敗したテストケースの数を返します。上記のコードでは特になにもせず console に失敗した数を表示してるだけですが、テスト終了時に何かしら処理を入れ込みたい場合は、ここに書くと良いでしょう。最後に close() を実行して TestCafe サーバを停止し、全ての接続を閉じます。ちなみに未実施のテストケース(保留中)も強制的に閉じられます。

今回はここまで。次回は実際のプロダクト開発でも採用しているテストコードの設計についてご紹介します。