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

## はじめに
花粉症でくしゃみが止まらない あなP（[@annap_ms](https://x.com/annap_ms)）です。

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

https://qiita.com/annap_ms/items/52359daeeaca6a15054c

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

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

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

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

> ※ 実際のコードは、[前回の記事](https://qiita.com/annap_ms/items/52359daeeaca6a15054c)をご参照ください。

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

### `UpdateRecordUseCase.ts` の Copilot 生成コードと評価
Copilot によって `/tests` コマンドで生成されたテストコードの例がこちらです。
``` ts: 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 仕様上も有効な入力である**ため、テスト内容を「正常系」へと修正

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

``` ts: 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`, `レコード番号`）の欠落したテストが提案された  
- `ユーザー選択` の空配列を異常と判断するなど、業務仕様とスキーマ仕様の違いに関する誤解が見られた  
- `code` や `name` の欠落といった細かい構造に関してはテストが生成されず、手動での補完が必要だった  
- スキーマ構造が複雑な場合は、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` と同様

``` 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 がより高精度のテストコードを生成するのではないかと思います。