13
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【kintone】Github Copilot でテストコードを自動生成してみた

Last updated at Posted at 2025-03-24

はじめに

花粉症でくしゃみが止まらない あなP(@annap_ms)です。

前回の記事では、kintone のカスタマイズコードを「クリーンアーキテクチャ」の考え方を取り入れて整理しました。責務を分離し、依存関係を明確にすることで、保守性や再利用性の高い構成を目指しました。

今回の記事では、その構成を活かして、GitHub Copilot を用いたテストコードの自動生成に取り組んだ内容を紹介します。

テスト対象のコードとその役割

以下の3つのファイルを例として取り上げ、GitHub Copilot を使ってテストコードを生成しました。

  • application/usecase/UpdateRecordUseCase.ts
  • domain/schema/AppRecordSchema.ts
  • infrastructure/repository/KintoneRecordRepository.ts

これらはそれぞれ、アプリケーションのビジネスロジック、スキーマ定義、API 連携といった異なる責務を持ち、アーキテクチャ上でも独立したレイヤーとして位置づけられています。

※ 実際のコードは、前回の記事をご参照ください。

GitHub Copilot によるテストコードの生成と評価

テストコードの作成には GitHub Copilot の /tests コマンドを使用しました。
これにより、対象の TypeScript ファイルに対して、それぞれの構造に応じた Jest テストコードが自動で提案されます。
/tests を使うと、describe ブロックや mock の初期化、expect の構文まで自動で補完されるため、テストコードに不慣れでもベースがすぐに整います。

UpdateRecordUseCase.ts の Copilot 生成コードと評価

Copilot によって /tests コマンドで生成されたテストコードの例がこちらです。

UpdateRecordUseCase.test.ts
import { UpdateRecordUseCase } from './UpdateRecordUseCase';
import { KintoneRecordRepositoryInterface } from '../../domain/repositoryInterface/KintoneRecordRepositoryInterface';

describe('UpdateRecordUseCase', () => {
  let mockRepository: jest.Mocked<KintoneRecordRepositoryInterface>;
  let updateRecordUseCase: UpdateRecordUseCase;

  beforeEach(() => {
    mockRepository = {
      updateRecord: jest.fn(),
    };
    updateRecordUseCase = new UpdateRecordUseCase(mockRepository);
  });

  test('should call repository.updateRecord with correct parameters', async () => {
    const recordId = '123';
    const revision = '1';
    const mockLoginUser = { code: 'user1', name: 'User One' };
    const mockDate = '2023-01-01';

    // Mock kintone global function
    global.kintone = {
      getLoginUser: jest.fn().mockReturnValue(mockLoginUser),
    } as any;

    // Mock Date
    jest.spyOn(global.Date.prototype, 'toISOString').mockReturnValue(`${mockDate}T00:00:00.000Z`);

    await updateRecordUseCase.execute(recordId, revision);

    expect(mockRepository.updateRecord).toHaveBeenCalledWith({
      id: recordId,
      revision,
      record: {
        ユーザー選択: { value: [{ code: mockLoginUser.code, name: mockLoginUser.name }] },
        日付: { value: mockDate },
      },
    });
  });

  test('should throw an error if repository.updateRecord fails', async () => {
    const recordId = '123';
    const revision = '1';

    mockRepository.updateRecord.mockRejectedValue(new Error('Update failed'));

    await expect(updateRecordUseCase.execute(recordId, revision)).rejects.toThrow('Update failed');
  });
});

テストの観点と構成

  • 正常系の動作確認

    • 正しい引数によるリポジトリの updateRecord 呼び出しの確認
    • kintone.getLoginUser()Date に対するモックの適用による値の一貫性の確保
  • 異常系の動作確認

    • リポジトリで発生したエラーの例外スローの確認

評価と所感

  • Copilot は依存関係が明確なユースケースに対して、実用性の高いテストコードを自動生成できた
  • kintone.getLoginUser()Date などの環境依存処理もモック付きで提案され、すぐに実行可能な構成だった
  • 正常系・異常系の観点がバランスよく含まれており、追加修正はほぼ不要だった
  • ビジネスロジックがシンプルな構造であれば、Copilot の初期提案をそのまま採用できる可能性が高い
  • 提案内容の完成度は3ケースの中でも最も高く、補正の手間も少なかった

AppRecordSchema.ts の Copilot 生成コードと評価

Copilot により提案されたテストコードは、AppRecordSchema のバリデーションに対して基本的な構造をカバーしていましたが、以下の点を修正・補完しました:

  • BaseRecordSchema で定義された $id, $revision, レコード番号 をテストデータに追加
    ↳ 当初は未定義のままテストが生成されており、Zod によるバリデーションエラーが発生していたため修正

  • ユーザー選択 フィールドの 空配列に関するテストケース
    ↳ Copilot はこれを「異常系」としてエラーを期待する形で提案していたが、Zod スキーマでは空配列も許容されており、API 仕様上も有効な入力であるため、テスト内容を「正常系」へと修正

  • ユーザー選択 の中身が不完全なケース(codename の欠落)もテスト対象として追加
    ↳ 初期提案には含まれていなかったが、Zod の型定義に従って適切な構造であるかを検証するために補完

AppRecordSchema.test.ts
import { AppRecordSchema } from "./AppRecordSchema";

describe("AppRecordSchema", () => {
  test("Valid record should pass validation", () => {
    const validRecord = {
      $id: { value: '1' },
      $revision: { value: '2' },
      レコード番号: { value: '0001' },
      ユーザー選択: { value: [{ code: 'user1', name: 'User One' }] },
      日付: { value: '2023-10-01' },
    };
    

    expect(() => AppRecordSchema.parse(validRecord)).not.toThrow();
  });

  test("Invalid record: missing ユーザー選択 field", () => {
    const invalidRecord = {
      $id: { value: '1' },
      $revision: { value: '2' },
      レコード番号: { value: '0001' },
      日付: { value: "2023-10-01" },
    };

    expect(() => AppRecordSchema.parse(invalidRecord)).toThrow(
      "Required"
    );
  });

  test("Invalid record: ユーザー選択 has wrong type", () => {
    const invalidRecord = {
      $id: { value: '1' },
      $revision: { value: '2' },
      レコード番号: { value: '0001' },
      ユーザー選択: { value: [{ code: 123, name: "User One" }] }, // `code` should be a string
      日付: { value: "2023-10-01" },
    };

    expect(() => AppRecordSchema.parse(invalidRecord)).toThrow();
  });

  test("Invalid record: 日付 has incorrect format", () => {
    const invalidRecord = {
      $id: { value: '1' },
      $revision: { value: '2' },
      レコード番号: { value: '0001' },
      ユーザー選択: { value: [{ code: "user1", name: "User One" }] },
      日付: { value: "01-10-2023" }, // Wrong date format
    };

    expect(() => AppRecordSchema.parse(invalidRecord)).toThrow();
  });

  test("Invalid record: 日付 is missing", () => {
    const invalidRecord = {
      $id: { value: '1' },
      $revision: { value: '2' },
      レコード番号: { value: '0001' },
      ユーザー選択: { value: [{ code: "user1", name: "User One" }] },
    };

    expect(() => AppRecordSchema.parse(invalidRecord)).toThrow(
      "Required"
    );
  });

  test("Invalid record: ユーザー選択 is empty", () => {
    const invalidRecord = {
      $id: { value: '1' },
      $revision: { value: '2' },
      レコード番号: { value: '0001' },
      ユーザー選択: { value: [] }, // Empty array
      日付: { value: "2023-10-01" },
    };

    expect(() => AppRecordSchema.parse(invalidRecord)).not.toThrow();
  });
});

test("Valid record with multiple ユーザー選択 entries should pass validation", () => {
  const validRecord = {
    $id: { value: '1' },
    $revision: { value: '2' },
    レコード番号: { value: '0001' },
    ユーザー選択: {
      value: [
        { code: "user1", name: "User One" },
        { code: "user2", name: "User Two" },
      ],
    },
    日付: { value: "2023-10-01" },
  };

  expect(() => AppRecordSchema.parse(validRecord)).not.toThrow();
});

test("Invalid record: ユーザー選択 entry missing 'code' field", () => {
  const invalidRecord = {
    $id: { value: '1' },
    $revision: { value: '2' },
    レコード番号: { value: '0001' },
    ユーザー選択: {
      value: [{ name: "User One" }], // Missing `code`
    },
    日付: { value: "2023-10-01" },
  };

  expect(() => AppRecordSchema.parse(invalidRecord)).toThrow();
});

test("Invalid record: ユーザー選択 entry missing 'name' field", () => {
  const invalidRecord = {
    $id: { value: '1' },
    $revision: { value: '2' },
    レコード番号: { value: '0001' },
    ユーザー選択: {
      value: [{ code: "user1" }], // Missing `name`
    },
    日付: { value: "2023-10-01" },
  };

  expect(() => AppRecordSchema.parse(invalidRecord)).toThrow();
});

test("Invalid record: 日付 is not a string", () => {
  const invalidRecord = {
    $id: { value: '1' },
    $revision: { value: '2' },
    レコード番号: { value: '0001' },
    ユーザー選択: { value: [{ code: "user1", name: "User One" }] },
    日付: { value: 20231001 }, // Not a string
  };

  expect(() => AppRecordSchema.parse(invalidRecord)).toThrow();
});

test("Invalid record: 日付 has invalid regex format", () => {
  const invalidRecord = {
    $id: { value: '1' },
    $revision: { value: '2' },
    レコード番号: { value: '0001' },
    ユーザー選択: { value: [{ code: "user1", name: "User One" }] },
    日付: { value: "2023/10/01" }, // Invalid format
  };

  expect(() => AppRecordSchema.parse(invalidRecord)).toThrow();
});

テストの観点と構成

  • 正常系のバリデーション

    • 必須項目を含む正しい形式のレコードの通過確認
    • ユーザー選択 フィールドの複数要素に対する妥当性検証の確認
  • 異常系のバリデーション

    • 必須項目の欠落時のバリデーションエラーの確認(例:ユーザー選択, 日付
    • 型や構造の不正に対するバリデーション拒否の確認(例:code が数値、日付 の型不正、正規表現不一致)

評価と所感

  • Copilot は Zod スキーマの型定義に基づいて、基本的なバリデーションテストを自動生成できた
  • 継承元の BaseRecordSchema を認識せず、必須項目($id, $revision, レコード番号)の欠落したテストが提案された
  • ユーザー選択 の空配列を異常と判断するなど、業務仕様とスキーマ仕様の違いに関する誤解が見られた
  • codename の欠落といった細かい構造に関してはテストが生成されず、手動での補完が必要だった
  • スキーマ構造が複雑な場合は、Copilot の提案を実行・検証して仕様との整合性を確認することが重要

KintoneRecordRepository.ts の Copilot 生成コードと評価

Copilot により提案されたテストコードは、KintoneRecordRepository における API 呼び出し処理の挙動に対して、基本的な構成をカバーしていましたが、以下の点を修正・補完しました:

  • kintone.api.url(...) を使用しており、実行時に TypeError となる問題
    kintone.api.url は関数として存在しないため、jest.fn() によるモック追加または 直接 URL を記述する形式に修正

  • jest.mock('@kintone/kintone-js-sdk') を含んでいたが、対象コードはグローバルの kintone オブジェクトを使用していたため不要
    ↳ 不要な mock() を削除し、代わりに global.kintone をテストスコープで手動定義して対応

  • ユーザー選択 フィールドの 空配列に関するテストケース
    AppRecordSchema.test.ts と同様

KintoneRecordRepository.test.ts
import { KintoneRecordRepository } from './KintoneRecordRepository';

describe('KintoneRecordRepository', () => {
  const mockKintoneApi = jest.fn();
  const mockApiUrl = jest.fn((path: string, preview: boolean) => `/mocked${path}`);

  beforeEach(() => {
    jest.clearAllMocks();

    (mockKintoneApi as any).url = mockApiUrl;

    (global as any).kintone = {
      app: {
        getId: jest.fn().mockReturnValue('123'),
      },
      api: mockKintoneApi,
    };
  });

  const repository = new KintoneRecordRepository();

  test('should update record with valid data', async () => {
    const validRecord = {
      ユーザー選択: { value: [{ code: 'user1', name: 'User One' }] },
      日付: { value: '2023-10-01' },
    };

    mockKintoneApi.mockResolvedValueOnce({});

    await expect(
      repository.updateRecord({
        id: '1',
        record: validRecord,
      })
    ).resolves.not.toThrow();

    expect(mockKintoneApi).toHaveBeenCalledWith(
      '/mocked/k/v1/record',
      'PUT',
      {
        app: '123',
        id: '1',
        record: validRecord,
      }
    );
  });

  test('should include revision if provided', async () => {
    const validRecord = {
      ユーザー選択: { value: [{ code: 'user1', name: 'User One' }] },
      日付: { value: '2023-10-01' },
    };

    mockKintoneApi.mockResolvedValueOnce({});

    await expect(
      repository.updateRecord({
        id: '1',
        revision: '5',
        record: validRecord,
      })
    ).resolves.not.toThrow();

    expect(mockKintoneApi).toHaveBeenCalledWith(
      '/mocked/k/v1/record',
      'PUT',
      {
        app: '123',
        id: '1',
        revision: '5',
        record: validRecord,
      }
    );
  });

  test('should throw error if AppRecordSchema validation fails', async () => {
    const invalidRecord = {
      日付: { value: 'invalid-date' }, // Invalid date format
    };

    await expect(
      repository.updateRecord({
        id: '1',
        record: invalidRecord,
      })
    ).rejects.toThrow();

    expect(mockKintoneApi).not.toHaveBeenCalled();
  });

  test('should update record even if ユーザー選択 is empty', async () => {
    const emptyRecord = {
      ユーザー選択: { value: [] }, // Empty but valid
      日付: { value: '2023-10-01' },
    };

    mockKintoneApi.mockResolvedValueOnce({});

    await expect(
      repository.updateRecord({
        id: '1',
        record: emptyRecord,
      })
    ).resolves.not.toThrow();

    expect(mockKintoneApi).toHaveBeenCalledWith(
      '/mocked/k/v1/record',
      'PUT',
      {
        app: '123',
        id: '1',
        record: emptyRecord,
      }
    );
  });
});

テストの観点と構成

  • 正常系の検証

    • バリデーションに通過する正しいレコードで kintone.api が正しく呼び出されること
    • revision パラメータが指定された場合に適切に含まれること
    • ユーザー選択 が空でも API 呼び出しが成功すること(仕様上有効)
  • 異常系の検証

    • AppRecordSchema に違反する不正なレコード(例:日付形式不正)で kintone.api が呼び出されないこと

評価と所感

  • API 呼び出しに必要な構造(HTTP メソッド、引数の組み立てなど)は正確に補完されていた
  • kintone.api をモックした上で expect による呼び出し検証も含まれており、提案されたテストはすぐに実行可能だった
  • 一方で kintone.api.url() を含めてしまう誤補完があり、グローバル関数の仕様を正確に扱えていない場面もあった
  • ユーザー選択 が空のケースを異常系として扱う誤りがあり、仕様とのズレを修正する必要があった
  • 全体的に提案の精度は高いが、API仕様や環境依存コードとの整合性を自ら確認することが求められる

さいごに

GitHub Copilot を使用することで、テストコード作成のハードルが一気に下がったと実感しました。 /tests コマンドを使えば、describe ブロックや mock の初期化、基本的な expect の構文まで自動補完されるため、テストに不慣れな場面でもベースがすぐに整うのは非常に大きなメリットです。

一方で、生成されたコードが常に正しいとは限らないことにも注意が必要です。
特に、プロンプト(コメントやコード構造)によって Copilot の提案内容は大きく変わるため、

  • 型定義や関数のインターフェースを明確にする
  • 期待する仕様をコメントなどで簡潔に記述する
  • 異常系やユースケースの背景を具体的に記述する

といった工夫をすることで、Copilot がより高精度のテストコードを生成するのではないかと思います。

13
6
0

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
13
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?