ENECHANGE Developer Blog

ENECHANGE開発者ブログ

clineが暴走?テストコード生成の課題を救ったのは.clinerulesでした

こんにちは。ENECHANGEの川野邉です。

先日、テストコード自動生成を期待してclineを利用しましたが、思わぬ暴走や課題に直面しました。

本記事では、その課題と解決に役立った.clinerulesの活用事例を紹介します。 同じ悩みを持つ開発者の参考になれば幸いです。

docs.cline.bot

この記事で書くこと・書かないこと

✅ 書くこと

  • clineにフロントエンドのテストを書かせた際に起きた課題
  • 課題を解決するために導入した.clinerulesと効果
  • (まとめ)clineにテストを書かせてみての学びやポイント

❌書かないこと

  • clineや.clinerulesの詳しい技術的な内部実装
  • 他のAIテストツールとの比較
  • テストコード・実装コードの具体的な解説や説明

clineにフロントエンドのテストを書かせた際に起きた課題

clineに対して「ソースコードを読ませたあと、意図通りのテストコードを自動で生成してくれる」ことを期待していました。 しかし、実際に試したところ次のような課題に直面しました。。

  • 意味のないテストが生成される(例:全てモック化されて実際の挙動と無関係)
  • 失敗するテストがそのまま生成され、完了と判定される
  • テストを成功させるために、実装コードを勝手に変更する
  • 成功するまで延々と書き直すような非効率な生成

エラーに苦しみテストを書き直すcliine

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 で調整が必要と感じました。

慎重にファイルを読み込んでいくcline

今回は暴走しないことに目を向けていたため、慎重になっている点は今回は目を瞑りました。

1~2人日ほどかかるボリュームのテストコードの実装も、暴走を止めてclineと向き合えば半日で書いてくれます。さらに慣れてくると、より多くのテストを短時間で正確に書けるようになると思います。

まとめ

clineにテストコードを書かせるといっても、「とにかく早く・自動生成されるように指示を出せばOK」というわけではありません。今回clineにテストを書かせてみての学びは以下です。

  • clineはルールがなければ、目的に合わないテストコードを量産する
  • 意図を明確に.clinerulesで伝えることで、cilneの書くテストコードは飛躍的に改善される
  • ルールで縛りすぎると、過剰なAPIリクエストが発生するので程よく調整は必要
  • 都度 .clineruleを追加・修正しながら改善していくのがコツ

私のようにclineを試したけど暴走したという方がいれば、まずは.clinerulesで目的やルールを提供することをお勧めします。  

これから cline や他のAIツールの導入を検討している方々にとって、この記事が少しでも参考になれば幸いです。