みなさん、GitHub Copilot使ってますか?
GitHub Copilotは、GitHubとOpenAI社が共同開発したAIを用いたコード補完ツールです。
Copilotは副操縦士という意味ですが、その名の通りコーディング時にペアプログラミングする副操縦士のようにコードの補完をしてくれます。
GitHub Copilotに関する詳しい説明や導入方法についてはこちらを参照してください。
(私はVisual Studio Codeに導入)
GitHub CopilotはAI機能によりコード上にコメントを記入するだけでソースコードを自動的に補完してくれます。
以下のような感じです。
コメントを書いた後の操作は、
補完 -> tab -> Enter -> 補完 -> tab -> Enter ...
たったこれだけです。
左手と右手の小指しか動かしてません。
すごいですね。ほぼコメント書くだけでプロダクトコードが書けてしまう。
ここで思ったわけです。Copilot使ったらTDDってどうなるんや??
テスト駆動開発(TDD)とは
テスト駆動開発(test-driven development; TDD)とは、プログラム開発手法の一種で、プログラムに必要な各機能について、最初にテストを書き(これをテストファーストと言う)、そのテストが動作する必要最低限な実装をとりあえず行なった後、コードを洗練させる、という短い工程を繰り返すスタイルである。多くのアジャイルソフトウェア開発手法、例えばエクストリーム・プログラミングにおいて強く推奨されている。近年はビヘイビア駆動開発へと発展を遂げている。
このTDDにおける開発サイクルは以下のようになります。
- 失敗するテストを書く (レッド)
- テストを迅速に動作させる最小限のコードを書く。酷いコードで良い(グリーン)
- きれいなコードにする(リファクタリング)
この順序でコードを書くことで「動作するきれいなコード」を保つことができます。
では、このTDDをCopilotを使って実現しようとするとどうなるか、試してみましょう。
事前準備
今回は以下の環境で簡単な実装を試してみたいと思います。
開発言語 | Typescript |
実行環境 | Node.js |
Testing Framework | Jest |
エディタ | Visual Studio Code |
プロジェクト作成
パッケージ管理ツールインストール。今回はYarnを利用。npmなど他のパッケージ管理ツールを使っても問題ないです。
npm install -g yarn
ディレクトリ作成
mkdir copilot-tdd
cd copilot-tdd
初期設定
yarn init -y
yarn add -D typescript jest ts-jest @types/jest
tsc --init
mkdir src tests
touch src/index.ts tests/index.test.ts
ディレクトリは以下のようになります。
├── node_modules
├── package.json
├── src
│ └── index.ts
├── tests
│ └── index.test.ts
├── tsconfig.json
├── yarn-error.log
└── yarn.lock
スクリプト追加。
テストを実行するためのスクリプトをpackage.json
に追加します。
{
"name": "copilot-tdd",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"scripts": {
"test": "jest"
},
"devDependencies": {
"@types/jest": "^29.5.3",
"jest": "^29.6.1",
"ts-jest": "^29.1.1",
"typescript": "^5.1.6"
}
}
追加したのは以下の部分
"scripts": {
"test": "jest"
},
Jest設定ファイル作成
ts-jestはJest用ソースマップをサポートするTypeScriptのプリプロセッサ。これを使うことでTypeScriptで書かれたプロジェクトをJestでテストできます。
yarn ts-jest config:init
ここまでで下準備完了です。
以下のコマンドを実行してみましょう。
yarn test
エラーが出ますが、これは特に問題ありません。まだテストコードもプロダクトコードも書いてないですもんね。
自信を持って次に進みましょう。
% yarn test
yarn run v1.22.19
$ jest
FAIL tests/index.test.ts
● Test suite failed to run
Your test suite must contain at least one test.
at onResult (node_modules/@jest/core/build/TestScheduler.js:133:18)
at node_modules/@jest/core/build/TestScheduler.js:254:19
at node_modules/emittery/index.js:363:13
at Array.map (<anonymous>)
at Emittery.emit (node_modules/emittery/index.js:361:23)
Test Suites: 1 failed, 1 total
Tests: 0 total
Snapshots: 0 total
Time: 2.872 s, estimated 3 s
Ran all test suites.
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
TDDする
では、ここから本題です。Copilotを使ったTDDをやっていきましょう。
今回のお題はFizz Buzz問題をやってみましょう。
Fizz Buzzのルールは以下のとおりです。
![create_test.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/415206/2d0bf7a5-f721-ffca-3130-80828c3db36d.gif)
- 最初のプレイヤーは「1」と数字を発言する。
- 次のプレイヤーは直前のプレイヤーの発言した数字に1を足した数字を発言していく。
- ただし、3の倍数の場合は「Fizz」(Bizz Buzzの場合は「Bizz」)、5の倍数の場合は「Buzz」、3の倍数かつ5の倍数の場合(すなわち15の倍数の場合)は「Fizz Buzz」(Bizz Buzzの場合は「Bizz Buzz」)を数の代わりに発言しなければならない。
- 発言を間違えた者や、ためらった者は脱落となる。
これをTDDの手順に従って実装していきます。Copilotと共に。
今回は、実験の場なのでなるべく頭を使わずにあえてCopilotがサジェストしてくれるコードをそのまま受け入れることにします。
- 失敗するテストを書く (レッド)
- テストを迅速に動作させる最小限のコードを書く。酷いコードで良い(グリーン)
- きれいなコードにする(リファクタリング)
失敗するテストを書く (レッド)
初めに以下のようにindex.test.tsにFizz Buzzのルール(仕様)をコメントに記述します。
/**
* FizzBuzzルール
* 最初のプレイヤーは「1」と数字を発言する。
* 次のプレイヤーは直前のプレイヤーの発言した数字に1を足した数字を発言していく。
* ただし、3の倍数の場合は「Fizz」(Bizz Buzzの場合は「Bizz」)、5の倍数の場合は「Buzz」、3の倍数かつ5の倍数の場合(すなわち15の倍数の場合)は「Fizz Buzz」(Bizz Buzzの場合は「Bizz Buzz」)を数の代わりに発言しなければならない。
* 発言を間違えた者や、ためらった者は脱落となる。
*/
そのあとは、Copilotがサジェストしてくれるものをそのまま受け入れてみます。
こんな感じ
/**
* FizzBuzzルール
* 最初のプレイヤーは「1」と数字を発言する。
* 次のプレイヤーは直前のプレイヤーの発言した数字に1を足した数字を発言していく。
* ただし、3の倍数の場合は「Fizz」(Bizz Buzzの場合は「Bizz」)、5の倍数の場合は「Buzz」、3の倍数かつ5の倍数の場合(すなわち15の倍数の場合)は「Fizz Buzz」(Bizz Buzzの場合は「Bizz Buzz」)を数の代わりに発言しなければならない。
* 発言を間違えた者や、ためらった者は脱落となる。
*/
import { FizzBuzz } from '../src/fizzbuzz';
describe('FizzBuzz', () => {
describe('play', () => {
test('1を渡したら1を返す', () => {
const fizzbuzz = new FizzBuzz();
const result = fizzbuzz.play(1);
expect(result).toBe('1');
});
});
});
コードを見ると、以下の箇所ではfizzbuzzというファイルをインポートしようとしてるので、ここは書き換えます。(悪くないですが、今回はindex.tsに全部書いてしまおうと思うので)
import { FizzBuzz } from '../src/fizzbuzz';
-> import { FizzBuzz } from '../src/index';
FizzBuzzというクラスからオブジェクトをnewしてplayというメソッドを呼ぶんですね。
この状態で、テストを実行すると結果は以下のようになります。
FAIL tests/index.test.ts
● Test suite failed to run
tests/index.test.ts:8:26 - error TS2306: File '/Users/kmasayoshi/workspace/KAG/copilot-tdd/src/index.ts' is not a module.
8 import { FizzBuzz } from '../src/index';
~~~~~~~~~~~~~~
Test Suites: 1 failed, 1 total
Tests: 0 total
Snapshots: 0 total
Time: 3.325 s
FAILしました。そりゃそうです。プロダクトコードは何も実装してませんからね。
いい感じに、失敗してくれました。
テストを迅速に動作させる最小限のコードを書く(グリーン)
では、次にプロダクトコードを書いてテストを迅速に成功させましょう。
Copilotのサジェストで出来上がったコードはこちら。
export class FizzBuzz {
play(number: number): string {
return '1';
}
}
いたってシンプル。必ず'1'を返してくれるステキなコードですね。
やりやがったな!!なんて醜いコードだ!
いいんですよ。
これが迅速に動作させる最小のコードです。
テストを実行してみましょう。
結果は以下のとおりです。
PASS tests/index.test.ts
FizzBuzz
play
✓ 1を渡したら1を返す (4 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 3.105 s
PASSしましたね。
きれいなコードにする(リファクタリング)
さて次にコードをきれいにしていきます。
さすがに必ず'1'を返すというのはあんまりなので、引数に渡された値を文字列にして返すようにします。
export class FizzBuzz {
play(number: number): string {
return number.toString();
}
}
テスト結果は、、、グリーン
PASS tests/index.test.ts
FizzBuzz
play
✓ 1を渡したら1を返す (6 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 3.579 s
いい感じです。
失敗するテストを書く2(レッド)
この調子でテストを増やしていきます。もちろんCopilotのサジェストされるがままに突き進んでいきます。(今回は実験なので特別ですよ。プロダクト開発では適切にテスト設計しましょう。)
次にレッドとなるのが3を渡したタイミングです。
describe('FizzBuzz', () => {
describe('play', () => {
test('1を渡したら1を返す', () => {
const fizzbuzz = new FizzBuzz();
const result = fizzbuzz.play(1);
expect(result).toBe('1');
});
test('2を渡したら2を返す', () => {
const fizzbuzz = new FizzBuzz();
const result = fizzbuzz.play(2);
expect(result).toBe('2');
});
test('3を渡したらFizzを返す', () => {
const fizzbuzz = new FizzBuzz();
const result = fizzbuzz.play(3);
expect(result).toBe('Fizz');
});
});
});
テスト結果
FAIL tests/index.test.ts
FizzBuzz
play
✓ 1を渡したら1を返す (4 ms)
✓ 2を渡したら2を返す
✕ 3を渡したらFizzを返す (3 ms)
● FizzBuzz › play › 3を渡したらFizzを返す
expect(received).toBe(expected) // Object.is equality
Expected: "Fizz"
Received: "3"
23 | const fizzbuzz = new FizzBuzz();
24 | const result = fizzbuzz.play(3);
> 25 | expect(result).toBe('Fizz');
| ^
26 | });
27 | });
28 | });
at Object.<anonymous> (tests/index.test.ts:25:22)
Test Suites: 1 failed, 1 total
Tests: 1 failed, 2 passed, 3 total
Snapshots: 0 total
Time: 3.245 s
テストを迅速に動作させる最小限のコードを書く2(グリーン)
では、レッドになったので、迅速に最小コードを書きましょう。
既存のplayメソッドを一旦削除して引数が3のケースを書いて、、、
と思いましたが、Copilot様のご指示により一瞬にして出来上がってしまいました。
出来上がったコードは以下のとおりです。
export class FizzBuzz {
play(num: number): string {
if (num % 15 === 0) {
return 'Fizz Buzz';
}
if (num % 3 === 0) {
return 'Fizz';
}
if (num % 5 === 0) {
return 'Buzz';
}
return num.toString();
}
}
お、終わっとるやん。。。
ということで実験は終了。
試しにテストコードの方に不足していたテストを追加しましたが、もちろんテスト結果はずっとグリーン。
Copilot様、瞬殺でしたね。
ちなみに、TDDの順序に従わず、プロダクトコードを先に書くという方法も試してみましたが、コードを書き切るまでの時間はそちらの方が早い結果となりました。
こんな感じです。
さいごに
今回、Copilotを使ったTDDについて試してみました。補完により大量のコードが量産されることになるため、改めてテストの重要性を感じました。
Copilotがプログラミニングにおいて良い相棒となることは間違いがないので、これからも試行錯誤しながらCopilotを使った場合のよりよいコードの書き方を模索していきたいですね。
ということで、GitHub CopilotでTDDやってみました。