こんにちは。ENECHANGEの川野邉です。
先日、テストコード自動生成を期待してclineを利用しましたが、思わぬ暴走や課題に直面しました。
本記事では、その課題と解決に役立った.clinerulesの活用事例を紹介します。 同じ悩みを持つ開発者の参考になれば幸いです。
この記事で書くこと・書かないこと
✅ 書くこと
- clineにフロントエンドのテストを書かせた際に起きた課題
- 課題を解決するために導入した.clinerulesと効果
- (まとめ)clineにテストを書かせてみての学びやポイント
❌書かないこと
- clineや.clinerulesの詳しい技術的な内部実装
- 他のAIテストツールとの比較
- テストコード・実装コードの具体的な解説や説明
clineにフロントエンドのテストを書かせた際に起きた課題
clineに対して「ソースコードを読ませたあと、意図通りのテストコードを自動で生成してくれる」ことを期待していました。 しかし、実際に試したところ次のような課題に直面しました。。
- 意味のないテストが生成される(例:全てモック化されて実際の挙動と無関係)
- 失敗するテストがそのまま生成され、完了と判定される
- テストを成功させるために、実装コードを勝手に変更する
- 成功するまで延々と書き直すような非効率な生成
clineが目的を誤解したままテストコードを書き進めていく現象が起き、指示をし直したりと手戻りのコストが発生しました。
特に テストを成功させるために、実装コードを勝手に変更する という動きは、機能要件が変更されるコードが混入する危険性がありました。
課題を解決するために導入した.clinerules
これらの課題を解決するために「テストコード実装時の注意点」などを.clinerulesに定義しました。
以下が、今回定義した.clinerulesです。省略している箇所もあります。
clineの暴走を止めて欲しかったので、禁止事項・やってほしくないことを明記しました。
# リポジトリ概要 (省略) # 使用ライブラリ 以下のファイルを確認してください。 - package.json - pnpm-lock.yaml # コーディングルール - TypeScriptで any は禁止です。 - lintルールでも禁止されています。 - as を使った型アサーションは原則使用しないください。 - 使用する場合はコメントを入れてください。 - 関数は原則アロー関数で定義してください。 - 定数は全て全て大文字でスネークケースで記述してください。 - ハードコーディングは禁止です。 # テストコード実装時の注意点 - モックの使い方に注意し、必ず成功するテストコードは書かないでください。 - テスト対象の実装コードは変更しないでください。 - .css.ts と index.ts のファイルのテストコード実装は不要です。 - カバレッジの向上しないテストケースは書かないでください。 - テストコードの実装が完了したら必ず pnpm run test を実行し全てのテストが成功することを確認してください。
結果として、.clinerulesに 「モックの使い方に注意し、必ず成功するテストコードは書かないでください。」 のルールを書いただけでも、必ず成功するようなテストコードは書かなくなったことに加えて、importしている他の実装ファイルも読み取り、かなり慎重にテストコードを書くようになりました。
また、カバレッジもほぼ100%に近いテストコードを書くようになり、無意味なテストコードも書かなくなりました。カバレッジの一定の目標値は.clinerulesでも指定し調整できるかは今後検討していきます。
以下に、clineの書いた一部のテストファイルを貼っておきます。(長いので折りたたみ)。
clineが書いたテストファイル
import { useEmapApi } from "@/context/emapApiContext"; import { handleFormChange } from "@/context/inputFormContext"; import { act, renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useConsumptions } from "./useConsumptions"; vi.mock("@/context/emapApiContext", () => ({ useEmapApi: vi.fn(), })); vi.mock("@/context/inputFormContext", () => ({ handleFormChange: vi.fn(), })); describe("useConsumptions", () => { const mockHogeConsumptions = vi.fn(); const mockFugaConsumptions = vi.fn(); const mockForm = { values: { baseMonthForHogeUsage: "3", baseMonthUsageForHoge: "100", baseMonthForFugaUsage: "6", baseMonthUsageForFuga: "50", postalCodeLeft: "123", postalCodeRight: "4567", people: "3", }, getValues: vi.fn(() => ({ baseMonthForHogeUsage: "3", baseMonthUsageForHoge: "100", baseMonthForFugaUsage: "6", baseMonthUsageForFuga: "50", })), getInputProps: vi.fn((key: string) => { const values: Record<string, { value: string }> = { postalCodeLeft: { value: "123" }, postalCodeRight: { value: "4567" }, people: { value: "3" }, }; return values[key] || { value: "" }; }), }; beforeEach(() => { vi.clearAllMocks(); // APIモックの設定 mockHogeConsumptions.mockResolvedValue([ 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, ]); mockFugaConsumptions.mockResolvedValue([ 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, ]); (useEmapApi).mockReturnValue({ clients: { hogeConsumptions: mockHogeConsumptions, fugaConsumptions: mockFugaConsumptions, }, }); }); it("Hogeの場合、正しい初期値で初期化されること", () => { const { result } = renderHook(() => useConsumptions(mockForm, false), ); expect(result.current.monthId).toBe("baseMonthForHogeUsage"); expect(result.current.costId).toBe("baseMonthUsageForHoge"); expect(result.current.errorMessage).toBe(""); }); it("Fugaの場合、正しい初期値で初期化されること", () => { const { result } = renderHook(() => useConsumptions(mockForm, true)); expect(result.current.monthId).toBe("baseMonthForFugaUsage"); expect(result.current.costId).toBe("baseMonthUsageForFuga"); expect(result.current.errorMessage).toBe(""); }); it("Hogeが空で人数のみが提供された場合、handleFormChangeが人数の値で呼ばれること", async () => { // Hogeが空の場合のテスト const emptyConsumptionForm = { ...mockForm, values: { ...mockForm.values, baseMonthUsageForHoge: "", }, getValues: vi.fn(() => ({ baseMonthForHogeUsage: "3", baseMonthUsageForHoge: "", baseMonthForFugaUsage: "6", baseMonthUsageForFuga: "50", })), }; const { result } = renderHook(() => useConsumptions(emptyConsumptionForm, false), ); await act(async () => { await result.current.handleBaseConsumptionChange({ people: "4" }); }); expect(handleFormChange).toHaveBeenCalledWith(emptyConsumptionForm, { people: "4", }); expect(mockHogeConsumptions).not.toHaveBeenCalled(); }); it("Hogeが空の場合、エラーメッセージが設定されること", async () => { const { result } = renderHook(() => useConsumptions(mockForm, false), ); await act(async () => { await result.current.handleBaseConsumptionChange({ consumption: "" }); }); expect(result.current.errorMessage).toBe("Hogeを入力してください。"); expect(mockHogeConsumptions).not.toHaveBeenCalled(); }); it("Fugaが空の場合、エラーメッセージが設定されること", async () => { const { result } = renderHook(() => useConsumptions(mockForm, true)); await act(async () => { await result.current.handleBaseConsumptionChange({ consumption: "" }); }); expect(result.current.errorMessage).toBe("Fugaを入力してください。"); expect(mockFugaConsumptions).not.toHaveBeenCalled(); }); it("Hogeが0の場合、エラーメッセージが設定されること", async () => { const { result } = renderHook(() => useConsumptions(mockForm, false), ); await act(async () => { await result.current.handleBaseConsumptionChange({ consumption: "0" }); }); expect(result.current.errorMessage).toBe("Hogeが小さすぎます。"); }); it("isComposingがtrueの場合、APIが呼ばれないこと", async () => { const { result } = renderHook(() => useConsumptions(mockForm, false), ); await act(async () => { await result.current.handleBaseConsumptionChange({ consumption: "100", month: "3", isComposing: true, }); }); expect(mockHogeConsumptions).not.toHaveBeenCalled(); }); it("hogeConsumptions APIが呼ばれ、フォームの値が更新されること", async () => { const { result } = renderHook(() => useConsumptions(mockForm, false), ); await act(async () => { await result.current.handleBaseConsumptionChange({ consumption: "100", month: "3", }); }); expect(mockHogeConsumptions).toHaveBeenCalledWith({ baseMonth: 3, consumption: 100, postCode: "1234567", numberOfFamily: 3, }); expect(handleFormChange).toHaveBeenCalledWith(mockForm, { baseMonthForHogeUsage: "3", baseMonthUsageForHoge: "100", hogeUsageMonthly1: "100", hogeUsageMonthly2: "200", hogeUsageMonthly3: "300", hogeUsageMonthly4: "400", hogeUsageMonthly5: "500", hogeUsageMonthly6: "600", hogeUsageMonthly7: "700", hogeUsageMonthly8: "800", hogeUsageMonthly9: "900", hogeUsageMonthly10: "1000", hogeUsageMonthly11: "1100", hogeUsageMonthly12: "1200", }); }); it("fugaConsumptions APIが呼ばれ、フォームの値が更新されること", async () => { const { result } = renderHook(() => useConsumptions(mockForm, true)); await act(async () => { await result.current.handleBaseConsumptionChange({ consumption: "50", month: "6", }); }); expect(mockFugaConsumptions).toHaveBeenCalledWith({ baseMonth: 6, consumption: 50, postCode: "1234567", }); expect(handleFormChange).toHaveBeenCalledWith(mockForm, { baseMonthForFugaUsage: "6", baseMonthUsageForFuga: "50", fugaUsageMonthly1: "50", fugaUsageMonthly2: "60", fugaUsageMonthly3: "70", fugaUsageMonthly4: "80", fugaUsageMonthly5: "90", fugaUsageMonthly6: "100", fugaUsageMonthly7: "110", fugaUsageMonthly8: "120", fugaUsageMonthly9: "130", fugaUsageMonthly10: "140", fugaUsageMonthly11: "150", fugaUsageMonthly12: "160", }); }); it("APIエラーが発生した場合、適切に処理されること", async () => { mockHogeConsumptions.mockRejectedValueOnce(new Error("API Error")); const { result } = renderHook(() => useConsumptions(mockForm, false), ); await act(async () => { await result.current.handleBaseConsumptionChange({ consumption: "100", month: "3", }); }); // エラーが発生しても処理が続行されることを確認 expect(handleFormChange).toHaveBeenCalledWith(mockForm, { baseMonthForHogeUsage: "3", baseMonthUsageForHoge: "100", }); }); });
補足
慎重になりすぎたのか、同じファイルを2度読み込んでいるような振る舞いもしていました。余計なAPIリクエストを飛ばしている可能性もあるので .clinerules で調整が必要と感じました。
今回は暴走しないことに目を向けていたため、慎重になっている点は今回は目を瞑りました。
1~2人日ほどかかるボリュームのテストコードの実装も、暴走を止めてclineと向き合えば半日で書いてくれます。さらに慣れてくると、より多くのテストを短時間で正確に書けるようになると思います。
まとめ
clineにテストコードを書かせるといっても、「とにかく早く・自動生成されるように指示を出せばOK」というわけではありません。今回clineにテストを書かせてみての学びは以下です。
- clineはルールがなければ、目的に合わないテストコードを量産する
- 意図を明確に.clinerulesで伝えることで、cilneの書くテストコードは飛躍的に改善される
- ルールで縛りすぎると、過剰なAPIリクエストが発生するので程よく調整は必要
- 都度 .clineruleを追加・修正しながら改善していくのがコツ
私のようにclineを試したけど暴走したという方がいれば、まずは.clinerulesで目的やルールを提供することをお勧めします。
これから cline や他のAIツールの導入を検討している方々にとって、この記事が少しでも参考になれば幸いです。