🐨

AIと一緒にテスト駆動開発

2025/01/04に公開

AIと一緒にテスト駆動開発してみたいと思い立ち、VSCodeのプラグインを作成してみました。
作ったプラグインが生成したソースコードの紹介と、プラグインの実装の解説を記載します。

プラグインの機能

  • src/index.test.tsに実装されたテストコードを満たすコードをsrc/index.tsに生成する
  • 生成したコードに対してビルドを実行する
  • 生成したコードに対してテストを実行する
  • ビルドまたはテストが失敗した場合、src/index.tsのコードを修正して再度ビルド&テストを実行する
  • コードの再生成を5回繰り返してもテストが失敗する場合は、コードの再生成をやめる

実際にAIがテストに失敗しながらコードを生成している様子

作ったプラグインが生成したソースコードの紹介

プラグインを使ってAIが生成したコードを紹介します。
使用したAIはGitHub Copilotです。

FizzBuzz

まずは手始めにFizzBuzzが実装されるか試してみます

実装したテストケース

index.test.ts
import { getFizzBuzz } from './index';

test('test', () => {
    expect(getFizzBuzz(15)).toBe('FizzBuzz');
});

AIが生成したコード

index.ts
export const getFizzBuzz = (num: number): string => {
    if (num % 15 === 0) return 'FizzBuzz';
    if (num % 3 === 0) return 'Fizz';
    if (num % 5 === 0) return 'Buzz';
    return num.toString();
}

全テストケース記載する前にメソッド名から正しいコードを推測されてしまいました。
テスト駆動開発なので、テストケースを満たす最低限だけ実装してほしかったのですが。間違ってないので良いです。

曜日の取得(テスト失敗からコードを再生成したパターン)

実装したテストケース

index.test.ts
import { getDayOfWeek } from './index';

test('test', () => {
    expect(getDayOfWeek('2025/01/01')).toBe('水曜日');
});

AIが生成したコード

index.ts
export const getDayOfWeek = (dateString: string): string => {
    const days = ['日曜日', '月曜日', '火曜日', '水曜日', '木曜日', '金曜日', '土曜日'];
    const date = new Date(dateString);
    return days[date.getDay()];
}

またもや完璧ですね。折角なので、指定した言語で表記してもらいましょう。

実装したテストケース

index.test.ts
import { getDayOfWeek } from './index';

test('test in Japanese', () => {
    expect(getDayOfWeek('2025/01/01', 'ja')).toBe('水曜日');
    expect(getDayOfWeek('2025/01/08', 'ja')).toBe('水曜日');

    expect(getDayOfWeek('2025/01/02', 'ja')).toBe('木曜日');
    expect(getDayOfWeek('2025/01/09', 'ja')).toBe('木曜日');
});

test('test in English', () => {
    expect(getDayOfWeek('2025/01/01', 'en')).toBe('Wednesday');
    expect(getDayOfWeek('2025/01/08', 'en')).toBe('Wednesday');

    expect(getDayOfWeek('2025/01/02', 'en')).toBe('Thursday');
    expect(getDayOfWeek('2025/01/09', 'en')).toBe('Thursday');
});

AIが最初に生成したコード

エラーが出ているのですが、ビルドの失敗結果から再度コードが生成されました。

index.ts
export const getDayOfWeek = (dateString: string, lang: 'en' | 'ja'): string => {
    const daysOfWeek = {
        en: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
        ja: ['日曜日', '月曜日', '火曜日', '水曜日', '木曜日', '金曜日', '土曜日']
    };

    const date = new Date(dateString);
    const dayIndex = date.getDay();

    return daysOfWeek[lang][dayIndex];
}

上記のコードではテストが全て通りました。

クラスの実装

クラスの実装もしてくれるか試してみました。

実装したテストケース

index.test.ts
import { Human } from './index';

test('test', () => {
    expect(new Human('Taro').getName()).toBe('Taro');
});

AIが生成したコード

index.ts
export class Human {
    name: string;

    constructor(name: string) {
        this.name = name;
    }

    getName() {
        return this.name;
    }
}

問題ないですね。新たにgetIntroductionメソッドを追加してみます。

実装したテストケース

index.test.ts
import { Human } from './index';

test('test', () => {
    const taro = new Human('Taro');
    expect(taro.getName()).toBe('Taro');
    expect(taro.getIntroduction()).toBe('My name is Taro');

    const megu = new Human('Megu');
    expect(megu.getName()).toBe('Megu');
    expect(megu.getIntroduction()).toBe('My name is Megu');
});

AIが生成したコード

index.ts
// src/index.ts
export class Human {
    name: string;

    constructor(name: string) {
        this.name = name;
    }

    getName() {
        return this.name;
    }

    getIntroduction() {
        return `My name is ${this.name}`;
    }
}

ファイル操作

ファイル操作を含む実装も生成してくれるでしょうか。

実装したテストケース

index.test.ts
import { TextFileWriter } from './index';
import * as fs from 'fs';
import * as path from 'path';

test('test', () => {
    const writer = new TextFileWriter('test.txt');
    writer.write('Hello, world!');

    const filePath = path.join('C:/Users/aya/workspace/test-code/out', 'test.txt');
    const data = fs.readFileSync(filePath, 'utf8');
    expect(data).toBe('Hello, world!');

});

AIが生成したコード

index.ts
import * as fs from 'fs';
import * as path from 'path';

export class TextFileWriter {
    private filePath: string;

    constructor(filename: string) {
        this.filePath = path.join('C:/Users/aya/workspace/test-code/out', filename);
    }

    write(content: string) {
        fs.writeFileSync(this.filePath, content, 'utf8');
    }
}

フォルダは設定ファイルや環境変数として設定することが一般的かと思います。なので、テストでも出力先のフォルダを環境変数から取得するようにしてみます。

実装したテストケース

index.test.ts
import { TextFileWriter } from './index';
import * as fs from 'fs';
import * as path from 'path';

test('test', () => {
    process.env.OUTPUT_DIR_PATH = 'C:/Users/aya/workspace/test-code/out';

    const writer = new TextFileWriter('test.txt');
    writer.write('Hello, world!');

    const filePath = path.join(process.env.OUTPUT_DIR_PATH, 'test.txt');
    const data = fs.readFileSync(filePath, 'utf8');
    expect(data).toBe('Hello, world!');

});

AIが生成したコード

index.ts
import * as fs from 'fs';
import * as path from 'path';

export class TextFileWriter {
    filePath: string;

    constructor(filename: string) {
        if (!process.env.OUTPUT_DIR_PATH) {
            throw new Error('OUTPUT_DIR_PATH environment variable is not set');
        }
        this.filePath = path.join(process.env.OUTPUT_DIR_PATH, filename);
    }

    write(content: string) {
        fs.writeFileSync(this.filePath, content, 'utf8');
    }
}

複数行のパターンもテストしておきましょう。

実装したテストケース

index.test.ts
import { TextFileWriter } from './index';
import * as fs from 'fs';
import * as path from 'path';

test('一行', () => {
    process.env.OUTPUT_DIR_PATH = 'C:/Users/aya/workspace/test-code/out';
    const fileName = 'test.txt';
    const filePath = path.join(process.env.OUTPUT_DIR_PATH, fileName);

    if (fs.existsSync(filePath)) {
        fs.unlinkSync(filePath);
    }

    const writer = new TextFileWriter(fileName);
    writer.write('Hello, world!');

    const data = fs.readFileSync(filePath, 'utf8');
    expect(data).toBe('Hello, world!');
});

test('複数行', () => {
    process.env.OUTPUT_DIR_PATH = 'C:/Users/aya/workspace/test-code/out';
    const fileName = 'test2.txt';
    const filePath = path.join(process.env.OUTPUT_DIR_PATH, fileName);

    if (fs.existsSync(filePath)) {
        fs.unlinkSync(filePath);
    }

    const writer = new TextFileWriter(fileName);
    writer.write('Hello, world!');

    const data = fs.readFileSync(filePath, 'utf8');
    expect(data).toBe('Hello, world!');

    writer.write('Hello, AI!');

    const data2 = fs.readFileSync(filePath, 'utf8');
    expect(data2).toBe('Hello, world!\nHello, AI!');

});

こちらについては、AIはテストが通るコードを生成することができませんでした。
AIが生成したコード

index.ts
// src/index.ts
import * as fs from 'fs';
import * as path from 'path';

export class TextFileWriter {
    private filePath: string;

    constructor(fileName: string) {
        this.filePath = path.join(process.env.OUTPUT_DIR_PATH!, fileName);
    }

    write(content: string): void {
        fs.writeFileSync(this.filePath, content, 'utf8');
    }

    append(content: string): void {
        fs.appendFileSync(this.filePath, content + '\n', 'utf8');
    }
}

テストケースを詳しく書いてみる

修正したテストケース

index.test.ts
// 省略

    const data = fs.readFileSync(filePath, 'utf8');
    expect(data).toBe('Hello, world!');
});

test('複数行 1回目と2回目のwriteの結果は改行で結合される、ただし最終行の末尾には改行がつかない', () => {
    process.env.OUTPUT_DIR_PATH = 'C:/Users/aya/workspace/test-code/out';
    const fileName = 'test2.txt';
    const filePath = path.join(process.env.OUTPUT_DIR_PATH, fileName);

// 省略

AIが生成したコード

index.ts
import * as fs from 'fs';
import * as path from 'path';

class TextFileWriter {
    filePath: string;
    buffer: string[];

    constructor(fileName: string) {
        if (!process.env.OUTPUT_DIR_PATH) {
            throw new Error('OUTPUT_DIR_PATH is not defined');
        }
        this.filePath = path.join(process.env.OUTPUT_DIR_PATH, fileName);
        this.buffer = [];
    }

    write(text: string) {
        this.buffer.push(text);
        fs.writeFileSync(this.filePath, this.buffer.join('\n'));
    }
}

export { TextFileWriter };

今度はテストが通るコードを生成してくれました。

API呼び出し

API呼び出しも実装してくれるか試してみます。
API呼び出しに使用するモジュールはaxios、またテストではAPI呼び出し部分はモック化することにします。

実装したテストケース

index.test.ts
import axios from 'axios';
import { Users } from './index';

jest.mock('axios');

test('should fetch users', async () => {
    const users = [{ name: 'Bob' }];
    const resp = { data: users };

    const spy = jest.spyOn(axios, 'get').mockResolvedValue(resp);
    const actual = await Users.all();

    expect(spy).toHaveBeenCalledWith('/users.json');
    expect(actual).toEqual(users)
});

AIが生成したコード

index.ts
import axios from 'axios';

export const Users = {
    all: async () => {
        const response = await axios.get('/users.json');
        return response.data;
    }
};

テストが通るコードを生成してくれました。
折角なので、ユーザーが所属する部門を返してくれる関数を追加してみます。APIには一覧を取得するAPIしか存在しない想定としましょう。ユーザー一覧と、部門一覧をAPIで取得したあと、指定されたユーザーの部門を一覧から取得します。

実装したテストケース

index.test.ts
// 省略

test('should get department of user', async () => {
    const users = [
        { userID: '01', name: 'Bob', departmentID: '01' },
        { userID: '02', name: 'Alice', departmentID: '02' },
        { userID: '03', name: 'Tom', departmentID: '03' }
    ];
    const departments = [
        { departmentID: '01', name: '営業部' },
        { departmentID: '02', name: '販売部' },
        { departmentID: '03', name: '人事部' }
    ];
    const respUser = { data: users };
    const respDepartment = { data: departments };

    const spy = jest.spyOn(axios, 'get')
        .mockImplementationOnce(() => Promise.resolve(respUser))
        .mockImplementationOnce(() => Promise.resolve(respDepartment));
    const actual = await Users.getDepartment('Alice');

    expect(spy).toHaveBeenCalledWith('/users.json');
    expect(spy).toHaveBeenCalledWith('/departments.json');
    expect(actual).toEqual('販売部')
});

AIが生成したコード

index.ts
import axios from 'axios';

interface User {
    userID: string;
    name: string;
    departmentID: string;
}

interface Department {
    departmentID: string;
    name: string;
}

export class Users {
    static async all(): Promise<User[]> {
        const response = await axios.get('/users.json');
        return response.data;
    }

    static async getDepartment(userName: string): Promise<string> {
        const usersResponse = await axios.get('/users.json');
        const departmentsResponse = await axios.get('/departments.json');

        const user = usersResponse.data.find((user: User) => user.name === userName);
        const department = departmentsResponse.data.find((dept: Department) => dept.departmentID === user.departmentID);

        return department.name;
    }
}

こちらもテストが通るコードを生成してくれました。
更にユーザーが見つからない場合はエラーにしてほしいので、エラーのテストケースを追加します。

実装したテストケース

index.test.ts
// 省略

test('error get department of user', async () => {
    const users = [
        { userID: '01', name: 'Bob', departmentID: '01' },
        { userID: '02', name: 'Alice', departmentID: '02' },
        { userID: '03', name: 'Tom', departmentID: '03' }
    ];
    const respUser = { data: users };

    const spy = jest.spyOn(axios, 'get')
        .mockImplementationOnce(() => Promise.resolve(respUser));

    try {
        await Users.getDepartment('Dom');
        throw new Error('test failed');
    } catch (e) {
        expect(spy).toHaveBeenCalledWith('/users.json');
        expect(e).toEqual(new Error('User not found'));
    }
});

AIが生成したコード

index.ts
import axios from 'axios';

interface User {
    userID: string;
    name: string;
    departmentID: string;
}

interface Department {
    departmentID: string;
    name: string;
}

export class Users {
    static async getDepartment(userName: string): Promise<string | null> {
        const usersResponse = await axios.get('/users.json');
        const users: User[] = usersResponse.data;

        const user = users.find((u: User) => u.name === userName);
        if (!user) {
            throw new Error('User not found');
        }

        const departmentsResponse = await axios.get('/departments.json');
        const departments: Department[] = departmentsResponse.data;

        const department = departments.find((d: Department) => d.departmentID === user.departmentID);
        return department ? department.name : null;
    }

    static async all(): Promise<User[]> {
        const usersResponse = await axios.get('/users.json');
        return usersResponse.data;
    }
}

テストが通るコードを生成してくれました。

プラグインの実装の解説

コードの全容は下記リポジトリにあります。

https://github.com/nagaoka-aya/test-driven-ai/releases/tag/20250104

こちらのコードを理解するには、下記の2つの知識があれば十分かと思います。

https://code.visualstudio.com/api/get-started/your-first-extension

https://code.visualstudio.com/api/extension-guides/language-model-tutorial

ここではプロンプトのみ紹介します。

まず、テストコードからソースコードの生成を要求する部分です。

const GENERATION_PROMPT = `あなたは優秀なプログラマです。
あなたの仕事はユーザーから与えられたテストコードが全て成功する完全なコードを提案することです。
レスポンスはMarkdownのコードブロック形式でしてください。
ただし、テストを満たすことが論理的に不可能な場合はImpossibleから始めて、以降にその理由を返してください。
以下にレスポンスの例を示します。

### テストを満たすことが論理的に可能な場合
\`\`\`js: fooBar.js
const great = () => {
	console.log("Awesome")
}
\`\`\`

### テストを満たすことが論理的に不可能な場合
Impossible
テストを満たすことが不可能な理由をここに書いてください。
`;

async function sendGenerateRequest(model: vscode.LanguageModelChat, testCode: string, messageHistory: string[]) {
	const messages = [
		vscode.LanguageModelChatMessage.User(GENERATION_PROMPT),
		vscode.LanguageModelChatMessage.User(testCode) // testCodeにはテストコードの全文が渡される
	];
	messageHistory.push(GENERATION_PROMPT); // 後程使用するため、messageHistoryにAIに送るメッセージと、AIからのレスポンスをpushしていく
	messageHistory.push(testCode);

	if (model) {
		let chatResponse = await model.sendRequest(
			messages,
			{},
			new vscode.CancellationTokenSource().token
		);
		return chatResponse;
	}
}

続いて、ビルドやテストの失敗からコードの再生成を要求する部分です。

const FIX_PROMPT = `あなたは優秀なプログラマです。
あなたの仕事は与えられたテストの実行結果から、テストを満たすように修正した完全なコードを提案することです。
レスポンスはMarkdownのコードブロック形式でしてください。
以下にレスポンスの例を示します。
\`\`\`js: fooBar.js
const great = () => {
	console.log("Awesome")
}
\`\`\`
`;

async function sendFixRequest(model: vscode.LanguageModelChat, buildAndTestResult: string, generatedCode: string, messageHistory: string[]) {
	const messages = [
		vscode.LanguageModelChatMessage.Assistant(messageHistory.join('\n')), // 過去のAIとの会話履歴を設定する
		vscode.LanguageModelChatMessage.User(FIX_PROMPT),
		vscode.LanguageModelChatMessage.User(buildAndTestResult), // buildAndTestResultにはビルドやテスト実行時のコンソールのログが渡される
		vscode.LanguageModelChatMessage.User(generatedCode) // generatedCodeにはAIが生成した失敗しているコードが渡される
	];
	messageHistory.push(FIX_PROMPT); // 後程使用するため、messageHistoryにAIに送るメッセージと、AIからのレスポンスをpushしていく
	messageHistory.push(buildAndTestResult);
	messageHistory.push(generatedCode);

	let chatResponse = await model.sendRequest(
		messages,
		{},
		new vscode.CancellationTokenSource().token
	);
	return chatResponse;
}

会話の履歴が、コードを生成するクオリティに関係するかはわかっていませんが、一応設定しています。

感想

意外にも、ほとんどの場合AIは初回のコード生成で完璧なコードを生成することは出来ませんでした(Typescriptは型チェックの設定がプロジェクトによって異なるのでその影響もあるとは思います)。AIにビルドとテスト結果を渡して再生成することで、しっかり動作するコードが生成されていきます。なので、コード生成においてはAIにビルドとテストをさせることは重要なようです。

テストコードを書くぐらいなら、AIに実装させずとも、自分でソースコードを実装することとそれほど利便性は変わらないのでは?と思っていましたが、実際にやってみると結構時間が短縮される気がしました。変数名を考える時間とタイピング時間はAIには勝てません。

とはいえテストを実装するのも敷居が高いので、次はテストをテキストで書いて、テストコードの生成もAIがやってくれるように修正してみたいです。

その他にも、APIをモックではなく実際に呼び出すことを想定したテストでコードを生成してくれるのか、DBアクセスを含むテストからコードを生成してくれるのか、なども試してみたいです。

Discussion