AIプログラミングアシスタントツール「GitHub Copilot Chat」を使って、JavaScriptによるWebアプリケーションの開発の仕方を学ぶ本連載。今回は、作成したアプリの品質を保証するための「テスト」を、Copilot Chatを使ってアプリに組み込んでいく。

現在のアプリケーション開発では、実装と同時にテストコードを作るのが当たり前になっている。テストコードの作成は初心者にとってハードルが高く感じられるかもしれないが、Copilot Chatを活用すればこれも対話的に進めることができる。

なぜテストが必要なのか

アプリ開発において、テストはきわめて重要な役割を果たす。

まず、テストがあることで、アプリが正しく動作していることを素早く確実に確認できる。アプリ開発は、コードを書いて、そのコードが期待通りに動くかどうかを確認するという作業の繰り返しである。テストがあれば、手動で動作確認を行う手間を省き、自動的にアプリの正しさを検証できる。

例えば、最初にコードを書いた直後は正しく動いていても、後で別の機能を追加するなどコードを修正した際に、意図せず既存の機能の動きを変えてしまうような事態はよく発生する。テストがあれば、そうした問題をすぐに発見できるようになる。

また、将来の改修が安全に行えるようになるというメリットもある。アプリに新機能を追加したりコードを整理したりする際、テストがあれば「既存の機能が壊れていないか」を自動的に確認できる。これにより、安心してコードを改善できる。

Copilot ChatのようなAIを利用する場合、これらのメリットはいっそう大きな意味を持つことになる。AIにコードの生成や改善を依頼した場合、それが正しく動作するコードかどうかの検証は、自分で書いたコードよりもさらに慎重になる必要があるからだ。あらかじめテストコードを用意しておくことで、AIの提案を適用していいのかどうかを素早く確認できるようになる。

JavaScript用のテストフレームワーク「Jest」

JavaScript開発にはさまざまなテスト用フレームワークがあるが、今回はその中でも最も広く使われている「Jest」を使用する。JestはFacebookを提供しているMetaが開発したJavaScript/TypeScript向けのテストフレームワークで、設定が容易で高速に動作する点が大きな特徴。初心者でも比較的容易に使うことができる。

Jestのインストール

Jestを使うためには、前提条件としてNode.jsと、パッケージマネージャーのnpmが必要となる。したがって、まずは下記WebサイトからNode.jsとnpmをインストールしよう。インストール方法は、コンソール(コマンドプロンプト)でコマンドを実行する方法と、ビルド済みのNode.js(実行形式のインストーラー)をダウンロードして実行する方法がある。

Windows、Linux、macOSのそれぞれのやり方が書かれていて、使用するOSやパッケージマネージャーを選択すると、詳しい説明が表示される。OSの情報は自動で読み取られるようになっているので、基本的には最初に表示されたやり方でインストールすれば大丈夫だ。

  • macOS向けのNode.jsのダウンロードページ

    macOS向けのNode.jsのダウンロードページ

Node.js/npmがインストールできたら、続いてJestをインストールしよう。VS Codeに戻ってテストを追加したいプロジェクト(筆者の例ではweatherinfo)を開き、その状態でメニューから「ターミナル」→「新しいターミナル」を選ぶか、またはキーボードショートカットで「Ctrl+Shift+`」を入力してターミナルを表示する。

作業ディレクトリがプロジェクトのホームディレクトリになっていることを確認したら、次の2つのコマンドを順番に実行する。

% npm init -y
% npm install --save-dev jes

「npm init -y」はこのプロジェクトでNode.jsを使うための初期設定をするコマンドで、本来は対話形式での追加の入力が必要だが、-yオプションをつけることで対話式の質問をスキップできる。このコマンドに成功すると「package.json」という設定ファイルが作成される。

「npm install --save-dev jest」は、Jestを使うのに必要なファイルやパッケージをインストールするコマンドになる。

2つのコマンドが実行できたら、次に「package.json」を開いて、scriptsセクションの「test」の項目を探す。最初は次のような記述になっているはずだ。

  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  }

これを次のように書き換える。

  "scripts": {
    "test": "jest"
  }

この設定によって、「npm test」というコマンドでテストを実行できるようになる。

テストファイルの書き方

Jestのテストファイルは、「ファイル名.test.js」という命名規則に従って付ける。例えば、weather.jsに対するテストであれば「weather.test.js」というファイル名にする。

基本的なテストの構造は以下のようになる。

test('テストの説明', () => {
    // 期待する結果を検証
    expect(実際の値).toBe(期待する値);
})

test()関数の第一引数はテストの説明で、第二引数は実際のテスト処理を行う関数である。テスト処理はexpect()とtoBe()を組み合わせて、実際の値が期待通りの値になっているのかを検証するのが基本形となる。

気温の単位変換関数にテストを追加する

天気予報アプリの全体にいきなりテストを追加するのはコードの変更量が多くなってしまうので、まずは小さな部分から始めよう。APIとの通信部分は外部サービスに依存するため、テストが複雑になりやすい。なので最初は、データを整形するような純粋なロジック部分をテスト対象として選ぶのがいいだろう。

本連載で作成した天気予報アプリには、摂氏と華氏を相互変換する機能がある。この変換処理は外部に依存せず、入力に対してかならず同じ結果を返すため、最初に試すテストに適している。それでは、Copilot Chatに以下のように依頼してみよう。

現在、天気予報アプリのscript.jsに摂氏から華氏への変換処理が含まれています。
この変換処理に対するJestの単体テストを追加してください

筆者の環境では、次のような回答が得られ、script.js、package.jsonの修正と、新規でscript.test.jsの作成を提案された。

手順(短く)

1. script.js のブラウザ依存処理を Node で require してもエラーにならないようにガードする。
2. cToF をテストから参照できるように CommonJS でエクスポートする。
3. cToF の Jest 単体テストを追加する(script.test.js)。
4. Jest を実行できるように package.json を追加する(test スクリプトと devDependency)。

変更は最小限で、ブラウザ側の挙動には影響を与えないようにしました。
テストを実行するには依存をインストールしてください:npm install または yarn。
実行: npm tes
  • Copilot ChatにJestのテストコード作成を依頼

    Copilot ChatにJestのテストコード作成を依頼

3つのファイルの変更・作成のポイントは以下のとおり。

  • script.js : ブラウザ専用の DOM 登録を document の存在チェックで囲み、テスト用に cToF を module.exports でエクスポートします
  • script.test.js : cToF の正常系・境界値のテストを追加します
  • package.json : Jest を使えるように最小限の package.json を追加します(devDependency と test スクリプト)。すでに package.json がある場合は scripts.test と devDependencies.jest を追記してください

既存のscript.jsはWebブラウザ上で動作する前提なのでdocumentオブジェクトへの操作を含んでいるが、Jestでテストを実行する際にはWebブラウザーの機能を呼び出さないようにする必要がある。「document の存在チェックで囲み」というのは、その対処を行ったという意味だ。

実際にテストコードを有効にするための処理としては、次のコードが追加されている。

// Node/CommonJS 環境からテスト可能にするエクスポート(影響を最小に)
if (typeof module !== 'undefined' && module.exports) {
    module.exports = { cToF };

module.exportsは、この関数を他のファイルから使えるようにするNode.jsの仕組みである。ブラウザで動くコードとは少し書き方が異なるが、Jestのテスト環境ではこの形式が必要になる。

テストコードの本体は新たに作られた「script.test.js」に記述されている。

const { cToF } = require('./script');

describe('cToF', () => {
    test('0°C は 32°F', () => {
        expect(cToF(0)).toBeCloseTo(32);
    });

    test('100°C は 212°F', () => {
        expect(cToF(100)).toBeCloseTo(212);
    });

    test('-40°C は -40°F(一致する点)', () => {
        expect(cToF(-40)).toBeCloseTo(-40);
    });

    test('小数も正しく変換される(25.5°C ≒ 77.9°F)', () => {
        expect(cToF(25.5)).toBeCloseTo(77.9, 5);
    });
});

このテストでは、cToF()関数に対して4パターンの呼び出しの結果を検証している。expect()にはcToF()の呼び出し結果を渡すので、これがテストする対象の実際の値である。toBeCloseTo()は、実際の値と期待する値が"ほぼ"同じ(デフォルトでは±0.005未満)である場合にテストをパスできる。

テストの実行と結果の読み方

テストは、ターミナルから「npm test」というコマンドを呼び出すことで実行する。現在の状態でnpm testを実行すると、次のような出力になるはずだ。

% npm test

> weatherinfo@1.0.0 test
> jest

 PASS  ./script.test.js
  cToF
    ✓ 0°C は 32°F (2 ms)
    ✓ 100°C は 212°F
    ✓ -40°C は -40°F(一致する点) (1 ms)
    ✓ 小数も正しく変換される(25.5°C ≒ 77.9°F)

Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total
Snapshots:   0 total
Time:        0.535 s
Ran all test suites

PASSはすべてのテストが成功したことを示し、各テストの横にチェックマーク(✓)が表示される。

もしもテストが失敗した場合には、次のような出力になる。

% npm test

> weatherinfo@1.0.0 test
> jest

 FAIL  ./script.test.js
  cToF
    ✕ 0°C は 32°F (1 ms)
    ✓ 100°C は 212°F
    ✓ -40°C は -40°F(一致する点)
    ✓ 小数も正しく変換される(25.5°C ≒ 77.9°F)

  ● cToF › 0°C は 32°F

    expect(received).toBeCloseTo(expected)

    Expected: 32
    Received: 33.8

    Expected precision:    2
    Expected difference: < 0.005
    Received difference:   1.7999999999999972

      3 | describe('cToF', () => {
      4 |       test('0°C は 32°F', () => {
    > 5 |               expect(cToF(0)).toBeCloseTo(32);
        |                               ^
      6 |       });
      7 |
      8 |       test('100°C は 212°F', () => {

      at Object.toBeCloseTo (script.test.js:5:19)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 3 passed, 4 total
Snapshots:   0 total
Time:        0.164 s, estimated 1 s
Ran all test suites

この出力から、4つのテストのうち「0°C は 32°F」が失敗しており、期待される値と実際の値が異なっていたことがわかる。どの行で失敗したかも示されているため、問題箇所を特定しやすい。

JSONデータから情報を抽出する処理にテストを追加する

別のパターンも試してみよう。今度は、OpenWeather APIから取得したJSONデータから必要な情報を抽出する処理に対してテストを追加してみる。この処理はrenderWeather()関数の中にあるが、この関数には表示用HTMLを組み立てる処理も含んでいるので、このままではデータ抽出の部分だけの単体テストはやりにくい。

そこで、最初にテストしたい処理だけを別関数に切り出してみる。このような単純な書き換えは、Copilot Chatに依頼すればすぐに完了する。次のように指示してみよう。

script.jsで、OpenWeather APIのレスポンスJSONから、表示に必要な情報(都市名、気温、天気の説明、アイコンコード)を抽出する処理を別の関数に切り出してください。
関数名はextractWeatherInfoとしてください

筆者の環境では回答は次のようになった。

手順(短く)

1. JSON から必要な表示情報を取り出す extractWeatherInfo(data) を追加する。
2. renderWeather 内の抽出処理を extractWeatherInfo 呼び出しに置き換える。
3. テスト用に Node 環境向けのエクスポートに extractWeatherInfo を追加する。

この説明にあるように、script.jsには次のようにextractWeatherInfo()関数が追加された。

// 追加: OpenWeather API のレスポンスから表示に必要な情報を抽出する
function extractWeatherInfo(data) {
    return {
        name: data.name || '',
        country: data.sys && data.sys.country ? data.sys.country : '',
        cTemp: data.main && typeof data.main.temp === 'number' ? data.main.temp : null,
        description: data.weather && data.weather[0] && data.weather[0].description ? data.weather[0].description : '',
        iconCode: data.weather && data.weather[0] && data.weather[0].icon ? data.weather[0].icon : null
    };

そしてrenderWeather()関数のデータ抽出部分は、次のようにextractWeatherInfo()関数を呼び出す形に書き換えられている。

    // 抽出処理を関数化して呼び出す
    const info = extractWeatherInfo(data);
    const name = info.name;
    const country = info.country;
    const cTemp = info.cTemp;
    const temp = cTemp !== null
        ? (currentUnit === 'C' ? Math.round(cTemp) : Math.round(cToF(cTemp)))
        : '―';
    const unitLabel = currentUnit === 'C' ? '°C' : '°F';
    const description = info.description;
    const iconCode = info.iconCode;
    const iconUrl = iconCode ? `https://openweathermap.org/img/wn/${iconCode}@2x.png` : ''

さらに、Copilot Chatはこの後でextractWeatherInfo()関数にテストを追加することを想定し、エクスポートの定義部分にextractWeatherInfo()関数も付け加えてくれた。

// Node/CommonJS 環境からテスト可能にするエクスポート(影響を最小に)
if (typeof module !== 'undefined' && module.exports) {
    module.exports = { cToF, extractWeatherInfo };

続いて、Copilot ChatにextractWeatherInfo()関数のテストコードも作ってもらおう。

extractWeatherInfo関数に対するJestの単体テストを追加してください。
OpenWeather APIの実際のレスポンス形式に基づいたテストデータを使用してください

筆者の環境では次の回答が得ら、この内容を実装したテストコードとして「script.extract.test.js」が作成された。

手順(短く)

1. OpenWeather の実際のレスポンスを模したサンプルデータを用意する。
2. extractWeatherInfo を require して期待される抽出結果をアサートする Jest テストを作成する。
3. 境界ケース(フィールド欠如・temp が数値でない等)も追加する

script.extract.test.jsの中身は次のとおり。

const { extractWeatherInfo } = require('./script');

describe('extractWeatherInfo', () => {
    test('OpenWeather の標準的なレスポンスから全フィールドを抽出できる', () => {
        const resp = {
            name: 'Tokyo',
            sys: { country: 'JP' },
            main: { temp: 20.3 },
            weather: [{ description: '晴天', icon: '01d' }]
        };
        expect(extractWeatherInfo(resp)).toEqual({
            name: 'Tokyo',
            country: 'JP',
            cTemp: 20.3,
            description: '晴天',
            iconCode: '01d'
        });
    });

    test('weather 配列が空または存在しない場合は description と iconCode が空/ null になる', () => {
        const resp1 = { name: 'X', sys: {}, main: { temp: 5 }, weather: [] };
        expect(extractWeatherInfo(resp1)).toMatchObject({
            description: '',
            iconCode: null
        });

        const resp2 = { name: 'Y', sys: {}, main: { temp: 5 } };
        expect(extractWeatherInfo(resp2)).toMatchObject({
            description: '',
            iconCode: null
        });
    });

    test('main.temp が数値でない場合は cTemp が null になる', () => {
        const resp = {
            name: 'Z',
            sys: {},
            main: { temp: '20' },
            weather: [{ description: '曇り', icon: '02n' }]
        };
        expect(extractWeatherInfo(resp)).toMatchObject({
            cTemp: null,
            description: '曇り',
            iconCode: '02n'
        });
    });

    test('ほとんど情報がないレスポンスでも安全に空値を返す', () => {
        expect(extractWeatherInfo({})).toEqual({
            name: '',
            country: '',
            cTemp: null,
            description: '',
            iconCode: null
        });
    });
});

このテストコードでは、extractWeatherInfo()関数に対して以下の4つのパターンの入力を与え、その出力が期待通りになっているかを検証している。

  • OpenWeather APIの標準的なレスポンスから必要なフィールドの値を抽出できるかどうか
  • weather配列が空または存在しない場合に、descriptionとiconCodeが空とnullになるかどうか
  • main.temp が数値でない場合に、cTempがnullになるかどうか
  • ほとんど情報がないレスポンスの場合でも、エラーにならずに安全に空の値を返せるかどうか

長いので詳細な解説は省略するが(知りたい人はCopilot Chatに聞いてみよう)、extractWeatherInfo()に渡すサンプルデータと、比較対象にしたい想定データを、それぞれJSON形式で指定しているのが、さきほどのcToF()関数のテストと大きく異なる点だ。比較用に使っている関数はtoEqual()とtoMatchObject()の2つ。

toEqual()は、2つのオブジェクトのプロパティが再帰的に等しい(プロパティがオブジェクトだった場合、そのオブジェクトのプロパティも等しい)かどうかを検証する。toMatchObject()は、引数に指定されたオブジェクトのプロパティが、対象オブジェクトのプロパティのサブセットになっているかどうかを検証する。このように、Jestでは関数の戻り値をさまざまな方法で検証することができる。

失敗したテストをCopilot Chatと一緒に直す

もしもテストが失敗した場合には、Copilot Chatに原因を調査してもらうことができる。プロンプトには次のように入力すればよい。

以下のテストが失敗しました。
このテストが失敗する理由を説明して、修正案をください。

[失敗したテスト結果をペースト

Copilot Chatは失敗の原因を分析し、考えられる理由を説明してくれるはずだ。例えば、「変換式が間違っている」、「小数点の計算誤差が原因である」、「テストの期待値が適切ではない」など、原因となりそうな可能性を示し、それに対する修正案を提示してくれる。

ただし、Copilot Chatの提案をそのまま無条件で適用するのは推奨しない。重要なのは、なぜその修正が適切なのかを正しく理解することである。提案された修正内容を確認し、自分で納得できたら適用する、という姿勢で臨むことが大切だ。

テスト依頼のコツ

Copilot Chatに効果的にテストを書いてもらうのにもいくつかのコツがある。

まず、テストを追加したい対象の関数を明確に伝えることだ。ファイル名と関数名を正確に指定するか、または関数のコードをそのままチャットに貼り付けると良い。指定するコンテキストが明確であるほど、適切なテストを作ってくれる。

入力と出力の要件を明示することも重要だ。「この関数は摂氏の温度を受け取り、華氏の温度を返します」のように、関数の仕様について詳しく説明すれば、より正確にさまざまな条件を網羅したテストケースを提案してくれる。それと同時に、どのようなケースをテストしたいかも伝えるようにしよう。例えば、「正常系だけでなく、異常な入力に対する動作もテストしたい」と伝えれば、エラーケースも含めたテストを作ることができる。

使用するテストフレームワークも明確に指定した方がいい。Copilot Chatはプロジェクトの現在の状態や依存関係から適切なフレームワークを推測してくれるが、プロジェクトが大きくなって依存関係が複雑になってくると、判断を誤ることもある。今回のようにJestを使っている場合には、「Jestで」と明示すれば、Jest特有の構文やベストプラクティスに沿ったコードを作ってくれる。

また、テストの作成もアプリ開発と同様に段階的に進めるのが効果的である。まず基本的な正常系のテストを作成し、動作を確認してから、徐々に厳密な境界条件や異常系の検証を追加していくのがいいだろう。

まとめ

この連載では、GitHub Copilot Chatを使ったJavaScript開発の基礎から実践までを学んできた。Copilot Chatは強力なツールだが、何でも正しくやってくれるような魔法の杖ではない。生成されたコードを盲目的に信じるのではなく、動作を確認し、理解し、必要に応じて修正する姿勢が大切で、テストはその品質を保証する手段の一つとなる。

AIを活用した開発は、これからのプログラミングの主流になっていくだろう。AIと対話しながら学び、実践していくスキルは、今後のキャリアにおいて大きな武器となるはずだ。