🎄

実例 ConvenienceFixture, orDefault() / TypeScript一人カレンダー

2024/12/21に公開

こんにちは、クレスウェア株式会社の奥野賢太郎 (@okunokentaro) です。本記事はTypeScript 一人 Advent Calendar 2024の17日目です。昨日は『実例 Result<T, E>』を紹介しました。

テストとモックの煩雑さに立ち向かう

我々開発者が取り組むべき姿勢は「1にテスト、2にテスト、3、4がテストで、5にテスト」です。

昨日の記事でも触れたように、筆者は技術顧問として株式会社トレタモバイルメニューサービスを設計・開発しております。飲食業界向けのシステムを開発していると、他社システムとの連携が複雑に絡むため、自由に触れない他社のデータや、筆者が所有していない実物のハードウェアを伴う操作が必要になる機能など、自動テストの実施が困難なケースが多々あります。

その結果、API仕様書などを頼りに、開発者側で膨大なモックデータを用意する必要が出てきます。とはいえ、毎回JSONのすべての項目を忠実に書いているとあまりに量が多くなり、テストコードが冗長になってしまいます。本カレンダーの1日目から述べているようにTypeScriptの活用によって堅牢性を常に確保したい気持ちはありますが、テストの記述性改善や作業時間短縮も同時に実現したいというニーズが生まれてきます。

だからといってテストコードならば雑に書いてよい、というわけでもありません。

Branded typesとテストモックの両立

2日目3日目の記事でValibotのBranded typesを活用するメリットを紹介しました。Branded typesを活用すると値の取り違えは激減し安全な開発が臨めますが、その分テスト用モックデータを記述するときも値を毎回Brandedに変換する必要があります。

たとえばasItemId()asUserName()といったパース関数を使わなければならず、安全性と引き換えに可読性が損なわれがちです。

また、テスト対象によっては、すべてのプロパティが重要とは限りません。たとえばcreatedmodifiedといったプロパティは時間計算のロジックで重要だが、image_pathpriceなどのプロパティはある場面ではあまり重要でないことがあります。一方、priceが金額計算に使われるテストならば厳密性が求められるなど、テスト対象の処理や何であるかやユースケースによって、注目したいプロパティが何なのかも変化します。

テストごとに「どのパラメータがテスト対象として関心があり、どのパラメータが興味のないものか」を表明したいときに、毎回忠実に厳密なモックを書いていると「さほど重要でないパラメータ」にまで高い堅牢性を求められ、結果として記述性や可読性が落ちていきます。これらを「雑に書く以外」の手段で、安全かつ簡便に書くことはできないでしょうか。

ConvenienceFixture型とorDefault()関数

こうした問題を解決するために筆者が用意したのがConvenienceFixture型とorDefault()関数です。

ConvenienceFixture型は、元の型に存在するプロパティを別の型に部分的に置き換えることを可能にし、orDefault()関数によって、特定パラメータが未指定の場合にデフォルト値を返却できるようにします。

type ExactKeys<T, U> = Exclude<keyof T, keyof U> extends never
  ? Exclude<keyof U, keyof T> extends never
    ? T
    : never
  : never;

export type ConvenienceFixture<
  T,
  U extends { [K in keyof U]: any },
> = keyof U extends keyof T
  ? Partial<ExactKeys<U, Partial<Pick<T, keyof U>>>>
  : never;

orDefault()関数はorDefaultFactory()関数の戻り値として扱います。最初に関数を初期化し、初期化された関数を使ってデフォルト値の適用を扱いますが、その際に型パラメータを豊富に取り入れることで、コードエディタの補完に滞りがないことを重視しています。

export function orDefaultFactory<T extends object>(
  params: T,
): <K extends keyof T>(key: K, defaultValue: Required<T>[K]) => Required<T>[K] {
  return function orDefault(key, defaultValue) {
    const ret = Object.hasOwn(params, key) ? params[key] : defaultValue;
    if (typeof ret === "undefined") {
      throw new PreconditionError("invalid");
    }
    return ret;
  };
}

これらを使うことで、たとえば次のようなモック作成関数を柔軟に書くことができます。以下のコードは全て株式会社トレタで実際に使用しているコードの抜粋であり、掲載許可をいただいております。

// Valibot にて定義された サービスタブ項目を返却するエンドポイントの本来のスキーマ
const serviceTab$ = $({
  __typename: literal("menuTabs"),
  originId: tabId$,
  type: literal("service"),
  iconPath: filledString$,
  label: filledString$,
  recommendation: null_(),
  categories: pipe(array(neverUsed()), empty()),
  visible: boolean(),
});

type ServiceTabParams = ConvenienceFixture<
  InferOutput<typeof serviceTab$>,
  Readonly<{
    originId: string;
    iconPath: string;
    label: string;
  }>
>;

/**
 * サービスタブ項目を返却するエンドポイントのレスポンスを模倣したい関数
 */
export function serviceTab(params?: ServiceTabParams): ServiceTabResponse {
  const orDefault = orDefaultFactory(params ?? {});

  return {
    __typename: "menuTabs",
    originId: asTabId(orDefault("originId", cuid())),
    type: "service",
    iconPath: asFilledString(orDefault("iconPath", "path/to")),
    label: asFilledString(orDefault("label", "サービス")),
    recommendation: null,
    categories: [],
    visible: true,
  };
}

サービスタブはというものは、飲食店において「お冷」や「おしぼり」といった商品以外の項目を取り扱う機能です。

これによって、ドメインモデル的に、recommendation(おすすめの商品)は必ずnullcategoriesは必ず[], visibleは常にtrue(必ず表示される)といった固有の定数があります。多言語対応を見越したlabelも、現状は「サービス」という日本語の固定値で問題ありません。アイコンパスもテスト中のモックなら表示不要なので固定値でよい場合があります。

こういった「常にこうなる」という値が含まれる場合、orDefault()のおかげで、originIdiconPathなどをテストごとに変更しつつ、それ以外のプロパティはすべてデフォルト値に任せるといった書き方が可能です。

// すべてデフォルト値を使った「単なる数合わせのモック」がほしいとき
const tab1 = serviceTab();

// id の一致を検証するようなテストで使いたいとき
const tab2 = serviceTab({
  originId: 'id',
});

// iconPath をもとに CDN のパラメータを最適化する処理で複数の iconPath を扱いたいとき
const tab3 = serviceTab({
  iconPath: 'somethingPath',
});

これにより、どのパラメータがテストの関心対象で、どのパラメータが重要でないかをテストコード上で明示でき、モックの記述性と可読性が大幅に改善できます。

ConvenienceFixture型の型テスト

Vitestでの型テストConvenienceFixture型が想定通りに動作するかを確認できます。以下のテストは業務で実際に使用しているコードです。

import { describe, expectTypeOf, test } from "vitest";

import type { FilledString } from "./filled-string";
import type { ConvenienceFixture } from "./convenience-fixture";

type Original = {
  a: FilledString;
  b: number;
  c: boolean;
};

describe("ConvenienceFixture", () => {
  describe("元の型に存在するプロパティを使って別の型を割り当てたオブジェクト型を作成できる", () => {
    test("case 0", () => {
      type Expected = {
        a?: string;
        b?: string;
        c?: string;
      };

      type Actual = ConvenienceFixture<
        Original,
        {
          a: string;
          b: string;
          c: string;
        }
      >;

      expectTypeOf<Actual>().toEqualTypeOf<Expected>();
    });

    test("case 1", () => {
      type Expected = {
        a?: number;
        b?: number;
        c?: number;
      };

      type Actual = ConvenienceFixture<
        Original,
        {
          a: number;
          b: number;
          c: number;
        }
      >;

      expectTypeOf<Actual>().toEqualTypeOf<Expected>();
    });
  });

  test("元の型に存在するプロパティの一部を使って別の型を作成できる", () => {
    type Expected = {
      a?: string;
      b?: string;
    };

    type Actual = ConvenienceFixture<
      Original,
      {
        a: string;
        b: string;
      }
    >;

    expectTypeOf<Actual>().toEqualTypeOf<Expected>();
  });

  test("元の型に存在しないプロパティを宣言すると never", () => {
    type Expected = never;

    type Actual = ConvenienceFixture<
      Original,
      {
        a: string;
        b: string;
        c: string;
        d: string;
      }
    >;

    expectTypeOf<Actual>().toEqualTypeOf<Expected>();
  });
});

このような仕組みを導入すると、プロダクションコード側で求める厳密な型要求と、テストコード側で必要な記述性・柔軟性の両方をうまく両立できます。たとえば、次のようなケースでもConvenienceFixture型を活用すれば「プロダクションでの厳密さ」と「テストでの記述性改善」のバランスをとることが可能です。

  • プロダクションコードでは、厳密なFilledStringを扱う
    • テスト記述時には、開発者が目視で注意すればよいだけなのでstring程度で十分
  • プロダクションコードでは、金額型にcurrencyCodeプロパティとJPY値のペアが必須
    • テスト記述時には、JPY前提でよいのでnumberだけの記述で十分
  • プロダクションコードでは、UnixTimeのミリ秒が必須
    • テスト記述時には、わかりにくいのでISO日時の文字列で書きたい

ConvenienceFixture型を活用するとany型で雑に済ませるより補完が効き、柔軟にしつつも安全性をそこまで犠牲にしないところが利点です。Branded typesは便利だが厳しすぎてテストが煩雑になっている、だからといってany型は使いたくない…といった状況で、このような「安全かつ簡便」を目指す工夫は有用です。

テストとプロダクションコードでどの程度の厳密さや柔軟性が必要かを見極めながら、TypeScriptの活用によって実現できる要望をさまざまな視点から検討し、一つ一つ試みていくとよいでしょう。どこまで厳密に堅牢に定義し、どこで柔軟性を持たせるべきか、このバランスを見極めて全体の開発速度を底上げすることが、TypeScript開発の醍醐味といえます。

明日は『実例 AllowTransitionFrom』

本日は『実例 ConvenienceFixture, orDefault()』を紹介しました。明日は『実例 AllowTransitionFrom』を紹介します。それではまた。

Discussion