16
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

型を明示しないテストが招いた落とし穴と、TypeScript+ESLintで守る単体テストの品質

Last updated at Posted at 2025-07-09

こんにちは!株式会社Schoo(以後スクー) 新卒2年目の @hiroto_0411です!
私の所属するチームでは現行システムのリプレイスによる「次世代プラットフォームの構築」を行っています。リプレイス後のシステム構成は、バックエンド(以後 BE)、Backend for Frontend(以後 BFF)、フロントエンド(以後 FE)という3層のアーキテクチャを採用しています。
BFFはTypeScriptで書いており、Honoというフレームワークを使っています。
今回の記事では、BFFの単体テストにおいて型を明示的に指定していなかったことで、どのような問題が起きたのかと、型必須にするメリットについてまとめてみようと思います!

この記事でわかること

  • 単体テストで型を明示的に指定しなかったことで発生した問題と解決策
  • 型必須にすることでより効果的な単体テストを書ける理由

この記事を通じて、「型を正しく使うことで、テストの信頼性が高まる」という実感を持っていただき、自分たちのプロジェクトにも取り入れてみようと思ってもらえたら嬉しいです。

今回の記事の内容はMita.ts #6で登壇した内容になります。

スクーにおけるBFFの役割

本題に入る前に、今回はBFFのコードを例に話を進めるので、BFFについて補足します。スクーのリプレイスプロジェクトにおいてBFFを採用している理由・役割は以下になります。

  • BEは、モジュラーモノリスアーキテクチャを採用しているため、ドメインごとのシンプルなAPIだけを提供しており、コンテキストがまたがる場合があるので、BFFがそのつなぎ役を担う
  • 過去に、FEの処理負担が多すぎる問題があり以下のような処理はBFFで担当するようにしたいと考えた
    • gRPCの取り扱いなど、複雑な通信処理
    • サーバーとやり取りするためのデータ形式(DTO)の整形
    • 認証や認可のロジック

また、FEから必要な情報だけ無駄なくリクエストできるようBFFではGraphQLを採用しています。

単体テストで型を明示的に指定しなかったことで発生した問題

ディレクトリ構成

リプレイスプロジェクトは以下の構成で実装しています。(今回の説明で不要なものは一部省略しています。)
今回はdomain/repositoryのinterfaceをmockしてService層に対しての単体テストを書く場合を例に説明しようと思います。

.
├── common
│   └── errorHandler
│       ├── index.test.ts
│       └── index.ts
├── config
│   ├── datadog
│       └── index.ts
├── domain
│   └── repository // BEとの通信を行うinterfaceを定義する
│       └── UserRepository
|           └── index.ts
├── features //サービスごとにディレクトリを切る
│   ├── user 
│   │   ├── userResolver.ts //serviceの処理を受け取り、GraphQLの処理をする
│   │   ├── userSchema.ts //GraphQLが提供するスキーマを定義する
│   │   ├── userService.ts //repositoryを呼び出すなど、サービス固有のロジックを実装する
│   │   └── userTypes.ts //BFF内で取り回す用の型を定義する
|   |   └── tests
│   │       └── userService.test.ts
│   └── index.ts
├── index.ts
├── infrastructures
│   └── repository //domain/repositoryで定義したinterfaceの実装
│       └── implUserRepository
│         ├── index.test.ts
│         └── index.ts
│     
└── utils
    

domain/repositoryとserviceの実装例

※以下の内容は実際のプロダクトコードではなくサンプルになります。

BFF内で扱う型の定義

./features/user/userTypes.ts
export type User = {
  userId: string;
  name: string;
};

domain/repository

interfaceを定義します。

./domain/repository/UserRepository/index.ts
export interface IUserRepository {
  getUser(context: Context): Promise<User>;
}

service

service層はrepositoryの実装の詳細を知らない実装にしています。

./features/user/userService.ts
export const userService = {
  async getUser(
    repository: IUserRepository,
    context: Context
  ): Promise<User> {
    logger.info({
      message: "START getUser query",
      operationName: "getUser",
    });
    try {
      // ユーザー情報をdomain/repositoryのinterfaceを呼び出して取得する
      const userResponse = await repository.getUser(context);
      return {
        ...userResponse,
      };
    } catch (error) {
      logger.error({
        error,
        message: "FAILED getUser",
      });
      throw convertConnectErrorToGqlError("Service", "getUser", error);
    }
  },
};

userService.tsの単体テストを実装してみる

userService.tsに対するテストを書いていきます。テストはVitestというテストフレームワークを使用しています
domain/repositoryでinterfaceを定義しており、service層はrepositoryの実装の詳細を知らない実装になっているので、interfaceをmockして単体テストを実装します。

型を明確に指定しておらずにinterfaceの変更に対応できていない例

./features/user/tests/userService.test.ts
test("repositoryからデータを取得できたとき、その値をユーザ情報として返すこと", async () => {
  // モックデータ準備 ここで型を明示的に指定しないとmockしているinterfaceが返す値の型が変わってしまっても隠蔽されてしまい、テストでエラーが発生しない
  const mockUser = { 
    userId: "user1",
    name: "テストユーザー"
  };

  const mockRepository: IUserRepository = {
    getUser: vi.fn().mockResolvedValue(mockUser),
  };

  const mockContext: Context = {} as Context;

  // テスト実行
  const result: User = await userService.getUser(mockRepository, mockContext);

  // 検証
  expect(result).toEqual({
    ...mockUser,
  });
});

APIで返す値にuserが所属する組織の情報を追加してみます。

./features/user
export type User = {
 userId: string;
 name: string;
 organization: {
   id: string;
   name: string;
 };
};

本来であれば変更が加わった際に、以下のmockの返り値を定義する部分(mockUser)でエラーが発生して欲しいですがmockUserは型を明示的に定義していないためエラーにならないです。

./features/user/tests/userService.test.ts
const mockUser = { 
  userId: "user1",
  name: "テストユーザー"
};
// 省略
const mockRepository: IUserRepository = {
  getUser: vi.fn().mockResolvedValue(mockUser),
};

// 省略

mockのinterfaceを定義する部分(mockRepository)でもエラーが起きて欲しいですが、mockResolvedValue(value: Awaited<ReturnType<T>>): this は以下のような実装になっており、mockResolvedValue(value: Awaited<ReturnType<T>>): this で使う値(mockUser)は明確な型定義がないため、戻り値型がanyとして解釈されてしまい、ここでもエラーにならないです。

// vitestのmockResolvedValueの実装
mockResolvedValue(value: Awaited<ReturnType<T>>): this;

型を明確に定義しなかったことによる問題点と解決策

問題点

mockした部分の型定義を明確にしないと、上記のようにinterfaceが返す値に大幅な変更があってもテストでエラーにならず、テストコードの修正を忘れる、意図したロジックをテストできないなど、効果的な単体テストが書きにくくなります。

解決策

問題点を解決するために以下の2つを行いました。

  • テストコードでは変数を定義するときに型を明示的に指定する
  • 上記のルールをLinterに設定することでチーム内で同じ問題が発生しないようにする
ESLintに設定を追加
eslint.config.js
// すべての test ファイルで、any禁止 & 明示的な型指定を強制
// すべての test ファイルで、any禁止 & 明示的な型指定を強制
{
  files: ["**/*.test.ts", "**/*.spec.ts"],
  rules: {
    "@typescript-eslint/no-explicit-any": "error",
    "@typescript-eslint/typedef": [
      "error",
      {
        "variableDeclaration": true,
      }
    ],
  },
}

テストコード内の変数は明示的に型を指定する
./features/user/tests/userService.test.ts
test("repositoryからデータを取得できたとき、ユーザ情報と所属する組織の情報を返すこと", async () => {
  // モックデータ準備 明示的に型を指定する
  const mockUser: Awaited<ReturnType<IUserProfileRepository['getUserProfile']>> = { 
    userId: "user1",
    name: "テストユーザー",
    organization: {
      id: 'org456',
      name: 'Schoo',
    },
  };

  const mockRepository: IUserRepository = {
    getUser: vi.fn().mockResolvedValue(mockUser),
  };

  const mockContext: Context = {} as Context;

  // テスト実行
  const result: User = await userService.getUser(mockRepository, mockContext);

  // 検証
  expect(mockRepository.getUser).toHaveBeenCalledWith(mockContext);

  expect(result).toEqual({
    ...mockUser,
  });
});

すべての test ファイルで、変数を定義する際はany禁止 & 明示的な型指定を強制とする設定を加えることで、interfaceが返す値に変更があった場合は、mockデータを修正しないとエラーが出るようになりました。

まとめ

mockを使うような単体テストを書く場合は、型を明示的に指定して書くことで、より安全なテストが書けるようになりました。さらに、ESlintなどでルールを明確にすることで、チームとしてもより安全なテストが書けるようになります。
「型を明示する」という一手間が、将来のバグや修正漏れを防ぎ、プロダクトの品質を底上げすることができると思います。みなさんのプロジェクトでも、ぜひLinterのルールや型の取り扱いを見直してみてください!

参考

おまけ: 今回の発表に至った裏話と学び

記事の内容とは少し離れるのですが、今回の発表に至った経緯について、ちょっとした裏話を書いてみたいと思います。

実は私のメイン業務はGoを使ったBEの実装であり、今回取り上げた内容で登壇を決めた時点では、TypeScriptにはほとんど触れたことがありませんでした。
そんな私が登壇しようと思ったきっかけは、社内で実施されたFE研修でした。

当初はFEに対して漠然とした苦手意識を持っていたのですが、研修を通じてHTML・CSS・JavaScriptから最新のFE技術に至るまでの歴史や進化の流れを知り、「この技術はどういう思想で生まれたのか?どんな課題を解決しようとしてきたのか?」といった背景を理解することで、どんどん引き込まれていきました。

「FE周りの技術って、思っていたよりずっと奥深くて面白いな」と感じた私は、「研修楽しかったな!」の勢いのまま登壇に申し込んで、自分を勉強せざるを得ない状況に追い込んでみました。

その結果、今回の記事で紹介したように、BEの知識を活かしつつ、研修で得た新しい視点やスキルを業務にも活かすことができました。

研修を企画中の方、特定の分野に漠然とした苦手意識がある方の参考になったらと思って、おまけを書いてみました。

苦手意識がある技術や分野については、その歴史や思想を知ってみる、興味を持ったら登壇や記事執筆などアウトプットをすることで自分の理解を深めてみると、新たな発見があるかもしれないですね!


Schooでは一緒に働く仲間を募集しています!

16
8
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?