🐧

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

2025/02/02に公開

『AIと一緒にテスト駆動開発』でテストコードから実装を生成させたので、今回はテキストで書かれたテスト用件からテストコードを生成させます。

テストコード生成の機能

テストコードのビルドを成功させるために、AI生成前にインタフェースだけは手動で作成する前提とします。

  • test-case.mdに記述されたテスト要件を満たすテストコードをsrc/testフォルダ配下に生成する
  • 生成したコードに対してビルドを実行する
  • ビルドまたはテストが失敗した場合、コードを修正して再度ビルドを実行する
  • コードの再生成を5回繰り返してもビルドが失敗する場合は、コードの再生成をやめる

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

プラグインを使ってAIが生成したコードを紹介します。前回自身でテストコードを実装した部分をAIに実装してもらいました。
使用したAIはGitHub Copilotです。

FizzBuzz

テスト要件

# FizzBuzzのテスト
## テスト対象
./index.tsのgetFizzBuzz = (num: number): stringメソッド

## ケース1
入力値:15の倍数、期待値:FizzBuzz

## ケース2
入力値:3の倍数、期待値:Fizz

## ケース3
入力値:5の倍数、期待値:Buzz

AIが生成したコード

const getFizzBuzz = require('../index').getFizzBuzz;

test('returns FizzBuzz for multiples of 15', () => {
    expect(getFizzBuzz(15)).toBe('FizzBuzz');
    expect(getFizzBuzz(30)).toBe('FizzBuzz');
});

test('returns Fizz for multiples of 3', () => {
    expect(getFizzBuzz(3)).toBe('Fizz');
    expect(getFizzBuzz(9)).toBe('Fizz');
});

test('returns Buzz for multiples of 5', () => {
    expect(getFizzBuzz(5)).toBe('Buzz');
    expect(getFizzBuzz(10)).toBe('Buzz');
});

具体的な値を入力することもできるのですが、データバリエーションを揃えたい場合は抽象的に指示できるのはいいですね。特にデータの個数は指示してませんが2つずつ生成されました。
このテストコードから、実装の生成もAIに依頼しましたが、期待通りの実装が生成されました。

曜日の取得

テスト要件

## テスト対象
../index.tsのgetDayOfWeek = (dateString: string, lang: string): stringメソッド

## ケース1
日本語を指定すると日本語の曜日表記となること
入力値:
 第一引数:'2025/01/01'から始まる一週間,
  第二引数: 'ja'固定、
  期待値:'水曜日'から始まる曜日
入力値:
 第一引数:'2026/01/01'から始まる一週間,
  第二引数: 'ja'固定、
  期待値:'木曜日'から始まる曜日

## ケース2
英語を指定すると英語の曜日表記となること
入力値:
 第一引数:'2025/01/01'から始まる一週間,
  第二引数: 'en'固定、
  期待値:'Wednesday'から始まる曜日
入力値:
 第一引数:'2026/01/01'から始まる一週間,
  第二引数: 'en'固定、
  期待値:'Thursday'から始まる曜日

AIが生成したコード

const getDayOfWeek = require('../index').getDayOfWeek;

describe('getDayOfWeek', () => {
    test('日本語を指定すると日本語の曜日表記となること', () => {
        expect(getDayOfWeek('2025/01/01', 'ja')).toBe('水曜日');
        expect(getDayOfWeek('2025/01/02', 'ja')).toBe('木曜日');
        expect(getDayOfWeek('2025/01/03', 'ja')).toBe('金曜日');
        expect(getDayOfWeek('2025/01/04', 'ja')).toBe('土曜日');
        expect(getDayOfWeek('2025/01/05', 'ja')).toBe('日曜日');
        expect(getDayOfWeek('2025/01/06', 'ja')).toBe('月曜日');
        expect(getDayOfWeek('2025/01/07', 'ja')).toBe('火曜日');

        expect(getDayOfWeek('2026/01/01', 'ja')).toBe('木曜日');
        expect(getDayOfWeek('2026/01/02', 'ja')).toBe('金曜日');
        expect(getDayOfWeek('2026/01/03', 'ja')).toBe('土曜日');
        expect(getDayOfWeek('2026/01/04', 'ja')).toBe('日曜日');
        expect(getDayOfWeek('2026/01/05', 'ja')).toBe('月曜日');
        expect(getDayOfWeek('2026/01/06', 'ja')).toBe('火曜日');
        expect(getDayOfWeek('2026/01/07', 'ja')).toBe('水曜日');
    });

    test('英語を指定すると英語の曜日表記となること', () => {
        expect(getDayOfWeek('2025/01/01', 'en')).toBe('Wednesday');
        expect(getDayOfWeek('2025/01/02', 'en')).toBe('Thursday');
        expect(getDayOfWeek('2025/01/03', 'en')).toBe('Friday');
        expect(getDayOfWeek('2025/01/04', 'en')).toBe('Saturday');
        expect(getDayOfWeek('2025/01/05', 'en')).toBe('Sunday');
        expect(getDayOfWeek('2025/01/06', 'en')).toBe('Monday');
        expect(getDayOfWeek('2025/01/07', 'en')).toBe('Tuesday');

        expect(getDayOfWeek('2026/01/01', 'en')).toBe('Thursday');
        expect(getDayOfWeek('2026/01/02', 'en')).toBe('Friday');
        expect(getDayOfWeek('2026/01/03', 'en')).toBe('Saturday');
        expect(getDayOfWeek('2026/01/04', 'en')).toBe('Sunday');
        expect(getDayOfWeek('2026/01/05', 'en')).toBe('Monday');
        expect(getDayOfWeek('2026/01/06', 'en')).toBe('Tuesday');
        expect(getDayOfWeek('2026/01/07', 'en')).toBe('Wednesday');
    });
});

こちらもテストコード生成も、その後のテストコードから実装の生成も期待通りにできました。ちなみに一度テストケース2の指示を「第二引数: 'ja'固定」と間違えていたのですが、気を利かせてenに直してました。とはいえ無言で指示を無視された時人間が気付けないので、事前にテスト用件をレビューさせた方がよさそうです。

クラスの実装

テスト要件

## テスト対象
../index.tsのHumanクラス

## ケース1
前処理:コンストラクタのnameに'Taro'を指定してHumanクラスをインスタンス化
getName()メソッドを実行すると戻り値が'Taro'になること
getIntroduction()メソッドを実行すると戻り値が'My name is Taro'になること

## ケース2
前処理:コンストラクタのnameに'Megu'を指定してHumanクラスをインスタンス化
getName()メソッドを実行すると戻り値が'Megu'になること
getIntroduction()メソッドを実行すると戻り値が'My name is Megu'になること

AIが生成したコード

const Human = require('../index').Human;

describe('Human class tests', () => {
    test('Case 1: name is Taro', () => {
        const human = new Human('Taro');
        expect(human.getName()).toBe('Taro');
        expect(human.getIntroduction()).toBe('My name is Taro');
    });

    test('Case 2: name is Megu', () => {
        const human = new Human('Megu');
        expect(human.getName()).toBe('Megu');
        expect(human.getIntroduction()).toBe('My name is Megu');
    });
});

クラスに対するテストも無事実装してくれました。

ファイル操作

テスト要件

# TextFileWriterのテスト
## テスト対象
../index.tsのTextFileWriterクラスのwrite(content: string): voidメソッド
TextFileWriterクラスのインタフェース
class TextFileWriter {
    constructor(fileName: string) {
        // nop
    }

    write(text: string) {
        // nop
    }
}

## ケース1
入力が1回の場合
前提:
 ファイルの絶対パスは環境変数OUTPUT_DIR_PATHから導出されること
 テスト時にはOUTPUT_DIR_PATHかには'C:/Users/aya/workspace/test-code/out'を設定する
前処理:
 ファイル名'test.txt'でTextFileWriterクラスを作成する
実行:
 writeメソッドで'Hello, world!'を書き込む
検証:
 ファイルにHello, world!が書きこまれている

## ケース2
入力が複数回の場合
前提:
 ファイルの絶対パスは環境変数OUTPUT_DIR_PATHから導出されること
 テスト時にはOUTPUT_DIR_PATHかには'C:/Users/aya/workspace/test-code/out'を設定する
前処理:
 ファイル名'test2.txt'でTextFileWriterクラスを作成する
実行:
 writeメソッドで'Hello, world!'を書き込む
 もう一度writeメソッドで'Hello, world!'を書き込む
検証:
 ファイルにHello, world!\nHello, world!が書きこまれている

AIが生成したコード

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

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

    beforeAll(() => {
        if (!fs.existsSync(outputDir)) {
            fs.mkdirSync(outputDir, { recursive: true });
        }
    });

    afterEach(() => {
        fs.readdirSync(outputDir).forEach(file => {
            fs.unlinkSync(path.join(outputDir, file));
        });
    });

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

        const filePath = path.join(outputDir, fileName);
        const fileContent = fs.readFileSync(filePath, 'utf-8');
        expect(fileContent).toBe(content);
    });

    test('write multiple inputs', () => {
        const fileName = 'test2.txt';
        const writer = new TextFileWriter(fileName);
        const content = 'Hello, world!';
        writer.write(content);
        writer.write(content);

        const filePath = path.join(outputDir, fileName);
        const fileContent = fs.readFileSync(filePath, 'utf-8');
        expect(fileContent).toBe(`${content}\n${content}`);
    });
});

クラスを扱う場合requireでモジュールを読み込むとビルドエラーになる可能性が多いため、importを使用するようプロントを修正しました。テスト対象のファイルのクリーニングはあえて指示しなかったのですが、一般的な対応としてクリーニング処理も実装してくれています。
ファイル操作はAPIや使い方を調べるのが毎回面倒だな、とおもいつつ実装しているのでテスト要件だけで実装してくれるのは助かります。

テストコードから生成された本体の実装

import * as fs from 'fs';
import * as path from 'path';

export class TextFileWriter {
    private 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) {
        const formattedContent = content.endsWith('\n') ? content : content + '\n';
        fs.appendFileSync(this.filePath, formattedContent, 'utf-8');
    }
}

前回に引き続き、本体の実装は成功しませんでした。
テストケースだけだと、ラインとラインの結合にだけ改行コードを使うのが理解できないようです。

API呼び出し

テスト要件

# Usersのテスト
## テスト対象
../index.tsのUsersクラスのall(): Promise<User[]>メソッド
Usersクラスのインタフェース
interface User {
    userID: string;
    name: string;
    departmentID: string;
}
class Users {
    static async all(): 
}

## ケース1
実行:
 Usersクラスのall()メソッドを呼び出す
検証:
 APIの'https://ai.testdriven.com/users'をリクエストした結果が得られること

AIが生成したコード

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

jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;

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

describe('Users', () => {
    it('should fetch users from API', async () => {
        const users: User[] = [
            { userID: '1', name: 'John Doe', departmentID: 'dept1' },
            { userID: '2', name: 'Jane Smith', departmentID: 'dept2' },
        ];

        mockedAxios.get.mockResolvedValue({ data: users });

        const result = await Users.all();

        expect(mockedAxios.get).toHaveBeenCalledWith('https://ai.testdriven.com/users');
        expect(result).toEqual(users);
    });
});

概ね意図したテストを生成してくれました。
テストケースに明記せずともモックを使用するコードが生成されました。ただ、上記のコードではaxiosをモックしていますが、生成する度にしようするHTTPリクエスターのライブラリは変わっていたので、テスト要件に明記するかpackage.jsonを読み取るように修正した方がよさそうです。
また、Userインタフェースがテストコードに実装されてしまうのも私の意図とは反します。

テストコードから生成された本体の実装

import axios from 'axios';

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

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

本体も意図した通に生成されました。

前回同様ユーザーが所属する部門を返してくれる関数を追加してみます。仕様も前回と同じです。APIには一覧を取得するAPIしか存在しない想定としましょう。ユーザー一覧と、部門一覧をAPIで取得したあと、指定されたユーザーの部門を一覧から取得します。

まずは大雑把に指示を出してみます。

# Usersのテスト
## テスト対象
../index.tsのUsersクラスのgetDepartment(userName: string): Promise<string>メソッド
Usersクラスのインタフェース
class Users {
    static async all(): Promise<User[]>
    static async getDepartment(userName: string): Promise<string>
}

## ケース2
前提:
 HTTPリクエストにはaxiosライブラリを使用すること
実行:
 UsersクラスのgetDepartment('Alice')メソッドを呼び出す
検証:
  APIの'https://ai.testdriven.com/departments'をリクエストすること
 APIの'https://ai.testdriven.com/users'をリクエストすること
 戻り値が販売部となること

AIが生成したコード

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

jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;

describe('Users', () => {
    describe('getDepartment', () => {
        it('should return the department of the user', async () => {
            const userName = 'Alice';
            const departmentsResponse = { data: { departments: ['Sales', 'Engineering'] } };
            const usersResponse = { data: { users: [{ name: 'Alice', department: 'Sales' }] } };

            mockedAxios.get.mockImplementation((url) => {
                if (url === 'https://ai.testdriven.com/departments') {
                    return Promise.resolve(departmentsResponse);
                } else if (url === 'https://ai.testdriven.com/users') {
                    return Promise.resolve(usersResponse);
                }
                return Promise.reject(new Error('not found'));
            });

            const department = await Users.getDepartment(userName);

            expect(mockedAxios.get).toHaveBeenCalledWith('https://ai.testdriven.com/departments');
            expect(mockedAxios.get).toHaveBeenCalledWith('https://ai.testdriven.com/users');
            expect(department).toBe('Sales');
        });
    });
});

予想通りではありますが、APIのインタフェースが意図した通りになりませんでした。多少https://ai.testdriven.com/usersを呼び出す意図を汲み取ってくれるかと期待したのですが、期待が高すぎたようです。
また「販売部」と指定しているのに「Sales」と英訳されています。

ここで少々プロンプトを修正し、以下の要望を追加しました。

  • テストデータが必要な場合はヒットしないデータを3件程度含むようにしてください。
  • 引数やテストデータが指定されている場合は正確に実装に反映してください。
  • テストケースは日本語で記述してください。

テスト要件の前提に使用するAPIのインタフェース情報も付加します。

# Usersのテスト
## テスト対象
../index.tsのUsersクラスのgetDepartment(userName: string): Promise<string>メソッド
Usersクラスのインタフェース
class Users {
    static async all(): Promise<User[]>
    static async getDepartment(userName: string): Promise<string>
}

## ケース2
前提:
 HTTPリクエストにはaxiosライブラリを使用すること
 'https://ai.testdriven.com/users'はユーザー一覧を取得するAPIである。正常レスポンスは[{userID: string, name: string, departmentID: string}]である。
  'https://ai.testdriven.com/departments'は部門一覧を取得するAPIである。正常レスポンスは[{departmentID: string, departmentName: string}]である。
実行:
 UsersクラスのgetDepartment('Alice')メソッドを呼び出す
検証:
 APIの'https://ai.testdriven.com/users'をリクエストすること
  APIの'https://ai.testdriven.com/departments'をリクエストすること
 戻り値が販売部となること

AIが生成したコード

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

jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;

describe('Usersクラスのテスト', () => {
    test('getDepartment("Alice")メソッドのテスト', async () => {
        // モックデータ
        const usersResponse = [
            { userID: '1', name: 'Alice', departmentID: '101' },
            { userID: '2', name: 'Bob', departmentID: '102' },
            { userID: '3', name: 'Charlie', departmentID: '103' }
        ];
        const departmentsResponse = [
            { departmentID: '101', departmentName: '販売部' },
            { departmentID: '102', departmentName: '開発部' },
            { departmentID: '103', departmentName: '人事部' }
        ];

        // モックの設定
        mockedAxios.get.mockImplementation((url) => {
            if (url === 'https://ai.testdriven.com/users') {
                return Promise.resolve({ data: usersResponse });
            } else if (url === 'https://ai.testdriven.com/departments') {
                return Promise.resolve({ data: departmentsResponse });
            }
            return Promise.reject(new Error('not found'));
        });

        // テスト実行
        const department = await Users.getDepartment('Alice');

        // 検証
        expect(mockedAxios.get).toHaveBeenCalledWith('https://ai.testdriven.com/users');
        expect(mockedAxios.get).toHaveBeenCalledWith('https://ai.testdriven.com/departments');
        expect(department).toBe('販売部');
    });
});

今度は意図した通りに生成されました。モックの引数匂い時手戻り値を変える部分もしっかりと実装されています(今思えばGETメソッドの指示が漏れてましたが、AIが推測して補完してくれてます)。
ここまでくると自分で実装するより、自動生成するほうが10倍は早くなります。

テストコードから生成された本体の実装

import axios from 'axios';

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

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

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

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

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

        const department = departmentsResponse.data.find((dept: Department) => dept.departmentID === user.departmentID);
        if (!department) {
            throw new Error('Department not found');
        }

        return department.departmentName;
    }
}

本体もテストを満たすように生成されました。

プラグインの実装の解説

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

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

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

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


// AIがコードを生成するためのプロンプト
const GENERATION_PROMPT = `あなたは優秀なプログラマです。
あなたの仕事はユーザーから与えられたテストケースを実装することです。
テスティングフレームワークにはjestを使用してください。
モジュールの読み込みはimportを使用してください。
テストデータが必要な場合はヒットしないデータを3件程度含むようにしてください。
引数やテストデータが指定されている場合は正確に実装に反映してください。
テストケースは日本語で記述してください。
レスポンスはMarkdownのコードブロック形式でしてください。
以下にレスポンスの例を示します。

\`\`\`ts: sum.test.ts
const sum = require('./sum');

test('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3);
});
\`\`\`
`;

async function sendTestCaseGenerateRequest(model: vscode.LanguageModelChat, testCase: string, messageHistory: string[]) {
    const messages = [
        vscode.LanguageModelChatMessage.User(GENERATION_PROMPT),
        vscode.LanguageModelChatMessage.User(testCase)
    ];
    messageHistory.push(GENERATION_PROMPT);
    messageHistory.push(testCase);

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

続いて、ビルドの失敗からコードの再生成を要求する部分です。これは前回のコード再生とほぼ同じです。

// AIが生成したコードを修正するためのプロンプト
const FIX_PROMPT = `あなたは優秀なプログラマです。
あなたの仕事は与えられたビルドエラーから、エラーを修正した完全なコードを提案することです。
レスポンスはMarkdownのコードブロック形式でしてください。
以下にレスポンスの例を示します。
\`\`\`js: fooBar.js
const sum = require('./sum');

test('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3);
});
\`\`\`
`;

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

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

感想

テストコードをAIに生成してもらうことで、かなりの作業時間を削減できる実感が得られました。APIの使用法を調査する時間がかなり削減されます。また、クリーニング処理も補完してくれたように、自分で実装するより品質が向上したと感じます(自分で実装したときは2、3度テストを実行して気付いた)。モックに関してもモックを使うように指示しないといけないだろうな、と思っていたので何も指示しなくてもモックを使用したのは驚きました。ただ逆にモックを使いたくない場合は明確に指示をしないといけません。
とはいえ、毎回意図通りにテストを生成するとは限らないので明記するほうが安全だとは思います。

テスト駆動開発のためのテスト要件はある程度経験を積んだプログラマが書くようなものになっています。テストケースの分割手法やモックを使用すること自体、プログラマ初心者には難しいでしょう。そういった意味ではこのプラグインは革新的な物であるとは感じません。これはこのプラグイン自体が人間駆動のものだからです。AI駆動とすることで、人間の知識に縛られない革新となるかもしれません。
ただ、私の趣味趣向としてAIが生成したものをただレビューする作業に面白味を見いだせず、人間駆動のプラグインとなってます。この考えは社会から求められている物と少々乖離があるように感じてもいますが。

次は作成したテスト要件をAIにレビューしてもらう機能をつけてみたいです。また、自動生成が成功していないファイル操作のクラスについても、解決策を考えてみたいです。

Discussion