Composition API + Jestでコンポーネントを一切マウントせずに書けるフロントエンド単体テストパターン

Composition API + Jest で コンポーネントを一切マウントせずに書ける フロントエンド単体テストパターン

どうもこんにちは! 2021年4月にANDPADにジョインし、現在チームでフロントエンドの開発をしている鳩です。

こちらのVue Composition APIをチームで導入して得られたメリット - ANDPAD Tech Blogでも取り上げられていますが、私のチームでもOptions API + Vuex で実装されていたコンポーネントをComposition API + without Vuex のパターンへ書き換えを行っています。

Composition APIへ書き換える最大のメリットは、コンポーネントのロジック部分の多くを単なるJavaScript関数として切り出せるので、コンポーネントとロジックを疎結合な状態に分離でき、単体テストが恐ろしく書きやすくなるという点です。

そんなわけで、コンポーネントを一切マウントせずに書けるフロントエンドあるあるテストパターンを紹介していこうと思います!レッツゴー!!

テストパターンを紹介する前に

JestのMockライブラリとしての実装仕様は独特です。 他の単体テストで使っていたMockライブラリのように、mock関数、spy関数のようなものがあるんだと思っていると、かなり混乱します。

ドキュメントのspyOnの箇所でも書いてあるとおり、他のテスティングライブラリの振る舞いとは違うものだと思って、ドキュメントを確認しながら実装することをオススメします。

Jestオブジェクト · Jest

注意: デフォルトでは、 jest.spyOn はスパイ化されたメソッドも呼び出します。 これは他の大抵のテストライブラリとは異なる振る舞いです。

↓こちらの記事でなぜ違うのか解説してくれています。

なぜJestのmockライブラリに混乱してしまうのか? - Qiita

jest.mockではなくjest.spyOnでの実装のほうがシンプルに書け、大体やりたいことができるのでjest.mockはあまり使うことはないかもしれないです。

APIコールのテストをする

APIコールする関数が、想定どおりの引数で呼び出されているかテストします。

ディレクトリ構成
ディレクトリ構成

installCompositionApi.jsはcodesandboxでエラーが出るので入れてあるだけなので気にしないでください。 Vue2で[vue-composition-api] must call Vue.use(VueCompositionAPI) before using any function. - Qiita ←こちらの記事を参考にしました。 ありがとう、こういう謎現象を記事にしてくれる素敵なあなたに感謝。

本題ですが、こういうシンプルなフォームがあったとします。

Form.vueコンポーネント

Form.vue

<template>
  <div class="container">
    <div class="columns is-5-tablet is-4-desktop is-3-widescreen">
      <div class="column">
        <h1>お問い合わせフォーム</h1>
        <form class="box">
          <div class="field">
            <label class="label">名前</label>
            <div class="control">
              <input
                v-model="form.name"
                type="text"
                class="input"
                placeholder="ほげ太郎"
              />
            </div>
          </div>
          <div class="field">
            <label class="label">コメント</label>
            <div class="control">
              <input
                v-model="form.comment"
                type="text"
                class="input"
                placeholder="コメント"
              />
            </div>
          </div>
          <div class="field">
            <button class="button is-success" @click.prevent="postForm">
              送信
            </button>
          </div>
        </form>
      </div>
    </div>
  </div>
</template>

<script>
import { defineComponent } from "@vue/composition-api";
import { useForm } from "@/usecases/form";

export default defineComponent({
  setup() {
    const { form, postForm } = useForm();

    return {
      form,
      postForm,
    };
  },
});
</script>

vuexで言うところのstateとactions、Options APIで言うところのdataとmethodsにあたるものを外に切り出します。

form.js

import { reactive } from "@vue/composition-api";
import axios from "axios";

export const useForm = () => {
  const form = reactive({
    name: "",
    comment: ""
  });
  const postForm = () => {
    // 第2引数で、ユーザ入力値が入っているreactiveのformを渡しています。
    axios.post("https://jsonplaceholder.typicode.com/posts", form).then(
      (response) => {
        console.log(response);
      },
      (error) => {
        console.log(error);
      }
    );
  };
  return {
    form,
    postForm
  };
};

form.spec.js

/* global jest, describe, test, expect, beforeAll, afterAll */

import { useForm } from "@/usecases/form";
import axios from "axios";

describe("新規作成", () => {
  // axiosのpostメソッドをmockします
  const mockPost = jest.spyOn(axios, "post");

  // describe配下の全てのテストの前に一度だけ実行されます
  beforeAll(async () => {
    // mockImplementationOnceにすると、もしテスト中に2回axios.postがcallされてる場合、2回目はmockされず本当にapiがcallされます。
    mockPost.mockImplementation(() => {
      return new Promise((resolve) => {
        resolve({});
      });
    });
  });

  // describe配下の全てのテストの後に一度だけ実行されます
  afterAll(() => {
    mockPost.mockClear();
  });

  test("axios.postが特定の引数で呼び出されたかを検証する", () => {
    const { form, postForm } = useForm();

    // ユーザが名前に「ぴよこ」、コメントに「こにゃにゃちわ」と入力した状態を再現します。
    form.name = "ぴよこ";
    form.comment = "こにゃにゃちわ";

    // ユーザが送信ボタンをクリックしたら呼ばれる関数を叩きます。
    postForm();

    // axios.postの引数に、ユーザ入力値がきちんとセットされてaxios.postが実行されているかテストします。
    expect(mockPost).toHaveBeenCalledWith(
      "https://jsonplaceholder.typicode.com/posts",
      form
    );
  });
});

プロダクトコードだとaxiosをラップして呼び出す場合が多いかと思いますが、ラップした関数やメソッドも上記と同様にMockして、呼び出された引数の検証を行うことができます。

この例だとシンプルなので、あまり嬉しいテストに見えないですが、例えば、ユーザ入力フォームの作りが複雑で、フロント側で持っているデータ構造をAPIをコールする前に別のprivateな関数でパラメータ整形するような場合、改修時にデータ整形をミスってAPIに変なデータを投げて保存できなくなるなどのバグをテストで検知できるようになります。

ここでは登場しなかったですが、もう少し複雑なUIだと、mutationsにあたるsetXXXも定義したりすると思うので、そのテストもここで通すことができます。

コンポーネントのUIまで再現したテストを書こうと思うと、もう少し複雑になってくるというのと、 UIに大きな変更があってもAPIに渡すものは変わらないということはよくあることだと思うので、APIコールの単体テストを入れておくのはコスパが良さそうと思っています。

さまざまな入力パターンをテストする

想定される入力パターンを全て網羅的にテストします。

f:id:arm4:20210818153354p:plain
お問い合わせフォームに居住地を追加
f:id:arm4:20210818153519p:plain
選択したエリアで担当のコールセンター名が動的に変化するメッセージを表示する。

例えば、こういう動的に変化するメッセージがあった場合に、選択したエリアのidでコールセンター名を取得するようなロジックを追加することになると思います。

表示で出さない場合でも、フロントで担当コールセンターを判定して宛先が違うメール通知APIを叩き分けるのような場合も同様です。

Form.vue

<template>
// ...
          <div class="field">
            <label class="label">居住地</label>
            <div class="select">
              <select v-model="form.area">
                <option value="">選択してください</option>
                <option :value="id" v-for="(area, id) in Areas" :key="id">
                  {{ area }}
                </option>
              </select>
            </div>
          </div>
          <div v-if="form.area" class="field">
            <span
              v-text="`${getCallCenterName(form.area)}コールセンター`"
            ></span
            >からご連絡いたします。
          </div>
// ...
</template>

<script>
import { defineComponent } from "@vue/composition-api";
import { Areas, useForm } from "@/usecases/form"; // Areasを追加

export default defineComponent({
  setup() {
    const { form, postForm, getCallCenterName } = useForm(); // getCallCenterNameを追加

    return {
      Areas,
      form,
      postForm,
      getCallCenterName,
    };
  },
});
</script>

form.js

import { reactive } from "@vue/composition-api";
import axios from "axios";

export const Areas = {
  1: "北海道",
  2: "東北",
  3: "関東",
  4: "中部",
  5: "近畿",
  6: "中国",
  7: "四国",
  8: "九州",
  9: "沖縄"
};

const CallCenters = {
  1: "東日本",
  2: "西日本",
  3: "九州"
};

export const useForm = () => {
  const form = reactive({
    name: "",
    comment: "",
    area: ""
  });
  const postForm = () => {
    form.call_center_name = getCallCenterName(form.area);
    axios.post("https://jsonplaceholder.typicode.com/posts", form).then(
      (response) => {
        console.log(response);
      },
      (error) => {
        console.log(error);
      }
    );
  };
  const getCallCenterName = (areaId) => {
    const areaNum = Number(areaId) || 0;
    switch (true) {
      case 1 <= areaNum && areaNum <= 4:
        return CallCenters[1];
      case 5 <= areaNum && areaNum <= 7:
        return CallCenters[2];
      case 8 <= areaNum && areaNum <= 9:
        return CallCenters[3];
      default:
        return "";
    }
  };
  return {
    form,
    postForm,
    getCallCenterName
  };
};

こんな感じでgetCallCenterName()を追加したので、単体テストを追加していきたいとします。

form.spec.js

// ...
describe("getCallCenterName", () => {
  const { getCallCenterName } = useForm();

  test.each([
    // id, expected
    ["", ""],
    ["0", ""],
    ["1", "東日本"],
    ["2", "東日本"],
    ["3", "東日本"],
    ["4", "東日本"],
    ["5", "西日本"],
    ["6", "西日本"],
    ["7", "西日本"],
    ["8", "九州"],
    ["9", "九州"]
  ])("getCallCenterName(%s) is %s", (id, expected) => {
    expect(getCallCenterName(id)).toBe(expected);
  });
});

こんなふうにform.spec.jsにテストを追加しました。

f:id:arm4:20210818192224p:plain
getCallCenterNameテスト結果

このように網羅的に入力値と期待値のテストを行う場合、eachを使うと配列を使って一気にテストが書けてテスト結果も分かりやすいので 将来的にコールセンターが増えた場合や、エリア担当地域に変更があった場合も、手動でテストするよりも楽で網羅的に検証することができます。

switchでの判定がいけてないなと思ってリファクタしようとした際も、入力値や判定パターンが多いと検証に漏れが出る可能性があるので、一気に返り値の検証が必要な関数は、網羅的なテストを入れておくとリファクタしやすくて安心です。

test.eachの他にもdescribe.eachもあり、複数のテストをまとめて別のテストデータでテストしていくことができます。

https://jestjs.io/ja/docs/api#describeeachtablename-fn-timeout

test.each

異なるテストデータで同じ内容のテストスイートを行う場合は、test.eachを使用します。 test.eachによりテストスイートを1回だけ記述し、データを渡すことができます。

describe.each

異なるテストデータで同じ内容のテストを行う場合は、describe.eachを使用します。 describe.eachによりテストスイートを1回だけ記述し、データを渡すことができます。

例外が投げられるかテストをする

関数を実行した際に特定の入力値が来たら例外が投げられるかをテストします。

先程のフォームに、フォームのコメントが未入力だった場合は、APIを叩かず例外を投げるという処理を追加したとします。

form.js

// ...
  const postForm = () => {
    // ここを足した
    if (!form.comment.trim()) {
      throw new Error("invalid inputs");
    }

    form.call_center_name = getCallCenterName(form.area);
    axios.post("https://jsonplaceholder.typicode.com/posts", form).then(
      (response) => {
        console.log(response);
      },
      (error) => {
        console.log(error);
      }
    );
  };
// ...

ちゃんと例外が投げられるかテストします。

postFormのテストをしているdescribeの一番最後に、異常系のテストとして足しました。

先程紹介したtest.eachを使って、複数パターンの入力値をテストしてみます。

form.spec.js

// ...
describe("新規作成(postForm)", () => {
// ...
  describe("異常系", () => {
    test.each([
      // input
      [""], // 未入力
      [" "], // 空白
      ["\t"], // タブ
      ["\n"] // 改行
    ])(`コメントが(空=%j)の場合に例外が投げられるか`, (input) => {
      const { form, postForm } = useForm();
      form.name = "ぴよこ";
      form.comment = input;
      // 例外が投げられるかテストする場合は関数でラップする必要があります!!!
      expect(() => {
        postForm();
      }).toThrow(new Error("invalid inputs"));
    });
  });
// ...

ここで注意して欲しいのが、エラーが投げられるかのテストの場合は、関数を関数でラップしなければいけないという点です。

よく見たらドキュメントにもそれっぽいことが書いてあったんですが、「関数の中のコード」というフレーズがどこを指してるかよく分かっておらず読み飛ばしてハマってしまいました。

みんなも注意してね。

https://jestjs.io/ja/docs/expect#tothrowerror

注意: 関数の中のコードはラップしてください。 そうしなければエラーが補足されず、アサーションは失敗します。

このテストを使うと、form.commentにundefinedやnullを渡した場合、trimでエラーが吐かれることも分かりますので、未然に考慮が漏れていないかなどのテストもできます。(TypeScriptであればそのへんは心配ないかと思うんですが、今回はテスト時にデータを書き換えてテストしてみるイメージとして紹介してみました)

f:id:arm4:20210818182335p:plain
コメントが(空=%j)の場合に例外が投げられるか

export defaultやファイル直下でexportされている関数をspyOnしたい

これはテストパターンというよりtipsですが、exportされいる関数そのものをMockするパターンの紹介です。

送信ボタンを押した際にコールセンターへメール通知を飛ばす要件が追加されました。 今回は、通知APIをコールする関数をpostFormの中に追記することにします。

まず通知サービス用のファイルを作ります。

notification.js

import axios from "axios";

export const sendNotification = ({ title, body }) => {
  axios
    .post("https://jsonplaceholder.typicode.com/posts", { title, body })
    .then(
      (response) => {
        console.log(response);
      },
      (error) => {
        console.log(error);
      }
    );
};

form.js

  const postForm = async () => {
    if (!form.comment.trim()) {
      throw new Error("invalid inputs");
    }
    form.call_center_name = getCallCenterName(form.area);
    await axios.post("https://jsonplaceholder.typicode.com/posts", form).then(
      (response) => {
        console.log(response);
      },
      (error) => {
        console.log(error);
      }
    );

    // これを足しました
    sendNotification({
      title: `${form.name}さんよりお問い合わせがありました`,
      body: form.comment
    });
  };

form.spec.jsのpostFormのテストでsendNotificationをMockします。

form.spec.js

// ...
// ファイル直下でexportされている関数をMockする際は、全exportをimportしasで別名を付けておきます。
import * as notificationUsecase from "@/usecases/notification";

describe("新規作成(postForm)", () => {
  const mockPost = jest.spyOn(axios, "post");

  // sendNotificationをMockします。
  // as で別名をつけたnotificationUsecaseを使うことでsendNotificationをMockすることができます。
  const mockSendNotification = jest.spyOn(
    notificationUsecase,
    "sendNotification"
  );

  beforeAll(async () => {
    mockPost.mockImplementation(() => {
      return new Promise((resolve) => {
        resolve({});
      });
    });

    // 追加
    mockSendNotification.mockImplementation(() => {
      return new Promise((resolve) => {
        resolve({});
      });
    });
  });

  afterAll(() => {
    mockPost.mockClear();

    // 追加
    mockSendNotification.mockClear();
  });

  describe("正常系", () => {
    test("axios.postが特定の引数で呼び出されたかを検証する", () => {
    // ...
    });
    test("sendNotificationが特定の引数で呼び出されたかを検証する", () => {
      const { form, postForm } = useForm();
      form.name = "ぴよこ";
      form.comment = "どこでもドアの保証期間はいつまでですか?";

      postForm();

      const expected = {
        title: "ぴよこさんよりお問い合わせがありました",
        body: "どこでもドアの保証期間はいつまでですか?"
      };
      // postFormが呼ばれた際にコールされたsendNotificationの引数が想定どおりかMockを使って検証します。
      expect(mockSendNotification).toHaveBeenCalledWith(expected);
    });
  });

このやり方が意外と思いつかなくて、少し悩んだ経験があるので最後に紹介してみました。

まとめ

今回はコンポーネントを一切マウントせずに書けるフロントエンド単体テストパターンを紹介してみました。

これまで、SeleniumでのE2EテストやSnapshot Testなどの場合、プロジェクトが進むにつれて形骸化、メンテナンスコストの肥大化によりメンバーの意欲がなくなるなど、フロントエンドのUIの変更の多さと実装コストに対して、テストを実装する困難さのほうが勝ってしまい、なかなかフロントエンドでのテストの実装が進まなかったという経験をしてました。

現在のチームでは、Composition APIを使い、コンポーネントとロジックを分離することで、バックエンドの単体テストを書くのと同じ感覚でロジック部分のテストが書けるようになり、非常に手軽かつ効果的なテストの実装ができていると感じます。

先日、ライブラリを入れ替えた際に単体テストが入っていたおかげで、ロジックが壊れていないことに自信が持てたのはもちろん、どんな振る舞いをするものか理解できリグレッションテストをする際にも役立ちました。

私のようにチームへあとから入ったメンバーにとっては、非常に分かりやすくありがたかったです。

最後に

ANDPADでは一緒に働く仲間を募集中です。

ANDPADは現在進行系で新規プロダクトの開発が多く、フロントエンドの技術選定もモダンで、さまざまな技術チャレンジができる環境です。
toB向けUIは難易度が高いものが多く腕に覚えのある方も歯ごたえ十分です。
我こそはという方、ぜひ私たちの仲間になってください!
来て!お願い!一緒に書こう!よろしくお願いします! engineer.andpad.co.jp