TestCafe で E2E テストを始めよう #4 - 関心の分離・メンテナブルなテストを書くためのベストプラクティス

前回 では環境変数やコマンドライン引数をテストコードから読み込む方法や TestCafe を Node.js モジュールから操作する方法についてご紹介しました。

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

DOM セレクタには専用の data 属性を使う

TestCafe ではテキスト入力やボタンクリックといった "ユーザアクション" の対象となる DOM の指定に CSS セレクタ を用います。 Document#querySelector でおなじみのアレですね。それこそ指定のための書き方はいくらでもありますが、E2E テスト専用の data 属性のみを使うようにしましょう。

普通であれば下記のような class, id, html 要素名あたりを使いがちです。

  • .foo
  • .foo .bar
  • .foo .bar > .baz
  • #target
  • #target > span

しかし class や id は一般的にプロダクションコード内部でのみ参照されるためのもので内向きの存在であり、外部の存在である E2E のテストコードがその存在を認識・参照出来てしまうのは、コンテキストの混在を招き、コードの関心がゴチャゴチャに混合して可読性・保守性が著しく損なわれてしまいます。

また、#target > span のようなネスト記法は、テストコードが DOM ツリー構造に強く依存してしまいます。DOM ツリーは UI 変更でいくらでもその構造が変わりうるため、その度にテストコードが壊れるリスクに晒されます。

E2E テストとそのプロダクト開発は疎結合でなくてはなりません。極端な話、両者を担当するメンバーないしチームは完全に別であってもおかしくないのです。ということは E2E テスト担当はプロダクションコード内の構造は知る由もありませんし、いつ変更されるかも分からない class, id, DOM ツリー構造に頼ることなど危険過ぎて出来るわけがありません。であれば E2E テスト用の DOM セレクタは専用インターフェースとしての data 属性に限定し、テストコードはそのインターフェースだけに関心を持っていれば安心となります。

// NG 🙅‍♀️
test('My first test', async (t: TestController) => {
  await t
    .typeText('#developer-name', 'wakamsha')
    .click('#submit-button');
});
// OK 🙆‍♀️
test('My first test', async (t: TestController) => {
  await t
    .typeText('[data-e2e="name"]', 'wakamsha')
    .click('[data-e2e="submit"]');
});

Page models を使う

data 属性を使うことで DOM セレクタを抽象化できたまでは良いですが、それがテストコードに直接記述されていてはまだ不十分です。UI(DOM)ツリー構造は変更が入りやすく、その度にテストコード側も修正するのは本質的でなく保守性にも欠けます。

そこで DOM セレクタを TestCafe のセレクタにマッピングするだけの Model 層を作ります。ユーザアクションの対象となる DOM はテストコードからは直接参照せず、Model 層を経由して参照しましょう。

例として下記のテストコードを改善してみます。

import { Selector } from 'testcafe';
...(中略)...
test('Test アカウントでログインする -> ログイン成功する', async t => {
  await t
    .typeText('input[type="email"]', 'test-user@example.com')
    .typeText('input[type="password"]', 'test-password')
    .pressKey('enter')
    .expect(Selector('[data-e2e="page-title"]').innerText)
    .eql('ホーム');
});

Model の粒度は特に理由が無ければ Page 単位にしておくのが良いでしょう。このテストケースでは "ログインページ", "ホームページ" の二つにまたがっていますので、 Entrance.ts , Home.ts をそれぞれ作成します。

import { Selector } from 'testcafe';
const inputEmail = Selector('input[type="email"]');
const inputPassword = Selector('input[type="password"]');
export const Entrance = {
  inputEmail,
  inputPassword,
} as const;
import { Selector } from 'testcafe';
const pageTitle = Selector('[data-e2e="page-title"]');
export const Home = {
  pageTitle,
} as const;

公式ドキュメントでは class で定義していますが、インスタンスによる状態管理といったケースも無いのでシンプルに const 定義だけで済ませています。

Model が出来たので、テストコード側を改修します。

import { Entrance } from '../models/Entrance';
import { Home } from '../models/Home';
...(中略)...
test('Test アカウントでログインする -> ログイン成功する', async t => {
  await t
    .typeText(Entrance.inputEmail, email)
    .typeText(Entrance.inputPassword, password)
    .pressKey('enter')
    .expect(Home.pageTitle.innerText)
    .eql('ホーム');
});

テストコードから selector が消え去り、ユーザアクション対象への関心が薄まりました。これにより UI(DOM)ツリー構造に変更が入ったとしても Model 層のみを改修するだけで追従できるようになり、保守性が向上しますね。

締め: 疎結合と関心の分離

テストコードとテスト対象 UI (DOM ツリー)をいかに疎結合に保つかについてご紹介しました。

E2E テストコードにおける DOM セレクタは、例えるならフロントエンドから見た API ドキュメントと同じような存在です。フロントエンドのプログラムは API の仕様(エンドポイントやリクエストパラメータなど)だけを知っていれば充分であり、API の内部実装については一切知る必要がありません。これもまた関心の分離の一種と言えます。

E2E テストにおいても同様です。E2E テストコードは、テスト対象となる UI の DOM ツリー構造を把握する必要などなく、ユーザアクションに必要な箇所を特定できるセレクタ情報だけを知っていればよいはずです。その必要なセレクタ情報とういうのが data-e2e という data 属性であり、これをまとめてマッピングした Model 層は E2E テストにおける API ドキュメント に相当するということです。