APC 技術ブログ

株式会社エーピーコミュニケーションズの技術ブログです。

株式会社 エーピーコミュニケーションズの技術ブログです。

テストコードをGitHub Copilotに書いてもらう

こんにちは、ACS事業部の瀧澤です。

GitHub Copilot for Business(以下Copilot)を普段業務で使わせてもらっているのですが、サジェストの精度が想像以上に高く、日々のコーディング効率化にすごく役立っています。

今回は重要とはわかっていても後回しになりがちな、テストコードを作成する際にCopilotがどう役立つかに注目してみます。

テスト対象となるコードを用意

検証用に用意した空ディレクトリの中にapp.jsというファイルを作成します。
※本題ではないnpm initやパッケージのインストール手順は省略します

今回はユーザ(属性はidとnameのみ)のCRUDをするだけのシンプルなAPIを作成しました。

app.js

// create api server with the following requirements:
// it has endpoints for CRUD operaions for a resource called "users”

// start coding

const express = require('express');
const app = express();
const port = 3000;

const users = [
    {id: 1, name: 'foo'},
];

app.use(express.json());

app.get('/users', (req, res) => {
    res.send(users);
    }
);

app.get('/users/:id', (req, res) => {
    const user = users.find(user => user.id === parseInt(req.params.id));
    if (!user) res.status(404).send('The user with the given ID was not found.');
    res.send(user);
    }
);

app.post('/users', (req, res) => {
    const user = {
        id: users.length + 1,
        name: req.body.name
    };
    users.push(user);
    res.send(user);
    }
);

app.put('/users/:id', (req, res) => {
    const user = users.find(user => user.id === parseInt(req.params.id));
    if (!user) res.status(404).send('The user with the given ID was not found.');
    user.name = req.body.name;
    res.send(user);
    }
);

app.delete('/users/:id', (req, res) => {
    const user = users.find(user => user.id === parseInt(req.params.id));
    if (!user) res.status(404).send('The user with the given ID was not found.');
    const index = users.indexOf(user);
    users.splice(index, 1);
    res.send(user);
    }
);

app.listen(port, () => console.log(`Example app listening on port ${port}!`));

冒頭のコメントからも分かるとおり、このコードもほぼ全てCopilotに書いてもらっています。
(サジェストされたコードからの修正は「bodyParser.json()ではなくexpress.json()を使うようにした」「usersの初期値定義を追加」の二箇所のみ)
空っぽのファイルからコメントでざっくりした要件を指定するだけでここまで書けるのもすごいですね。

軽く動作確認のため、実際にサーバを起動してPostmanでリクエストを送ってみました。問題なさそうです。

テストコードを書く

早速Copilotの助けを借りてテストコードを書いてみましょう。 app.test.jsという名前でファイルを作成し、以下コメントを書きます。

// write tests for app.js

改行していくとCopilotがコードをサジェストしてくれるので、何も考えずtab+enterで全て受け入れていきます。
結果的に以下のようなコードになりました。

app.test.js

// write tests for app.js

const request = require('supertest');
const app = require('./app');

describe('GET /users', () => {
    it('should return all users', async () => {
        const res = await request(app).get('/users');
        expect(res.statusCode).toEqual(200);
        expect(res.body).toEqual([
            {id: 1, name: 'foo'},
        ]);
    });
});

describe('GET /users/:id', () => {
    it('should return a user if valid id is passed', async () => {
        const res = await request(app).get('/users/1');
        expect(res.statusCode).toEqual(200);
        expect(res.body).toEqual({id: 1, name: 'foo'});
    });

    it('should return 404 if invalid id is passed', async () => {
        const res = await request(app).get('/users/99');
        expect(res.statusCode).toEqual(404);
    });
}
);

describe('POST /users', () => {
    it('should return user if request is valid', async () => {
        const res = await request(app)
            .post('/users')
            .send({name: 'bar'});
        expect(res.statusCode).toEqual(200);
        expect(res.body).toHaveProperty('id');
        expect(res.body).toHaveProperty('name', 'bar');
    });

    it('should return 400 if request is invalid', async () => {
        const res = await request(app)
            .post('/users')
            .send({});
        expect(res.statusCode).toEqual(400);
    });
}
);

describe('PUT /users/:id', () => {
    it('should update the user if valid id is passed', async () => {
        const res = await request(app)
            .put('/users/1')
            .send({name: 'bar'});
        expect(res.statusCode).toEqual(200);
        expect(res.body).toHaveProperty('id');
        expect(res.body).toHaveProperty('name', 'bar');
    });

    it('should return 404 if invalid id is passed', async () => {
        const res = await request(app)
            .put('/users/99')
            .send({name: 'bar'});
        expect(res.statusCode).toEqual(404);
    });
}
);

describe('DELETE /users/:id', () => {
    it('should delete the user if valid id is passed', async () => {
        const res = await request(app).delete('/users/1');
        expect(res.statusCode).toEqual(200);
        expect(res.body).toEqual({id: 1, name: 'foo'});
    });

    it('should return 404 if invalid id is passed', async () => {
        const res = await request(app).delete('/users/99');
        expect(res.statusCode).toEqual(404);
    });
}
);

ぱっと見それぞれのエンドポイントに対するテストをいい感じに書いてくれてそうです。

テスト実行

とりあえず生成されたテストそのままで実行してみましょう。

~/playground/github-copilot $ npm test

> github-copilot@1.0.0 test
> jest


  ●  Cannot log after tests are done. Did you forget to wait for something async in your test?
    Attempted to log "Example app listening on port 3000!".

      41 |     user.name = req.body.name;
      42 |     res.send(user);
    > 43 |     }
         |      ^
      44 | );
      45 |
      46 | app.delete('/users/:id', (req, res) => {

      at console.log (node_modules/@jest/console/build/CustomConsole.js:141:10)
      at Server.<anonymous> (app.js:43:32)

 FAIL  ./app.test.js
  GET /users
    ✕ should return all users (1 ms)
  GET /users/:id
    ✕ should return a user if valid id is passed
    ✕ should return 404 if invalid id is passed
  POST /users
    ✕ should return user if request is valid
    ✕ should return 400 if request is invalid (1 ms)
  PUT /users/:id
    ✕ should update the user if valid id is passed (1 ms)
    ✕ should return 404 if invalid id is passed (1 ms)
  DELETE /users/:id
    ✕ should delete the user if valid id is passed (1 ms)
    ✕ should return 404 if invalid id is passed (4 ms)

  ● GET /users › should return all users

    TypeError: app.address is not a function

       6 | describe('GET /users', () => {
       7 |     it('should return all users', async () => {
    >  8 |         const res = await request(app).get('/users');
         |                                        ^
       9 |         expect(res.statusCode).toEqual(200);
      10 |         expect(res.body).toEqual([
      11 |             {id: 1, name: 'foo'},

      at Test.serverAddress (node_modules/supertest/lib/test.js:46:22)
      at new Test (node_modules/supertest/lib/test.js:34:14)
      at Object.obj.<computed> [as get] (node_modules/supertest/index.js:43:18)
      at Object.get (app.test.js:8:40)
・・・以下略・・・

エラーになってしまいました。
確認すると、TypeError: app.address is not a functionは、モジュールインポート(require('./app'))がうまくいっていないため発生していました。

app.jsの末尾に

module.exports = app;

を追加した上でテストを再実行してみます。

~/playground/github-copilot $ npm test

> github-copilot@1.0.0 test
> jest

  console.log
    Example app listening on port 3000!

      at Server.log (app.js:55:32)

 FAIL  ./app.test.js
  GET /users
    ✓ should return all users (160 ms)
  GET /users/:id
    ✓ should return a user if valid id is passed (11 ms)
    ✓ should return 404 if invalid id is passed (5 ms)
  POST /users
    ✓ should return user if request is valid (47 ms)
    ✕ should return 400 if request is invalid (9 ms)
  PUT /users/:id
    ✓ should update the user if valid id is passed (6 ms)
    ✓ should return 404 if invalid id is passed (8 ms)
  DELETE /users/:id
    ✕ should delete the user if valid id is passed (11 ms)
    ✓ should return 404 if invalid id is passed (4 ms)

  ● POST /users › should return 400 if request is invalid

    expect(received).toEqual(expected) // deep equality

    Expected: 400
    Received: 200

      42 |             .post('/users')
      43 |             .send({});
    > 44 |         expect(res.statusCode).toEqual(400);
         |                                ^
      45 |     });
      46 | }
      47 | );

      at Object.toEqual (app.test.js:44:32)

  ● DELETE /users/:id › should delete the user if valid id is passed

    expect(received).toEqual(expected) // deep equality

    - Expected  - 1
    + Received  + 1

      Object {
        "id": 1,
    -   "name": "foo",
    +   "name": "bar",
      }

      70 |         const res = await request(app).delete('/users/1');
      71 |         expect(res.statusCode).toEqual(200);
    > 72 |         expect(res.body).toEqual({id: 1, name: 'foo'});
         |                          ^
      73 |     });
      74 |
      75 |     it('should return 404 if invalid id is passed', async () => {

      at Object.toEqual (app.test.js:72:26)

Test Suites: 1 failed, 1 total
Tests:       2 failed, 7 passed, 9 total
Snapshots:   0 total
Time:        1.838 s, estimated 2 s
Ran all test suites.
Jest did not exit one second after the test run has completed.

'This usually means that there are asynchronous operations that weren't stopped in your tests. Consider running Jest with `--detectOpenHandles` to troubleshoot this issue.

今度はいくつかのテストが成功するようになり、エンドポイントロジックのテストができる段階まで進みました。

Copilotの検証が主目的のためこれ以上のコード修正は行いませんが、改めてテストコードや失敗しているテストを見てみると、app.jsのAPI定義に合わせたテストを作成しつつも、「一般的にはここをテストするだろう(不正なペイロードをPOSTしたら400で返す)」という内容もサジェストしてくれていることがわかります。これにより、本来であればアプリコードで実装しておくべき内容(ペイロードのバリデーション)に気づくことができます。

今回はアプリコード自体もほぼCopilotのサジェストのみで書いていますが、人力で書いたコードでもこういったテストケースのサジェストによって実装の甘さに気付いたりできるケースもありそうです。

おわりに

Copilotを使ったテストコード作成を試してみました。サジェスト内容をそのまま使う、とまではいかないにしても、まずはベースをCopilotに作ってもらい、それから必要箇所修正、という開発の進め方は十分にアリだと思いました。
今回はサンプルのため簡易的なコードでしたが、より実務に近い(行数や複雑度が大きい)コードを扱う際に良いサジェストをさせるポイント等を今後はみていきたいと思います。


私達ACS事業部はAzure・AKSなどのクラウドネイティブ技術を活用した内製化のご支援をしております。ご相談等ありましたらぜひご連絡ください。

www.ap-com.co.jp

また、一緒に働いていただける仲間も募集中です!
今年もまだまだ組織規模拡大中なので、ご興味持っていただけましたらぜひお声がけください。

www.ap-com.co.jp

本記事の投稿者: 瀧澤 育海