はじめに
花粉症でくしゃみが止まらない あな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
コマンドで生成されたテストコードの例がこちらです。
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 の型定義に従って適切な構造であるかを検証するために補完
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
と同様
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 がより高精度のテストコードを生成するのではないかと思います。