🛠️

WebComponent でラップした Vue コンポーネントを Cypress でテストする

2020/11/23に公開

はじめに

レガシーアプリケーションのWebフロントエンド改善に、自己完結型の WebComponent は有力な手段です。

なぜなら、レガシーなWebフロントエンドは以下の特徴を有していることが多いからです。

  • CSSのルールに秩序がない
  • jQueryなどでかなり広い範囲の DOM を変更する
  • グローバルな状態に依存する

Shadow DOM によって、既存の CSS ルールの影響を受けず、JavaScript によるエレメントの改変からUIを守ることができます。
加えて WebComponent の仕様によって、UIパーツにソフトウェア的なインターフェースを明確に定義することができます。
こうすることで、単一のソフトウェアユニットとしてのスコープをUIパーツに定義することができ、ユニットテストや、リファクタリング、UIのデザイン改善を行いやすくなります。

一方で、既存のアプリケーションから隔離されているため、できるだけWebComponent自身で完結していることが望ましいです。
そのため、WebComponet はAPIの通信や自身の状態を更新するなど色々なことを行います。
これらの様々なふるまいを自動でテストできる状態を作ることは、次世代のレガシーにしないためにも重要です。

本文書では、Vue.js をベースに構築した WebComponent を Cypress を使ってテストをするまでの手順を説明します。

コードだけを見れば十分という方は、以下のリポジトリを参照してください。
https://github.com/sterashima78/cypress-vue-webcomponent

プロジェクトの作成

Vue CLI で作成します。

vue create <project-name>

基本的には各自の自由で問題ないと思いますが、 E2E は選択しないでください。
Vue CLI Plugin の E2E テストはSPAを前提としているため、うまくいかないです。

また、Vue.js 自体のバージョンは 2系にしてください。3系は、vue-web-component-wrapper がサポートされていません。

私は、普段から Typescript + Composition API で開発を行っているので、Typescript のサポートを追加し、Class コンポーネントは選択しません。

追加の依存パッケージを追加します。

npm i -D cpx cypress http-server start-server-and-test npm-run-all
npm i axios @vue/composition-api

テスト対象の作成

テスト対象の要件

コンポーネントは、利用者との間に以下のインターフェースを持ちます。

  • Props
    • 利用者が任意の値を渡す
  • Event
    • コンポーネントから利用者へメッセージを送る
  • Slot
    • コンポーネントに任意のコンテンツを挿入する

加えて以下をトリガーに自身の状態を更新します。

  • ライフサイクルイベント
  • ユーザからUIへのアクション
    • ボタンクリック
    • フォーム入力
    • など..

状態の更新は多くの場合はそのまま対応するUIの更新がトリガーされます
また、状態の更新は、何かしらのロジックに基づき直接的に行われることもありますし、Web API などをコールして外部から取得した状態に更新することがります。

これらの機能を持たせたコンポーネントをデモとして実装します。

テスト対象

本筋ではないのでソースを示すだけにします。

以下が、メインのロジックです。useCounter を見ればどんな状態と振る舞いがあるか大体わかります。

src/components/counter.composition.ts
import { ref, computed, Ref, SetupContext } from "@vue/composition-api";
import axios from "axios";

export type Status = "ok" | "error:fetch" | "error:save" | "loading";
export type Message = Omit<Record<Status, string>, "ok">;
export const msg: Message = {
  loading: "読み込み中",
  "error:fetch": "読み込みに失敗",
  "error:save": "保存に失敗"
};

type FetchCount = () => Promise<number>;
type SaveCount = (count: number) => Promise<number>;

export const fetchCount: FetchCount = () =>
  axios
    .get<{ count: string }>("/api/count")
    .then(({ data }) => data.count)
    .then(c => parseInt(c, 10));

export const saveCount: SaveCount = (count: number) =>
  axios
    .post<{ count: string }>("/api/count", { count })
    .then(({ data }) => data.count)
    .then(c => parseInt(c, 10));

export const init = (
  count: Ref<number>,
  state: Ref<Status>,
  fetch: FetchCount,
  emit: SetupContext["emit"]
) => () => {
  state.value = "loading";
  return fetch()
    .then(c => {
      count.value = c;
      state.value = "ok";
    })
    .catch(() => {
      state.value = "error:fetch";
      emit("loaderr");
    });
};

export const save = (
  count: Ref<number>,
  state: Ref<Status>,
  save: SaveCount
) => () =>
  save(count.value)
    .then(c => {
      count.value = c;
      state.value = "ok";
    })
    .catch(() => {
      state.value = "error:save";
    });

export const toMessage = (
  count: Ref<number>,
  state: Ref<Status>,
  messages: Message
) => () => {
  switch (state.value) {
    case "ok":
      return count.value;
    default:
      return messages[state.value];
  }
};

export const useCounter = (
  onMounted: (fn: () => void) => void,
  emit: SetupContext["emit"]
) => {
  const count = ref(0);
  const state = ref<Status>("ok");
  // マウント時に Web API からカウンタの値を取得する
  onMounted(init(count, state, fetchCount, emit));
  return {
    // カウンタの数
    count,
    // 通信などの状態を示す
    status,
    // 表示テキスト
    text: computed(toMessage(count, state, msg)),
    // カウンタを増やす
    increment: () => count.value++,
    // カウンタを減らす
    decrement: () => count.value--,
    // カウンタの値をAPIで送信する
    save: save(count, state, saveCount)
  };
};

コンポーネントが以下。

src/components/Counter.vue
<template>
  <div>
    <h1>{{ msg }}</h1>
    <span v-text="text" />
    <button data-test="increment" @click="increment">increment</button>
    <button data-test="decrement" @click="decrement">decrement</button>
    <button data-test="save" @click="save">save</button>
  </div>
</template>

<script lang="ts">
import Vue from "vue";
import CompositionApi, {
  defineComponent,
  PropType,
  onMounted
} from "@vue/composition-api";
import { useCounter } from "./counter.composition";
Vue.use(CompositionApi);

export default defineComponent({
  props: {
    msg: {
      type: String as PropType<string>,
      default: "default msg"
    }
  },
  setup(_, { emit }) {
    return useCounter(onMounted, emit);
  }
});
</script>

以下のような機能を持ちます。

  • props で表示テキストを変更
  • mounted ライフサイクルイベントで通信
    • 成功時に状態変更
    • 失敗時に状態変更・エラーイベントを送信
  • ボタンクリック時に状態更新
  • ボタンクリック時に API 通信
    • 成功時・失敗時に状態更新

ということで、slot 以外は大体の要素を含んでいるはずです。
WebComponentには Vue の slot-scope などはないので、表示に関する役割が大きいです。
そのため、e2e テストで試験をする利点が小さいと思っているのでここではスコープ外にしています。
何かしらの方法で試験をしたい場合は以下にあるような、スナップショットテストをお勧めします。
https://qiita.com/sterashima78/items/8db32368289e4859480b

テストのための設定

Cypress

Cypress の設定をしていきます。

まずは、 npx cypress open を実行してみてください。
cypress ディレクトリと、いくつかのファイルが生成されたと思います。

cypress 以下に tsconfig.json を追加しておきます。

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

npm scripts

次に npm scripts の設定をします。

以下のタスクを設定して、順番に実行するとこでテストします。

  • vue-cli-service で webcomponents ビルド
  • テスト用のエントリファイル (HTML) 配置
  • テスト用のローカルサーバ起動
  • Cypress 起動 / テスト実行
package.json
{
  "scripts": {
    "build:cypress:test": "npm run build -- --target wc --name v --dest ./cypress-tests --inline-vue \"./src/components/*.vue\"",
    "copy:cypress:template": "cpx cypress/index.html cypress-tests/",
    "serve:cypress": "http-server ./cypress-tests/",
    "preserve:cypress": "run-s build:cypress:test copy:cypress:template",
    "cypress:run": "start-server-and-test 'npm run serve:cypress' http://localhost:8080 'cypress run --headless --browser chrome'",
    "cypress:open": "start-server-and-test 'npm run serve:cypress' http://localhost:8080 'cypress open'"
  },
}

これで、npm run cypress:open をすると、順次タスクが実行されます。
cypress-tests ディレクトリにビルド済み js と HTML が配置されていて、 8080 で serve されていればOKです。
Cypress が終了すると http-server も自動で停止します。

HTML ファイルは、以下のようにビルド済みJSをロードするだけです。

cypress/index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script src="/v.js"></script>
</head>
<body>
  
</body>
</html>

テストを記述する

最後にテストを記述していきます。

まず、Cypress でアクセスする予定の HTML は空っぽなので、ここにテスト対象のコンポーネントをマウントさせたりする必要があります。
これを実現するためのカスタムコマンドを実装します。

そのあと、それぞれの振る舞いに対応する試験を記述していきます。

テスト対象をマウントする

以下のようにカスタムコマンドを実装します。

cypress/support/commands.ts
Cypress.Commands.add("setup", (name: string, template?: string) =>
  cy
    .window({ log: false })
    .then(window => {
      window.document.body.innerHTML = template || `<${name}></${name}>`;
    })
    .then(() => cy.wait(500))
    .then(() => cy.get(name).shadow())
);

型定義も欲しいので、以下も作成します。
Cypress で提供されている型定義を拡張します。

cypress/@types/commands.d.ts
/// <reference types="cypress" />
declare namespace Cypress {
  // eslint-disable-next-line @typescript-eslint/class-name-casing
  interface cy {
    setup(name: string, template?: string): Chainable<Element>;
  }
}

以下のように使います。

cy.setup("v-counter").find("span").should("contain.text", "0")

cy.setup("v-counter") をコールすると、ドキュメントが以下のようになります。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script src="/v.js"></script>
</head>
<body>
  <v-counter></v-counter>
</body>
</html>

加えて、<v-counter> の shadow root が戻り値として得られます。

第二引数に任意のHTML文字列を渡すとそのHTMLがbody 直下に挿入されます。

これで、試験に必要な環境を準備して、shadow root 内部の要素にアクセスできるようになったので、試験を書いていけます。

試験の記述

テストコード全体は、前述のリポジトリを見てもらったほうが早いので、テストの種類ごとに抜粋して説明します。

状態の検証

UIの状態は、その表示に現れているので、UIのテキストや色などを検証します。

例えば、count は何もしていない状態で 0 です。
また、通信など行っていないときは、span 要素に count が表示されるので、以下で検証ができます。

cy.setup("v-counter").find("span").should("contain.text", "0")

このように○○の時は、どのような表示になっているか (どのような状態になっているか)を検証していきます。

props の検証

props が変更されたときの振る舞いを検証します。
ここでは、 msg はそのまま表示テキストであるので、前述を同じ方法で検証します。

describe("props", ()=> {
  it("props が見出しに表示される", ()=> {
    const props = "foo bar"
    cy.visit("http://localhost:8080")
    // 後で参照するために要素に alias 設定する
    cy.setup("v-counter").find("h1").as("title");
    // 設定前はデフォルト
    cy.get("@title").should("contain.text", "default");
    // props を設定する ($el は jQuery オブジェクト)
    cy.get("v-counter").then($el => $el.attr("msg", message));
    // 表記が変わったことを確認
    cy.get("@title").should("contain.text", props );
  })
})

API のモック

このコンポーネントは 外部の API に依存しています。
テスト用のAPIサーバがあればそこに向けておけばいいと考える人もいるかもしれませんが、自動テストという性質を考えると、APIサーバの状態に依存させると、テストが不安定になるなどのデメリットがあるため、モックをしておくのが適当と考えています。

実際のAPIをコールさせたい場合は、アプリケーション全体レベルでのテストをデザインして別途行うのが、良いと思います。

Cypress でAPIをモックするには以下を行います。

  • cy.server() をコール
  • cy.route(options) でモックするAPIを設定する

例えば、初期状態の取得について試験を記述しようとすると、以下を検証するかと思います。

  1. 対象ページにアクセスする (mounted ライフサイクルで通信開始する)
  2. テキストが通信中テキストになる
  3. 通信が完了する
  4. テキストが API から戻ってきた値になっている

以下のように記述します。

import { msg } from "../../../src/components/counter.composition";
describe("Counter", () => {
  const baseUrl = "http://localhost:8080/";
  beforeEach(() => {
    // モック設定
    cy.server();
  });
  describe("初期化通信成功", () => {
    it("initialize", () => {
      const count = 10000;
      // GET /api/count の仕様を設定
      cy.route({
        method: "GET",
        url: "/api/count",
        response: {
          count
        },
        delay: 2000
      })
      // 後で参照するためにエイリアス設定
      .as("init")
      cy.visit(baseUrl);
      cy.setup("v-counter").as("root");
      // ロード中
      cy.get("@span").should("contain.text", msg["loading"]);
      // 通信待ち
      cy.wait("@init");
      // 初期値
      cy.get("@span").should("contain.text", count);
    });
  });
});

エラーの時は、route のオプションで status を指定して再現します。

cy.route({
   method: "GET",
   url: "/api/count",
   status: 500,
   response: "",
   delay: 2000
}).as("init");

コンポーネントのイベント

コンポーネントの発行するイベントを検証します。

このコンポーネントでは、初期化通信に失敗したときにイベントを発行する仕様だったので、それを検証します。

describe("Counter", () => {
  const baseUrl = "http://localhost:8080/";
  beforeEach(() => {
    // モック設定
    cy.server();
  });
  describe("初期化通信失敗", () => {
    it("初期化通信失敗時はエラーメッセージが表示", () => {
      // GET /api/count で通信失敗
      cy.route({
        method: "GET",
        url: "/api/count",
        status: 500,
        response: "",
        delay: 2000
      }).as("init");
      cy.visit(baseUrl);
      cy.setup("v-counter").as("root");
      cy.get("@root")
        .find("span")
        .as("span");
      // 後で検証するために発行されたイベント保持する
      const events: number[] = [];
      // イベントが発行されたら保管ここでは、イベントが発行するたびに 0 を入れておく (ペイロードがあるイベントならそれを入れればいい)
      cy.get("v-counter").then($el => $el.on("loaderr", () => events.push(0)));
      cy.get("@span").should("contain.text", msg["loading"]);
      cy.wait("@init");
      cy.get("@span").should("contain.text", msg["error:fetch"]);
      // イベントの検証
      cy.wrap(events).should("eql", [0]);
    });
  });
});

UIに対するイベント

最後に UI パーツに対するイベント駆動で状態が変更されるような機能の検証です。
これは直観的でわかりやすいですが、一応例を示しておきます。

インクリメントボタンをクリックするとカウンタがインクリメントすることを検証します。

describe("Counter", () => {
  const baseUrl = "http://localhost:8080/";
  beforeEach(() => {
    // モック設定
    cy.server();
  });
  it("increment", () => {
      const count = 10000;
      cy.route({
        method: "GET",
        url: "/api/count",
        response: {
          count
        },
        delay: 2000
      })
      cy.visit(baseUrl);
      cy.setup("v-counter").as("root");
      cy.get("@root")
        .find("span")
        .as("span");
      // increment ボタンにエイリアス設定
      cy.get("@root")
        .find("[data-test=increment]")
        .as("button");
      // クリック前の表示
      cy.get("@span").should("contain.text", count);
      // クリック
      cy.get("@button").click();
      // 表示の更新
      cy.get("@span").should("contain.text", count + 1);
      // クリック
      cy.get("@button").click();
      // 表示の更新
      cy.get("@span").should("contain.text", count + 2);
    });
});

要素の参照に使うセレクタ

今回の例では h1 , span など一つしか要素が存在しないタグはタグ名で取得していましたが、data-test などのテスト用のカスタム属性で値を参照するほうがいいです。

マークアップの構造などにはテスト側ができるだけ興味を持たないように設計できるほうが、テストが壊れにくいです。

ビルド済みコードにテストに関係する情報が含まれてしまうのを懸念する方もいると思いますが、以下などを使うことでテスト時以外は取り除くことができます。

https://github.com/LinusBorg/vue-cli-plugin-test-attrs

まとめ

  • レガシープロジェクトを改良するのに WebComponent は有効
  • WebComponet の品質維持のために Cypress で自動テストを書こう
  • テストの内容は、WebComponet が持つ主なふるまいを網羅できるようにしよう (Cypress をちゃんと使えばできる)

Discussion