Yup と i18next を使ったスキーマの単体テストを作ってみた

f:id:apt-k-ueno:20211220140609j:plain

aptpod Advent Calendar 2021の 23 日目を担当する、製品開発グループ intadsh チームの佐藤 (TK)です。

多言語化されたアプリケーションのフォームの開発で Yup を使ったスキーマを作成する機会があり
Formik や、React Hook Form などのフォーム用のライブラリに適用する前に単体テストを実行したかったので作成してみました。

API ドキュメントをテスト仕様として定義するところから紹介したいと思います。

ゴール

  • 多言語されたバリデーションメッセージが定義されたスキーマのテストが通ること

技術

API ドキュメントからテスト仕様を定義する

レッドパターンを洗い出す

使用するエンドポイントの仕様を読んで、レッドパターン (= バリデーションメッセージを出す条件) を洗い出します。
今回は[POST] /contacts というエンドポイントに対してリクエストを投げることとします。

サンプルのAPI ドキュメント
サンプルのAPI ドキュメント

  • name
    • required なので、未入力の場合
  • age
    • type が integer なので、数値かつ整数ではない入力の場合
    • 0 以上になっているので、-1 以下の入力の場合
  • email
    • type が string (email) なので、メールアドレスの形式ではない場合
  • message
    • required なので、未入力の場合

レッドパターンから文言を決める

洗い出せたので、それぞれの条件で表示する文言を日本語と英語と決めていきます。

箇所 メッセージ (日本語) メッセージ (英語) 条件
name 名前を入力してください。 Please enter name. 未入力の場合
age 数値を入力してください。 Please enter a number. 数値ではない入力の場合
age 整数を入力してください。 Please enter an integer. 整数ではない入力の場合
age 年齢は 0 以上の整数を入力してください。 The integer of Age must be greater than or equal to 0. -1 以下の入力の場合
email メールアドレスが不正な形式です。 Email is in invalid format. メールアドレスの形式ではない場合
message メッセージを入力してください。 Please enter Message. 未入力の場合

これで、それぞれの条件に対応した表示したいメッセージが定義できました。
こちらをテスト仕様とし、次は多言語ライブラリを作成していきます。

※ 今回はサンプルなので最低限のバリデーションを定義しています。

多言語ライブラリを作成する

i18next の Basic sample を参考に文言を定義していきます。

import i18next from "i18next";
import { translations as translationsEN } from "./en/translations";
import { translations as translationsJA } from "./ja/translations";

i18next.init({
  lng: "en",
  debug: false,
  resources: {
    en: {
      translation: translationsEN,
    },
    ja: {
      translation: translationsJA,
    },
  },
});

export { i18next };

今回は日本語と英語で言語ファイルをそれぞれ分けて定義しました。
表示したいメッセージは先程定義したので、それを当てはめていきます。

日本語

import { Translations } from "../types";

export const translations: Translations = {
  "name.required": "名前を入力してください。",
  "age.invalid-type": "年齢は数値を入力してください。",
  "age.integer": "年齢は整数を入力してください。",
  "age.min": "年齢は 0 以上の整数を入力してください。",
  "email.invalid-format": "メールアドレスが不正な形式です。",
  "message.required": "メッセージを入力してください。",
};

英語

import { Translations } from "../types";

export const translations: Translations = {
  "name.required": "Please enter name.",
  "age.invalid-type": "Please enter a number.",
  "age.integer": "Please enter an integer.",
  "age.min": "The integer of Age must be greater than or equal to 0.",
  "email.invalid-format": "Email is in invalid format.",
  "message.required": "Please enter Message.",
};

念の為、言語 (lng)と Key を指定した場合の挙動を確認しておきます。
Jest で簡単な単体テストを書きました。

import { i18next } from ".";

test("name.required en", () => {
  i18next.changeLanguage("en");
  expect(i18next.t("name.required")).toBe("Please enter name.");
});

test("name.required ja", () => {
  i18next.changeLanguage("ja");
  expect(i18next.t("name.required")).toBe("名前を入力してください。");
});

...

テストを実行して問題ないようであれば、この言語設定を実際にスキーマに適用させていきます。
まずはスキーマのテストを書きます。

スキーマのテストを書く

「多言語されたバリデーションメッセージが定義されたスキーマ」が取得されることを期待しているので、
まずは、言語 (lng) を渡して、それに沿ったスキーマが返却されるような関数を想定します。

const { schema } = getSchema("en");

これで指定した言語のスキーマを取得する関数が定義できたので、先に作ったテストパターンを使ってテストケースを定義していきます。

import { getSchema } from "./schema";

describe("英語のスキーマ", () => {
  const { schema } = getSchema("en");

  it("名前が未入力の場合", () => {
    const testValues = {
      name: "",
      email: "simple@example.com",
      message: "message",
    };

    expect(() => schema.validateSync(testValues)).toThrow("Please enter name.");
  });

  it("年齢が数値ではない入力の場合", () => {
    const testValues = {
      name: "name",
      age: "age",
      email: "simple@example.com",
      message: "message",
    };

    expect(() => schema.validateSync(testValues)).toThrow(
      "Please enter a number."
    );
  });

  it("年齢が整数ではない入力の場合", () => {
    const testValues = {
      name: "name",
      age: 0.1,
      email: "simple@example.com",
      message: "message",
    };

    expect(() => schema.validateSync(testValues)).toThrow(
      "Please enter an integer."
    );
  });

  it("年齢が-1 以下の入力の場合", () => {
    const testValues = {
      name: "name",
      age: -1,
      email: "simple@example.com",
      message: "message",
    };

    expect(() => schema.validateSync(testValues)).toThrow(
      "The integer of Age must be greater than or equal to 0."
    );
  });

  it("メールアドレスの形式ではない場合", () => {
    const testValues = {
      name: "name",
      age: 29,
      email: "Abc.example.com",
      message: "message",
    };

    expect(() => schema.validateSync(testValues)).toThrow(
      "Email is in invalid format."
    );
  });

  it("メッセージが未入力の場合", () => {
    const testValues = {
      name: "name",
      age: 29,
      email: "simple@example.com",
      message: "",
    };

    expect(() => schema.validateSync(testValues)).toThrow(
      "Please enter Message."
    );
  });
});

エラー条件に沿った値をschema.validateSync に引数で入れると、ValidationError がスローされるので、Jest の .toThrow を使ってバリデーションメッセージを取得します。

さらに念の為、全ての値がエラー条件に沿った場合のテストケースも定義しておきます。

it("すべての値が不正な場合", () => {
  const testValues = {
    name: "",
    age: -1,
    email: "Abc.example.com",
    message: "",
  };

  schema
    .validate(testValues, { abortEarly: false })
    .catch((validationError) => {
      const validationErrors: Record<string, string> = {};
      const _validationError = validationError as Yup.ValidationError;

      _validationError.inner.forEach((error) => {
        if (error.path) {
          validationErrors[error.path] = error.message;
        }
      });

      expect(validationErrors).toEqual({
        name: "Please enter name.",
        message: "Please enter Message.",
        age: "The number of Age must be greater than or equal to 0.",
        email: "Email is in invalid format.",
      });
    });
});

schema.validate のオプションにabortEarly: false をセットすることによって、すべての検証が実行された後に ValidationError がスローされるようになります。
スローしたエラーの inner から validationErrors としてオブジェクトを生成し、toEqual で検証します。

スキーマを定義する

テストから実際にスキーマが取得できる関数を作成します。

import * as Yup from "yup";
import { i18next } from "../i18n";

import { Schema } from "./types";

export const getSchema = (lang: "en" | "ja") => {
  i18next.changeLanguage(lang);

  const schema: Yup.SchemaOf<Schema> = Yup.object().shape({
    name: Yup.string().required(i18next.t("name.required")),
    age: Yup.number()
      .transform((value, originalValue) =>
        originalValue === "" ? undefined : value
      )
      .typeError(i18next.t("age.invalid-type"))
      .integer(i18next.t("age.integer"))
      .min(0, i18next.t("age.min")),
    email: Yup.string().email(i18next.t("email.invalid-format")),
    message: Yup.string().required(i18next.t("message.required")),
  });

  return { schema };
};

テストを実行する

スキーマとテストを定義したら、実際にテストを実行してみます。

 PASS  src/schemas/schema.test.ts (7.414 s)
  英語のスキーマ
    ✓ 名前が未入力の場合 (16 ms)
    ✓ 年齢が数値ではない入力の場合 (30 ms)
    ✓ 年齢が整数ではない入力の場合 (1 ms)
    ✓ 年齢が-1 以下の入力の場合 (1 ms)
    ✓ メールアドレスの形式ではない場合 (1 ms)
    ✓ メッセージが未入力の場合 (1 ms)
    ✓ すべての値が不正な場合 (1 ms)
    ✓ すべての値が正常な場合 (1 ms)

Test Suites: 1 passed, 1 total
Tests:       8 passed, 8 total
Snapshots:   0 total
Time:        7.921 s

すべてのテストケースが通ったので、最後に正常な値を入れたパターンのテストを定義します。

it("すべての値が正常な場合", () => {
  const testValues = {
    name: "name",
    age: 10,
    email: "simple@example.com",
    message: "message",
  };

  expect(schema.isValidSync(testValues)).toBe(true);
});
    ✓ すべての値が正常な場合 (1 ms)

このように多言語化されたアプリケーションのフォームの開発で Yup を使ったスキーマを作成することで、バリデーションがより安全に実装できます。

また、Formik や、React Hook Form に結合する前にテストをすることによってバリデーションの品質向上につながればと思います。