Kuzunoha-NEのブログ

プログラミングなどの勉強をしてます

【Typescript】PuppeteerとJestでE2Eテストを作る

こんばんは葛の葉です。

最近はPuppeteerとテストコードを使用しE2Eテストを書いています。今回はそのことについて記載してきたいと思います。

今回のソースコードはこちら

github.com

E2Eテストとは

E2EテストとはEnd to End Testのことで、WebブラウザなどのUIを介してユーザーが実際に操作するような動きをテスト項目とし、それを自動で評価させるような仕組みを指します。例えばログイン画面などで認証情報を正しく入力した場合の画面遷移がきちんと成功しているか否か、あるいは失敗した場合は認証が失敗している旨のメッセージが表示されているか、といった部分をテストします。

E2Eテストの良いところは既存の機能を保証してくれるところになります。新しい機能を追加すると、その機能が影響して既存の機能が動かなくなることがあります。また、ライブラリや実行環境が変更、更新となった場合も同様に既存の機能が動かなくなることがあります。そういったことが懸念されるタイミングでE2Eテストを実施することで、本番環境に影響を及ぼす前に問題箇所をキャッチすることが出来、修正することが可能となります。

Puppeteerについて

Puppeteerとは、Chroniumをコードで動かすことが出来るNPMライブラリです。すなわち、WebブラウザをNode.Jsコードで動かすことができるので、このPuppeteerを元にWebスクレイピングを行ったりE2Eテストを作成することが出来ます。Puppeteerの強みは実際にWebブラウザを使った画面遷移が行えるので、httpリクエストでは出来ないSinglePageApplicationのスクレイピングやE2Eテストを実施できることになります。

npm i puppeteer

github.com

https://pptr.dev/

Puppeterがどのようなものであるかは、実際に動いているものを見てもらえればわかりやすいと思います。以下のgifは、PythonのFlaskで作成されたWebアプリケーションがhttp://127.0.0.1:8080のホストで動作しており、そのWebアプリに対してPuppeteerがアクセスし、Htmlファイルに対してクリックや書き込みを行っています。

f:id:Kuzunoha-NE:20191117204125g:plain
puppeteerで動作しているところ
ソースコードは以下になります。

from flask import Flask, render_template

app = Flask(__name__)

@app.route('/')
def index ():
    return render_template('index.html')
<!-- index.html -->
<!DOCTYPE html>
<html lang="ja">
    <header>
        <title>タイトルテスト</title>
    </header>
    <body>
        <div class="hogehoge" id="piyo">
            <p>ハローワールド</p>
        </div>
        <div>
            <p><input type="text" id="input"></p>
        </div>
        <div>
            <select id="select">
                <option value="メガドライブ">メガドライブ</option>
                <option value="セガサターン">セガサターン</option>
                <option value="テラドライブ">テラドライブ</option>
                <option value="ネオジオ">ネオジオ</option>
            </select>
        </div>
        <div>
            <a id="link" href="https://google.com">Google</a>へジャンプします。
        </div>
        <div>
            こんにちは<button class="helloButton" id="hello" onclick="ale()">button</button>さん
        </div>
        <script>
            function ale () {alert('hogehoge');}  
        </script>
    </body>
</html>
// ppp.ts
import puppeteer from 'puppeteer';
(async () => {
    const browser = await puppeteer.launch({
        headless: false,
        slowMo: 70,
        args: [
            '--window-size=568,354',
          ]
    });
    const page = await browser.newPage();
    await page.goto('http://127.0.0.1:8080')
    page.on('dialog', async dialog => {
        console.log(dialog.message())
        await dialog.dismiss()
       });
    // id helloの要素をクリックする。
    await page.click('#hello'); 
    // id input の要素に これが急にびっくりなのだ と記入する。
    await page.type('#input', 'これが急にびっくりなのだ');
    // id selectである select要素のvalueを テラドライブにする。以下省略。 
    await page.select('#select', 'テラドライブ'); 
    await page.type('#input', 'アーマードコア');
    await page.select('#select', 'セガサターン');
    await page.focus('#link');
    await page.click('#link');
    await page.waitForNavigation();
    await browser.close();
})();

上記はイメージがつきやすいようにChroniumを表示させましたが、Headlessで実行すればCLIで実行することが出来ます。当然ながらHeadlessのほうが実行速度は早いです。Headlessで実行するにはpuppeteer.launch()の中のオブジェクトを空で渡すようにします。

- const browser = await puppeteer.launch({
-     headless: false,
-     slowMo: 70,
-     args: [
-         '--window-size=568,354',
-         ]
- });

+ const browser = await puppeteer.launch({});

f:id:Kuzunoha-NE:20191117205437g:plain
CUIでPuppeteerを動かす

上記のgifは右側のターミナルがFlaskを実行しており、左側のターミナルがPuppeteerを実行しています。Puppeteerを実行後、hogehogeという標準出力が表示されていますが、これはHtmlで表示されたアラートの文字を取得してその文字をconsole.logで表示しているからです。Flaskの画面にて、GETのリクエストのログが表示されているのはPuppeteerがアクセスしたログになります。このように、Puppeteerはコード上でWebブラウザを動かすことができるのです。

Jestについて

Jestとはテストコードを実行することができるNPMライブラリになります。テスト用のライブラリは多種あり、今の所、どのようなものが適切であるかはわかりかねるのですが、今回は日本語検索がヒットしやすいことと書き方が完結でわかりやすいのでJestをチョイスしました。

jestjs.io

npm i jest

下記がテストコードになります。

// test.ts
test('1 + 2 は 3 になります。',()=>{
    expect(1 + 2).toBe(3);
})

''1 + 2 は 3 になります。'と書かれている箇所は、そのコールバック関数内で実施するテストの内容を完結に記述します。expect(1 + 2).toBe(3)はexpect関数内の計算結果がtoBeメソッド内と同様ならテストが通過するということになります。1+2は3なので、今回はテストが通過します。test.tsという名前にしておけばjestはtsファイルからコンパイルしなくても実行してくれます。jestコマンドを実行します。すると以下のように出力されます。

$ npx jest
 PASS  src/test.ts
  ✓ 1 + 2 は 3 になります。 (5ms)

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

次はあえてテストが通過しないように記述してみます。

test('1 + 2 は 3 になります。',()=>{
    expect(4 + 2).toBe(3);
})

当然ながら4 + 2は3ではなく6なのでテストは通過しません。実行してみると以下のような結果になります。

$ npx jest
 FAIL  src/test.ts
  ✕ 1 + 2 は 3 になります。 (9ms)

  ● 1 + 2 は 3 になります。

    expect(received).toBe(expected) // Object.is equality

    Expected: 3
    Received: 6

      1 | test('1 + 2 は 3 になります。',()=>{
    > 2 |     expect(4 + 2).toBe(3);
        |                   ^
      3 | })

      at Object.<anonymous>.test (src/test.ts:2:19)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        1.439s
Ran all test suites.

このように間違っているというエラーがThrowされ、異常終了としてプログラムが終了します。すなわち、正常終了か異常終了かをトリガーにして次の処理を条件分岐させることができるわけです。Linuxなら&&||が使えるというわけです。

また、文字列に対してもテストを実行をすることが出来ます。

test('hogehogeはhogehogeです。',()=>{
    expect('hogehoge').toBe('hogehoge');
})
$ npx jest
 PASS  src/test.ts
  ✓ hogehogeはhogehogeです。 (5ms)

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

PuppeteerとJestを組み合わせる

先に作成したppp.tsを元にPuppeteerとJestを組み合わせたものを作成します。最後に書いてあるコードをppp.test.tsとして保存し、コンパイルします。jest***.test.js(.ts)のファイルか__test__ディレクトリまたはspecディレクトリに入っているコードをすべて実行してくれます。コンパイル後のppp.test.jsjestで実行してみましょう。

f:id:Kuzunoha-NE:20191122203701g:plain
PuppeteerとJestが動作しているところ

最後に表示された結果が以下になります。

$ npx jest dist/ppp.test.js 
 PASS  dist/ppp.test.js
  ✓ ボタンを押したらhogehogeと表示する。 (374ms)
  ✓ Googleジャンプしますを押すとGoogleへリンクする (649ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        3.295s
Ran all test suites matching /dist\/ppp.test.js/i.

2つの項目がPASSしており、テストは合格しています。失敗しているパターンは以下のようになります。

$ npx jest dist/ppp.test.js 
 FAIL  dist/ppp.test.js
  ✓ ボタンを押したらhogehogeと表示する。 (368ms)
  ✕ Googleジャンプしますを押すとGoogleへリンクする (593ms)

  ● Googleジャンプしますを押すとGoogleへリンクする

    expect(received).toBe(expected) // Object.is equality

    Expected: "https://.google.com/"
    Received: "https://www.google.com/"

      38 |     await page.waitFor('#link');
      39 |     page.on('response', response => {
    > 40 |         expect(response.url()).toBe('https://.google.com/');
         |                                ^
      41 |     });
      42 |     await page.click('#link');
      43 | });

      at Page.page.on.response (dist/ppp.test.js:40:32)
      at NetworkManager.Page.networkManager.on.event (node_modules/puppeteer/lib/Page.js:112:69)
      at NetworkManager._onResponseReceived (node_modules/puppeteer/lib/NetworkManager.js:272:10)
      at CDPSession._onMessage (node_modules/puppeteer/lib/Connection.js:200:12)
      at Connection._onMessage (node_modules/puppeteer/lib/Connection.js:112:17)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        3.233s
Ran all test suites matching /dist\/ppp.test.js/i.

前述したとおり、テストが失敗したときは異常終了となります。Expectedとは正とする値が記述されており、Receivedは実際に取得された値となります。この2つが等しければテストは通りますが、今回は私が予想値を適当なものにしたのでテストが失敗しています。

    Expected: "https://.google.com/"
    Received: "https://www.google.com/"

以下が上記のgifの元コードになります。これをコンパイルして出力されたppp.test.jsをjestで実行すると動作します。なお、flaskが動いていることが前提です。

// ppp.test.ts
import puppeteer from 'puppeteer';

let browser:puppeteer.Browser;
let page:puppeteer.Page;

// テストを実施する前処理
beforeAll(async ()=>{
    browser = await puppeteer.launch({
        headless:false,
        slowMo:18,
    });
    page = await browser.newPage();
});

// 一つのテスト項目実施する直前に都度実施する処理
beforeEach(async ()=>{
    await page.goto('http://127.0.0.1:8080');
});

// 一つのテスト項目実施した直後に都度実施する処理
afterEach(async () => {
});

// テストすべて終了後の処理
afterAll(async () => {
    await page.close();
    await browser.close();
});

test('ボタンを押したらhogehogeと表示する。', async()=>{
    await page.waitFor('#hello');
    page.on('dialog', async dialog => {
        await dialog.dismiss()
        expect(dialog.message()).toBe('hogehoge');
    });
    await page.click('#hello');
});


test('Googleジャンプしますを押すとGoogleへリンクする', async()=>{
    await page.waitFor('#link');
    page.on('response', response => {
        expect(response.url()).toBe('https://www.google.com/');
    });
    await page.click('#link');
});

beforeAll afterAll beforeEach afterEach という記述がありますが、これもJestの記述になります。

beforeAll // テストを実施する前処理
afterAll // テストすべて終了後の処理
beforeEach // 一つのテスト項目実施する直前に都度実施する処理
afterEach // 一つのテスト項目実施した直後に都度実施する処理

まとめ

PuppeteerとJestでE2Eテストを作ることが出来ました。自動でモノが動くのをみると感動を覚えます。E2Eテストはシステムの堅牢さがあがるので今後もしっかりと導入していきたいです。

しかし、大変なところもあります。何を持って正しいとするかが難しいところです。例えば最後のURLについての正誤も、リダイレクトがかかるようになった場合はどうするか等、こちらに起因しないところで悩ましい事象も発生します。また、ちょっとしたHTMLの変更でPuppeteerが動かなくなる可能性があるので、総合的にテストが簡単に落ちやすくなるようになります。既存機能は問題なく動作しているが、Pueppeteerの視点からだとテストが実行できないので失敗となることが多いのです。結局のところ、こういった品質管理に関しては細心の注意を払っていかなければならないのだなぁと思うところでした。

一方で、PuppeteerとJestはCI/CDツールと組み合わせることができるので、多種の自動化の中でE2Eテストも盛り込めます。コードをmasterにマージした際にE2Eテストが通ると嬉しいですね。

というわけで、今回はE2Eテストについて長々と書いてみました。結構ざっくりと書いたので足りない部分もあると思いますが、その際はググってみたり調べてみたりしてみてください。私が今回この記事を書きたかったのは自動でブラウザ動くのってすげーって感じたところだけです。あとはもう完全におまけで書いてました。以上です。