🛠️

アプリケーションコードの構造によってテストコードをどう書くか

2024/12/22に公開

はじめに

本記事ではアプリケーションコードの構造によってどのようにテストコードを書くかを紹介します。題材として外部のWebサービスが提供するSDKに依存したアプリケーションを作る例で記載します。

アプリケーションの実装にはReactを使用してTypeScriptで記述します。
テストに使用するライブラリはVitest@testing-library/reactです。

コードの書き方に焦点を当てますので、テスト環境の設定をどのように行うかは公式のドキュメントなどを参考にしてください。

また、本記事で扱う外部Webサービスは架空のものです。実際のサービスやSDKとは無関係となりますのでご注意ください。

外部WebサービスのSDK

今回扱うSDKはHelloWorldSdkクラスとして提供されているものとします。

HelloWorldSdkはいくつかのインスタンスメソッドを持っています。
今回のアプリケーションはその中のgetGreetingメソッドを使うものとします。

getGreetingメソッドのインターフェイスは次のようになっています。非同期で提供元のWebサービスからメッセージ文字列を取得して返します。

type GetGreeting = () => Promise<string>;

メソッドの詳細な内部実装はブラックボックスとなっており、SDKの提供元によって保証されているものであるとします。

今回のテストコードを書くときの基本方針

SDKは提供されたものであるため、SDKの内部実装の詳細部分はテストの関心から外れます。

テストの際にはAPIへの通信部分をモックする(例えばfetch関数をモックするなど)のではなく、SDKの実行結果をどのように期待値として取得するテストコードを書くのかを考えます。

開発するアプリケーションの仕様

開発するアプリケーションの仕様は次のとおりです。

  1. ページの表示時にSDKのgetGreetingを使ってデータを取得する。
  2. 取得したデータを<h1>のテキストコンテンツとして描画する。

この仕様を満たすアプリケーションコードの書き方によって、どのようにテストコードを書くかを見ていきます。

書き方のパターン

コンポーネント内にすべて書いた場合

まずは、必要なすべてをアプリケーションコードの1コンポーネント内に書いた場合です。

アプリケーションコード

Greeting.tsx
import { useState, useEffect } from "react";

import { HelloWorldSdk } from "@/vendors/extraSdk";

export function Greeting() {
  const [data, setData] = useState("");

  useEffect(() => {
    (async () => {
      const sdk = new HelloWorldSdk();
      const data = await sdk.getGreeting();
      setData(data);
    })();
  }, []);

  return <h1 role="heading">{data}</h1>;
}

Greetingコンポーネントの動きは次のようになります。

  • useEffectでマウント時にSDKからデータを取得します。
  • 取得したデータをsetDataで更新します。
  • データを<h1>に表示します。

このGreetingコンポーネントのテストを書いてみます。

テストコード

以下は、上記のコンポーネントをテストするためのコードです。

Greeting.test.tsx
import { render, waitFor } from "@testing-library/react";

import { HelloWorldSdk } from "@/vendors/extraSdk";

import { Greeting } from "./Greeting";

describe("Greeting", () => {
  const run = () => render(<Greeting />);

  it("greetingを取得して表示する", async () => {
    const message = "message";
    vi.spyOn(HelloWorldSdk.prototype, "getGreeting").mockResolvedValue(message);

    const r = run();
    await waitFor(() =>
      expect(r.getByRole("heading")).toHaveTextContent(message),
    );
  });
});

このテストコードでは次のことを行っています。

  • vitestのspyOnを使用してHelloWorldSdkgetGreetingをモック化し、一定の値を返すように設定します。
  • testing-libraryのrenderを使用してコンポーネントをレンダリングします。
  • waitForを使って非同期データ取得後のDOM変化を検証します。

この場合のテストはvitestのspy機能を活用したコードとなります。

SDKへの結合度を弱めた場合

次はアプリケーションコードでSDKへの結合度を弱めてみます。

アプリケーションコード

コンポーネントの中でSDKのインスタンスを生成せずに、プロパティで外から渡すようにします。

Greeting.tsx
import { useState, useEffect } from "react";

export type HelloWorldSdkAPI = {
  getGreeting(): Promise<string>;
};

type Props = {
  api: HelloWorldSdkAPI;
};

export function Greeting({ api }: Props) {
  const [data, setData] = useState("");

  useEffect(() => {
    (async () => {
      const data = await api.getGreeting();
      setData(data);
    })();
  }, [api]);

  return <h1 role="heading">{data}</h1>;
}

apiプロパティとしてSDKを渡せるようにしていますが、apiの型をあえて直接HelloWorldSDKにはせずに、コンポーネントが必要とするAPIのみの型(HelloWorldSdkAPI)にしています。

このコンポーネントは次のように使います。

App.tsx
/* 中略 */

export function App() {
  return <Greeting api={new HelloWorldSdk()} />;
}

テストコード

以下は、上記のコンポーネントをテストするコードです。

Greeting.test.tsx
import { render, waitFor } from "@testing-library/react";

import { Greeting, HelloWorldSdkAPI } from "./Greeting";

describe("Greeting", () => {
  const run = (api: HelloWorldSdkAPI) => render(<Greeting api={api} />);

  it("greetingを取得して表示する", async () => {
    const message = "message";

    const r = run({
      async getGreeting() {
        return message;
      },
    });
    await waitFor(() =>
      expect(r.getByRole("heading")).toHaveTextContent(message),
    );
  });
});

apiプロパティはHelloWorldSdk型ではないため、必要となるgetGreetingメソッドを満たすオブジェクトを渡すだけでテストできます。この場合、テストライブラリのspy機能に頼らずにテストできます。

SDKをコンテキスト経由で渡すようにした場合

次はコンポーネントのプロパティで直接渡すのではなく、コンテキスト経由で提供して使えるようにしてみます。今回のケースでは大げさになりますが、構造が複雑なアプリケーションではこちらの方がやりやすい場合もあります。

アプリケーションコード

まずはgetGreetingを提供するコンテキストを用意します。
本来はファイルを分けるべきですが、便宜上1ファイルにまとめています。

HelloWorldSdkContext.tsx
import { createContext, use } from "react";

export type HelloWorldSdkAPI = {
  getGreeting(): Promise<string>;
};

export const HelloWorldSdkContext = createContext<HelloWorldSdkAPI>(
  Object.create({}) as HelloWorldSdkAPI,
);

export function useHelloWorldSdk() {
  return use(HelloWorldSdkContext);
}

このコンテキストを利用したアプリケーションコードは次のようになります。

Greeting.tsx
import { useState, useEffect } from "react";
import { useHelloWorldSdk } from "./HelloWorldSdkContext";

export function Greeting() {
  const { getGreeting } = useHelloWorldSdk();
  const [data, setData] = useState("");

  useEffect(() => {
    (async () => {
      const data = await getGreeting();
      setData(data);
    })();
  }, [getGreeting]);

  return <h1 role="heading">{data}</h1>;
}

コンテキストは次のように適用します。

App.tsx
/* 中略 */

export function App() {
  return (
    <HelloWorldSdkContext value={new HelloWorldSdk()}>
      <Greeting />
    </HelloWorldSdkContext>
  );
}

テストコード

上記に対してのテストコードは次のようになります。

Greeting.test.tsx
import { render, waitFor } from "@testing-library/react";

import { Greeting } from "./Greeting";
import {
  HelloWorldSdkContext,
  HelloWorldSdkAPI,
} from "./HelloWorldSdkContext";

describe("Greeting", () => {
  const run = (api: HelloWorldSdkAPI) =>
    render(<Greeting />, {
      wrapper({ children }) {
        return (
          <HelloWorldSdkContext value={api}>
            {children}
          </HelloWorldSdkContext>
        );
      },
    });

  it("greetingを取得して表示する", async () => {
    const message = "message";

    const r = run({
      async getGreeting() {
        return message;
      },
    });

    await waitFor(() =>
      expect(r.getByRole("heading")).toHaveTextContent(message),
    );
  });
});

renderメソッドのwrapperオプションでコンテキストプロバイダを適用する形となります。こちらもテストライブラリのspy機能には頼らずにテストを行えます。

コンテキスト経由にした場合のテストコードの応用

実際のケースではSDKの提供する複数のメソッドをアプリケーションの中で使い分けることもあるでしょう。

例えばSDKの提供する別のメソッド(仮にgetFarewellとします)もコンテキストが扱うようになった場合、HelloWorldSdkContextは次のようになります。

HelloWorldSdkContext.tsx
import { createContext, use } from "react";

export type HelloWorldSdkAPI = {
  getGreeting(): Promise<string>;
  getFarewell(): Promise<string>; // 追加分
};

export const HelloWorldSdkContext = createContext<HelloWorldSdkAPI>(
  Object.create({}) as HelloWorldSdkAPI,
);

export function useHelloWorldSdk() {
  return use(HelloWorldSdkContext);
}

このような場合、先程のテストコードではテストケースの関心事ではないメソッドまでモックする必要がでてきます。例えばGreetingコンポーネントでは使わないAPIのためにテストコードを次のようにする必要があります。

const r = run({
  async getGreeting() {
    return message;
  },
  async getFarewell() {
    // テストでは使わないが定義が必要
    throw new Error("not impl");
  },
});

このままだとコンテキストの提供するAPIが増えるほどテストケース内で関心事にないAPIのモックを行う手間が発生します。

これを回避するために次のようなヘルパーを作ってみます。

TestHelper.tsx
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { ReactNode } from "react";

import { HelloWorldSdkContext, HelloWorldSdkAPI } from "./HelloWorldSdkContext";
import { HelloWorldSdk } from "@/vendors/extraSdk";

type Overrides = Partial<HelloWorldSdkAPI>;

type Props = {
  children: ReactNode;
  overrides: Overrides;
};

export function MockHelloWorldSdk({ children, overrides }: Props) {
  const sdk = new HelloWorldSdk();
  const merged: HelloWorldSdkAPI = Object.keys(overrides).reduce(
    (ret, key) => {
      if (key in overrides) {
        // @ts-ignore
        ret[key] = overrides[key];
      }
      return ret;
    },
    { ...sdk } as HelloWorldSdkAPI,
  );

  return <HelloWorldSdkContext value={merged}>{children}</HelloWorldSdkContext>;
}

テストヘルパーMockHelloWorldSdkoverridesプロパティで渡したメソッドでテスト用に提供するAPIを置き換えます。

ポイントはtype Overrides = Partial<HelloWorldSdkAPI>;でAPIを個々にnullableにした型として定義することで、必要なメソッドのみを渡せば良い形にすることです。

このテストヘルパーを利用したテストコードは次のようになります。

Greeting.test.tsx
import { render, waitFor } from "@testing-library/react";

import { Greeting } from "./Greeting";
import { HelloWorldSdkAPI } from "./HelloWorldSdkContext";
import { MockHelloWorldSdk } from "./TestHelper";

type GetGreeting = HelloWorldSdkAPI["getGreeting"];

describe("Greeting", () => {
  const run = (getGreeting: GetGreeting) =>
    render(<Greeting />, {
      wrapper({ children }) {
        return (
          <MockHelloWorldSdk overrides={{ getGreeting }}>
            {children}
          </MockHelloWorldSdk>
        );
      },
    });

  it("greetingを取得して表示する", async () => {
    const message = "message";

    const r = run(async () => message);

    await waitFor(() =>
      expect(r.getByRole("heading")).toHaveTextContent(message),
    );
  });
});

テストヘルパーを使うことでgetFarewellはモックせずに、必要なgetGreetingだけをモックしてテストすることができます。

まとめ

外部Webサービスの提供するSDKを利用したアプリケーションを題材に、アプリケーションコードの書き方を変えながらテストコードがどのように書けるかを見てきました。

実際はこのように単純には行かないかと思いますが、参考になれば幸いです。

Discussion