必見の記事

ユニットテストをGitHub CopilotとChatGPT使って書いてみたらやばかったです

GitHub Copilotとの単体テストがやばい。ChatGPTが書いてくれるテストもすごい。もうこれらがない時代には戻れないような気がします。
2023.03.30

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは。AWS事業本部コンサルティング部に所属している今泉(@bun76235104)です。

みなさんユニットテスト書いてますか?

昨今AIがダミーデータを書いてくれたり、ユニットテストそのものを書いてくれたりと技術の進歩がすごいですね。

私はリファクタリングが好きですが、リファクタリングをする前に絶対に必要なもの。

そうテストですね。

今回私がテストを後回しにしてしまった以下のOSSについてGitHub CopilotChatGPTのそれぞれの力を借りながら、テストを書いてみました

※ これは以前私が始めたプロジェクトであり、OSSとして公開されているので学習に使われても問題のないコードです。

なお、GitHub Copilotの料金やVSCodeの拡張機能などは以下記事が分かりやすかったのでご参照ください。

先にまとめ

以下のような感じで、それぞれとテストを作ってみました。

私の所感です。

  • どちらを使うにしてもプロンプト力が問われる
    • また、もともとのコードの質やdocstringなども関わりそう
  • ChatGPTはテストの土台を作ってもらうのに良さそう
    • 私がみる限りすべての分岐を意識して書いてくれたようです
    • 境界値分析などが有効なテストだとあまり手直しも必要ないかもしれません
    • ただ今回は結構意図したテストケースと異なる結果が多かったので、修正の指示を出すよりも自分で手直しをしていきました(プロンプト力や全体の設計力不足)
  • Copilotは自分が意図したテストケースを着実に書くのに良さそう
    • 提案力がすごいです
    • これまで書いたコードも意識して提案してくれていそうで、やればやるほど楽になった気がしています
    • VSCodeで完結するし、ソースにそのまま提案を書いてくれるので効率は非常によかったです

ChatGPTにテストを書いてもらう

ChatGPTには指示とコードを与えて特定のクラスのテストを提案してもらいます。

20230330_chatgpt_copilot_test_siji

以下のように特定のクラスのテストを一気に作ってくれました。

20230330_chatgpt_coplit_test_result

GitHub Copilotと楽しくテスト

コメントで指示を与えることでCopilotが良い感じにテストのタイトルやテストそのものを提案してくれます。

そのため私はそれにほぼ乗っかるだけで、一部期待する結果を修正するだけの簡単なお仕事です。

20230330_github_coplilot_test_flow

ChatGPTとのやりとりとテストコード作成

上記のように、ChatGPTさんに頼むことで以下のようにテストケースを作成してくれます。

20230330_chatgpt_copilot_test_siji

生成の結果(コードの意味まで理解しないで結構です)
import { AwsService } from "./index";
import { RuleParam } from "./rule";

describe("SpacingRule", () => {
  describe("getMaybe", () => {
    it("should return null if there is no pattern", () => {
      const service = new AwsService("Product", "");
      const spacingRule = new SpacingRule(service);
      expect(spacingRule.getMaybe()).toBeNull();
    });

    it("should return a RuleParam with noSpacePattern", () => {
      const service = new AwsService("Amazon Product", "");
      const spacingRule = new SpacingRule(service);
      const rule: RuleParam = {
        expected: "AmazonProduct",
        patterns: ["AmazonProduct"],
        options: { wordBoundary: true },
      };
      expect(spacingRule.getMaybe()).toStrictEqual(rule);
    });

    it("should return a RuleParam with spaceDelimitedPattern", () => {
      const service = new AwsService("AWSProductName", "");
      const spacingRule = new SpacingRule(service);
      const rule: RuleParam = {
        expected: "AWS Product Name",
        patterns: ["AWS Product Name"],
        options: { wordBoundary: true },
      };
      expect(spacingRule.getMaybe()).toStrictEqual(rule);
    });

    it("should return a RuleParam with both noSpacePattern and spaceDelimitedPattern", () => {
      const service = new AwsService("AmazonAWSProductName", "");
      const spacingRule = new SpacingRule(service);
      const rule: RuleParam = {
        expected: "Amazon AWS Product Name",
        patterns: ["AmazonAWSProductName", "Amazon AWS Product Name"],
        options: { wordBoundary: true },
      };
      expect(spacingRule.getMaybe()).toStrictEqual(rule);
    });
  });

  describe("noSpacePattern", () => {
    it("should return a string with blank removed", () => {
      const service = new AwsService("Amazon Product", "");
      const spacingRule = new SpacingRule(service);
      expect(spacingRule["noSpacePattern"]()).toBe("AmazonProduct");
    });
  });

  describe("spaceDelimitedPattern", () => {
    it("should return a string with pascal case delimited by space", () => {
      const service = new AwsService("AWSProductName", "");
      const spacingRule = new SpacingRule(service);
      expect(spacingRule["spaceDelimitedPattern"]()).toBe("AWS Product Name");
    });
  });
});

ただし、この時点でテストコードを実行しても結構テスト失敗となってしまいます。

ということで、コメントアウトを入れて1つずつ見ていくことにしました。

必要な部分以外をコメントアウト
describe("SpacingRule", () => {
  describe("getMaybe", () => {
    it("should return null if there is no pattern", () => {
      const service = new AwsService("Product", "Amazon");
      const spacingRule = new SpacingRule(service);
      expect(spacingRule.getMaybe()).toBeNull();
    });

  //   it("should return a RuleParam with noSpacePattern", () => {
  //     const service = new AwsService("Amazon Product", "");
  //     const spacingRule = new SpacingRule(service);
  //     const rule: RuleParam = {
  //       expected: "AmazonProduct",
  //       patterns: ["AmazonProduct"],
  //       options: { wordBoundary: true },
  //     };
  //     expect(spacingRule.getMaybe()).toStrictEqual(rule);
  //   });

  //   it("should return a RuleParam with spaceDelimitedPattern", () => {
  //     const service = new AwsService("AWSProductName", "");
  //     const spacingRule = new SpacingRule(service);
  //     const rule: RuleParam = {
  //       expected: "AWS Product Name",
  //       patterns: ["AWS Product Name"],
  //       options: { wordBoundary: true },
  //     };
  //     expect(spacingRule.getMaybe()).toStrictEqual(rule);
  //   });

  //   it("should return a RuleParam with both noSpacePattern and spaceDelimitedPattern", () => {
  //     const service = new AwsService("AmazonAWSProductName", "");
  //     const spacingRule = new SpacingRule(service);
  //     const rule: RuleParam = {
  //       expected: "Amazon AWS Product Name",
  //       patterns: ["AmazonAWSProductName", "Amazon AWS Product Name"],
  //       options: { wordBoundary: true },
  //     };
  //     expect(spacingRule.getMaybe()).toStrictEqual(rule);
  //   });
  });

  // describe("noSpacePattern", () => {
  //   it("should return a string with blank removed", () => {
  //     const service = new AwsService("Amazon Product", "");
  //     const spacingRule = new SpacingRule(service);
  //     expect(spacingRule["noSpacePattern"]()).toBe("AmazonProduct");
  //   });
  // });

  // describe("spaceDelimitedPattern", () => {
  //   it("should return a string with pascal case delimited by space", () => {
  //     const service = new AwsService("AWSProductName", "");
  //     const spacingRule = new SpacingRule(service);
  //     expect(spacingRule["spaceDelimitedPattern"]()).toBe("AWS Product Name");
  //   });
  // });
});

書かれたテストは「帰ってくる型」やあるべき状態を意識して書いてくれていました。

が、もともとの私の全体の作りの荒さやdocstringなどの少なさからか、結構期待する結果が異なっていたので、最終的に作ってもらった土台から意図する結果に変えていく作業が必要となりました。

また、面白かったのが指示としては「パブリックメソッドをテストしてください」と書いていたのですが、以下のように無理やりプライベートメソッドをテストしようとしてくれていました。

  // noSpacePatterはprivateなメソッド
  describe("noSpacePattern", () => {
    it("should return a string with blank removed", () => {
      const service = new AwsService("Amazon Product", "");
      const spacingRule = new SpacingRule(service);
      // 無理やり呼び出すテクニックに感心しました
      expect(spacingRule["noSpacePattern"]()).toBe("AmazonProduct");
    });
  });

手直しの指示を出してもよかったのですが、結局自分でちょっとずつ修正するのが早そうだったので自分で修正していきテストを完成させていきました。

ちょっとずつ変えて作ったテストコード
describe("SpacingRule", () => {
  describe("getMaybe", () => {
    it("should return null if there is no pattern", () => {
      const service = new AwsService("Product", prefixAmazon);
      const spacingRule = new SpacingRule(service);
      expect(spacingRule.getMaybe()).toBeNull();
    });

    it("should return a Excessive Space Name", () => {
      const service = new AwsService("ProductName", prefixAmazon);
      const spacingRule = new SpacingRule(service);
      const rule: RuleParam = {
        expected: "ProductName",
        // 不要なスペースを入れたパターンが返却される
        patterns: ["Product Name"],
        options: { wordBoundary: true },
      };
      expect(spacingRule.getMaybe()).toStrictEqual(rule);
    });

    it("should return a space Deleted Name", () => {
      const service = new AwsService("Product Name", prefixAmazon);
      const spacingRule = new SpacingRule(service);
      const rule: RuleParam = {
        expected: "Product Name",
        // スペースを削除したパターンが返却される
        patterns: ["ProductName"],
        options: { wordBoundary: true },
      };
      expect(spacingRule.getMaybe()).toStrictEqual(rule);
    });

    it("should return a RuleParam with both noSpacePattern and spaceDelimitedPattern", () => {
      const service = new AwsService("ProductName", prefixAmazon);
      const spacingRule = new SpacingRule(service);
      const rule: RuleParam = {
        expected: "Amazon AWS Product Name",
        patterns: ["AmazonAWSProductName", "Amazon AWS Product Name"],
        options: { wordBoundary: true },
      };
      expect(spacingRule.getMaybe()).toStrictEqual(rule);
    });
  });

});

GitHub Copilotと一歩ずつテストを作り上げていく様子

次にCopilotを使ってテストを書いてみました。

土台としてChatGPTが最初に作ってくれたテストを1つだけ残した状態でテストを書いてみます。

describe("SpacingRule", () => {
  describe("getMaybe", () => {
    it("should return null if there is no pattern", () => {
      const service = new AwsService("Product", prefixAmazon);
      const spacingRule = new SpacingRule(service);
      expect(spacingRule.getMaybe()).toBeNull();
    });
  });
});

Copilotに対してはコメントで指示を与えたり、意図を教えるのが良いと聞いたのでコメントを書き始めます。

20230330_copliot_chatgpt_test_copilot1

itと書いた時点で以下のように、ChatGPTが作ってくれたテストのタイトルを参考に提案を出してくれます。

20230330_copliot_chatgpt_test_copilot2

私としてはCloudFrontのようなサービス名だったら、Cloud Frontのように余分なスペースを考慮したルールが返されるという意図を持っていたので、should return execessive spacくらいまで打ち込んでいたら、以下のようにテストのタイトルを提案してくれました。

20230330_copliot_chatgpt_test_copilot3

まぁ良さそうだったのでtabキーを押して提案を受け入れます。

すると今度は以下のようにテスト自体も提案してくれます。

20230330_copliot_chatgpt_test_copilot4

tabキーを押して提案を受け入れます。

20230330_copliot_chatgpt_test_copilot5

私の指示が悪く、ちょっとだけ期待する結果(exptected)が異なっているので、以下のように修正しました。

// write test with noSpacePattern like CloudFront
    it("should return execessive space rule if there is noSpacePattern", () => {
      const service = new AwsService("CloudFront", prefixAmazon);
      const spacingRule = new SpacingRule(service);
      const result = spacingRule.getMaybe();
      expect(result).toEqual({
        expected: "CloudFront",
        // Copliotは `Cloud Front ` という末尾に余分なスペースを入れた結果を提案してきていた
        // あんな拙い指示をちゃんと守っているのが恐ろしく、すごい
        patterns: ["Cloud Front"],
        options: { wordBoundary: true },
      });
    });

すごくないですか?

乗ってきたので次のテストを書いていきます。

本来はProduct Nameというサービス名をProductNameのようにスペースを消してしまう誤りを検知するためのルールを作る関数をチェックします。

ちょっとコメントを書いている段階で私の意図を察してAmazon Elastic Compute Cloudのようなサービス名まで含めて提案してくれます。

やばくないですか?(語彙力)

20230330_copliot_chatgpt_test_copilot6_with_comment

そしてテストコードも良い感じに提案してくれます。

20230330_copliot_chatgpt_test_copilot7_with_comment

神じゃないですか?(語彙力)

こちらも少し提案から手動で修正して、テストが作れました。

再掲となりますが、以下のように修正の時間を入れても30秒足らずでテストをかけています。

20230330_github_coplilot_test_flow

やればやるほどCopilotの提案も良くなっていくので、非常に楽しくテストコードを書くことができました。

最後に

Copilotさんの提案力とVSCodeにそのまま書いてくれる手軽さがすばらしかったです。

今回はChatGPTに土台を作ってもらってCopilotで後続のテストを作っていくという形式を試してみました。

最初からCopilotだけでやるのも良いと思うので、ぜひみなさんもベストなAI活用を考えて、広めてください!

Copilot Xはさらに進化した体験ができそうでとても楽しみです。私も即waitlistにjoinしています。

まだご存じではなかった方も、ぜひ一度チェックしてみてください。

以上今泉でした。この記事がどたなかの時間を1秒でも削ればうれしいです。