Kuzunoha-NEのブログ

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

【Typescript】Expressで任意のステータスコードで配信する。

こんばんは葛の葉です。

今回はExpressで任意のhttpステータスコードを送信する機能を書きます。

github.com

import e from 'express';
import {Request, Response} from 'express';

const app = e();

app.get('/', (req:Request, res:Response)=>{
    // 200 status
    res.status(200).send("hoge");
});

app.get('/bad', (req:Request, res:Response)=>{
    // 400 status
    res.status(400).send("huga");
});


app.listen(9000, ()=>{
    console.log('Listen 9000')
})

expressのルーティングはレスポンスにstatusメソッドがついているのでそこに指定してあげれば大丈夫です。

http://127.0.0.1:9000/にアクセスすると200ステータスでhttp://127.0.0.1:9000/badにアクセスすると400ステータスでアクセスできます。

$ curl -v 127.0.0.1:9000

* Rebuilt URL to: 127.0.0.1:9000/
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 9000 (#0)
> GET / HTTP/1.1
> Host: 127.0.0.1:9000
> User-Agent: curl/7.58.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< X-Powered-By: Express
< Content-Type: text/html; charset=utf-8
< Content-Length: 4
< ETag: W/"4-MfMN28sb+ERldvDmSqTIip8FXjw"
< Date: Fri, 20 Dec 2019 12:11:54 GMT
< Connection: keep-alive
< 
* Connection #0 to host 127.0.0.1 left intact
hoge


$ curl -v 127.0.0.1:9000/bad

*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 9000 (#0)
> GET /bad HTTP/1.1
> Host: 127.0.0.1:9000
> User-Agent: curl/7.58.0
> Accept: */*
> 
< HTTP/1.1 400 Bad Request
< X-Powered-By: Express
< Content-Type: text/html; charset=utf-8
< Content-Length: 4
< ETag: W/"4-BlYONiHJ2LYKKyjnVKWhUyqIg/M"
< Date: Fri, 20 Dec 2019 12:12:54 GMT
< Connection: keep-alive
< 
* Connection #0 to host 127.0.0.1 left intact
huga

【GitHub】GitHubにActionっていうCIツールが追加されてたよ

こんばんは葛の葉です。

いつものアホ面でGithubを眺めてたら見慣れないのがあった。GitHubアクションというものらしい。そういえば邪魔くさいポップが出ていたっけ。ちょっと触ってみることにした。

f:id:Kuzunoha-NE:20191212213036j:plain

help.github.com

GitHub Actions では、エンドツーエンドの継続的インテグレーション (CI) と継続的デプロイメント (CD) 機能をリポジトリに直接ビルドすることができます。 GitHub Actionsは、GitHubに組み込まれている継続的インテグレーションサービスを駆動します。 詳細については、「継続的インテグレーションについて」を参照してください。

とまぁ、GitHubリポジトリへのPushやMargeをトリガーにDockerなどを実行できるワークフローというものを作成することで、CICDができるという代物みたい。CICDっていまの仕事で構築しているのだけれども、出来上がると本当に便利。今まではGitHub単体で出来なかったので、これからさらにGitHubの人気が上がるんじゃないのかな?

今回のGitHubリポジトリはこちら

github.com

.github/workflowsディレクトリ内を見る

中にはnodejs.ymlというymlファイルが入っている。このymlファイルがGitHubActionのワークフローを作るファイルになる。ワークフローはGitlab-CIでいうパイプラインと同じ単位だと思う。

gistedccfe666213183e055342991c6bb728

今回の作ったワークフローだけど、特殊な部分は以下の箇所かと思う。一瞬なにをやっているかわからなかったactions/checkout@v2はこのGithubActionで使用できる特殊なアクションで、GitHubが元々用意してくれている部分。この箇所はGitHubが用意してくれているNodejsのDockerImageを使用するように命令を記載しているところになる。

    steps:
    - uses: actions/checkout@v2
    - name: Use Node.js 12.x
      uses: actions/setup-node@v1
      with:
        node-version: 12.x

他にも、Job間でデータを共有できるArtifactや、Job内で別のコンテナを起動できるServicesという機能もついている。前者はビルドやコンパイルが成功したあとのデータを後続のJobにデ共用するときに、後者は結合テストを行うときに便利だと思う。

help.github.com

help.github.com

いろんなことを自動化させて、ちょっとした心配事を減らしていきましょう。

【MySQL】トランザクションを少し勉強しよう

こんばんは、葛の葉です。

今回はMySQLを使ってトランザクションを勉強しようと思います。

トランザクションとは

データベースはデータを保存しています。プログラマーやプログラムを使うユーザーはデータベースに対して、データを閲覧、追加、変更、削除を行いますが、そういったデータ操作についての処理を一つに纏めることをトランザクションと呼びます。トランザクションは最終的にその処理をデータベースに反映させる(コミットする)か、それらの処理を拒否する(ロールバックする)のどちらかを実施します。

トランザクションにおいては、纏まった処理を実施している最中に、他のユーザー(他のトランザクション)がデータベースにアクセスした際に、どういうデータの見え方をする必要があるかが問題となるケースがあります。

例えば、本屋さんのECサイトがあったとして、本の在庫数を管理するデータベースを持ってるとします。

ID 題名 在庫数
1 ソロモンの鍵 30
2 黒い雌鳥 19
3 ネクロノミコン 2

太郎さんがネクロノミコンを買い物かごに入れたときに、花子さんがこの表を確認したとしましょう。そのときにネクロノミコンの数は1であるか2であるか、ということになります。

使用する環境はこちら

DockerComposeを使用します。リポジトリを以下に置いておくので使ってください。

github.com

リポジトリには今回使用するテーブルを用意しました。testデータベース内にbooksテーブルを用意しています。

mysql> show tables;
+----------------+
| Tables_in_test |
+----------------+
| books          |
+----------------+
1 row in set (0.00 sec)

mysql> SELECT * FROM books;
+----+-----------------+--------+
| ID | Name            | Number |
+----+-----------------+--------+
|  1 | Soromon no Kagi |     30 |
|  2 | Kuroi Medori    |     19 |
|  3 | Nekuro Nomikon  |      2 |
+----+-----------------+--------+
3 rows in set (0.00 sec)

MySQLのクライアントを起動します。

$ git clone https://github.com/kuzunoha-ne/sqlLearn.git
$ cd sqlLeran
$ docker-compose up -d
$ docker-compose exec db mysql -p
Enter password: root

mysql>prompt one> 
PROMPT set to 'taro> '
taro> 

トランザクション分離の勉強なので2つのターミナルがほしいですね。

$ docker-compose exec db mysql -p
Enter password: root

mysql> prompt two> 
PROMPT set to 'hanako> '
hanako> 

こうしてtaro>というCLIhanako>というCLIの2つが出来たと思います。それぞれ太郎さんと花子さんというようにします。

デフォルトは1構文に対して1トランザクションである。

まずはdatabaseを選択します。

taro> USE test;
hanako> USE test;

そうしたらSQL文で色々みてみましょう。

taro> SELECT * FROM books WHERE ID=3;
+----+----------------+--------+
| ID | Name           | Number |
+----+----------------+--------+
|  3 | Nekuro Nomikon |      2 |
+----+----------------+--------+
1 row in set (0.00 sec)

hanako> SELECT * FROM books WHERE ID=3;
+----+----------------+--------+
| ID | Name           | Number |
+----+----------------+--------+
|  3 | Nekuro Nomikon |      2 |
+----+----------------+--------+
1 row in set (0.00 sec)

taro> INSERT INTO books VALUES(4, "hogehoge", 10);
hanako> INSERT INTO books VALUES(5, "piyopiyo", 8);

taro> SELECT * FROM books;
+----+-----------------+--------+
| ID | Name            | Number |
+----+-----------------+--------+
|  1 | Soromon no Kagi |     30 |
|  2 | Kuroi Medori    |     19 |
|  3 | Nekuro Nomikon  |      2 |
|  4 | hogehoge        |     10 |
|  5 | piyopiyo        |      8 |
+----+-----------------+--------+
5 rows in set (0.00 sec)

hanako> SELECT * FROM books;
+----+-----------------+--------+
| ID | Name            | Number |
+----+-----------------+--------+
|  1 | Soromon no Kagi |     30 |
|  2 | Kuroi Medori    |     19 |
|  3 | Nekuro Nomikon  |      2 |
|  4 | hogehoge        |     10 |
|  5 | piyopiyo        |      8 |
+----+-----------------+--------+
5 rows in set (0.00 sec)

MySQLにおいてはSQL文を実行するたびに自動でコミットが行われるオートコミットという機能がデフォルトでついています。この機能の関係で、taroとhanakoが明示的にコミットを実施しなくてもコミットは行われています。一方で自動でコミットを実施するので、「やっぱりやめた」とするロールバックをすることは出来ません。

taro> rollback;
Query OK, 0 rows affected (0.00 sec)

taro> SELECT * FROM books; # 結果が変わらない
+----+-----------------+--------+
| ID | Name            | Number |
+----+-----------------+--------+
|  1 | Soromon no Kagi |     30 |
|  2 | Kuroi Medori    |     19 |
|  3 | Nekuro Nomikon  |      2 |
|  4 | hogehoge        |     10 |
|  5 | piyopiyo        |      8 |
+----+-----------------+--------+
5 rows in set (0.00 sec)

トランザクションを明示的にしてみる。

taroがトランザクションをかけます。そして、ネクロノミコンを買おうとします。そのトランザクション中にhanakoさんがテーブルの中身を検索します。

taro> start transaction;
Query OK, 0 rows affected (0.00 sec)

taro> SELECT * FROM books WHERE ID=3;
+----+----------------+--------+
| ID | Name           | Number |
+----+----------------+--------+
|  3 | Nekuro Nomikon |      2 |
+----+----------------+--------+
1 row in set (0.00 sec)

taro> UPDATE books SET Number=Number-1 WHERE ID=3;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

taro> SELECT * FROM books WRERE ID=3;
+----+----------------+--------+
| ID | Name           | Number |
+----+----------------+--------+
|  3 | Nekuro Nomikon |      1 |
+----+----------------+--------+
1 row in set (0.00 sec)

hanako> SELECT * FROM books WHERE ID=3;
+----+----------------+--------+
| ID | Name           | Number |
+----+----------------+--------+
|  3 | Nekuro Nomikon |      2 |
+----+----------------+--------+
1 row in set (0.00 sec)

taro> commit;
Query OK, 0 rows affected (0.00 sec)

taro> SELECT * FROM books WHERE ID=3;
+----+----------------+--------+
| ID | Name           | Number |
+----+----------------+--------+
|  3 | Nekuro Nomikon |      1 |
+----+----------------+--------+
1 row in set (0.00 sec)

hanako> SELECT * FROM books WHERE ID=3;
+----+----------------+--------+
| ID | Name           | Number |
+----+----------------+--------+
|  3 | Nekuro Nomikon |      1 |
+----+----------------+--------+
1 row in set (0.00 sec)

taroさんがcommitする前にhanakoさんがSELECTするとネクロノミコンの数は2になってます。commit後はhanakoさんがSELECTすると1になっています。

taro> start transaction;
Query OK, 0 rows affected (0.00 sec)

taro> UPDATE books SET Number=Number-1 WHERE ID=2;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

taro> SELECT * FROM books WHERE ID=2;
+----+--------------+--------+
| ID | Name         | Number |
+----+--------------+--------+
|  2 | Kuroi Medori |     18 |
+----+--------------+--------+
1 row in set (0.00 sec)

taro> rollback;
Query OK, 0 rows affected (0.01 sec)

taro> SELECT * FROM books WHERE ID=2;
+----+--------------+--------+
| ID | Name         | Number |
+----+--------------+--------+
|  2 | Kuroi Medori |     19 |
+----+--------------+--------+
1 row in set (0.00 sec)

トランザクションロールバックすれば値は変更されません。

【雑記】OSSのIssueにバグ再現コードを提供しよう

こんばんは葛の葉です。

さて、とあるOSSを使っていたところ、ちょっと重いバグが出ていて、しかも開発者がそれを再現できないというものがありました。

そこで時間を掛けて調査したところ、そのバグを再現できる環境を作成することが出来ました。早速、開発者の人に知らせました。その際にとても役に立ったのがDockerとGithubでした。なぜなら、Dockerを使ってバグ発生用の環境構築し、Githubを使うことでコマンドなどの再現方法を記載して、ソースコードそのものを簡単に提供することができたからです。

Dockerで環境構築する。

Dockerについて詳しく説明はしませんが、DockerfileやDockerComposeをうまく使うことでバグを再現する環境を構築することが出来ます。今回はレプリケーション構成のときに発生するという環境に依存するバグでした。本来なら面倒なレプリケーションの構築もDockerfileとDockerComposeを使うことで簡単に構成することができます。インフラストラクチャアズコードの通り、後述のGithubで共有することもできるので非常に強力です。

www.docker.com

Githubでコードを共有する。

バグを再現するコードや上記のDocker等を共有するためのリポジトリを作りましょう。Descriptionにはバグのことが起票されているIssueなどのリンクを貼りましょう。そのリポジトリが何を意味しているのかがわかりやすくなります。バグを再現するための方法はreadme.mdに記載しておきましょう。出来れば内容は英語で書いてあげたら良いのでしょうが、英語がわからない場合は機械翻訳に掛けたりするのでも良いでしょう。コマンドやソースコードの書き方はITエンジニアの慣行に習って記載します。

Githubに共有するとIssueに情報を書きこんでくれる人がいます。見逃さないようにしましょう。(私は気が付かないで1日ほったらかしにしてた👀)

github.com

GithubのIssueとイシュートラッカーに書く

GithubのIssueに書き込みましょう。今回作成したリポジトリもリンクしておきます。内容については英語で書くほうがいいみたいです。機械翻訳でも良いのでそれらしいことを書いておきましょう。書かないでリンクだけ貼っても見てもらえないと思います。

そのOSSがイシュートラッカーを使っている可能性もあります。今回のOSSで使用されていたイシュートラッカーはJiraでした。問題の要素となるキーワードを検索にかければ、同様のIssueが立ち上がっていると思うのでそこにもコメントしてあげましょう。

https://www.atlassian.com/ja/software/jira

何か書き込めばレスポンスをもらえる可能性もあります。レスポンスをくれる人がコントリビューターかもしれないですが、そうじゃないかもしれないです。「誰この人」って人からレスをもらうことがありますが、キチンと返信しましょう。

まとめ

ソフトウェアである以上、バグはつきものだと思います。普段使わせてもらっているOSSにバグが出たなら、それを直すお手伝いをしてあげましょう。「つよつよエンジニア」っぽいことしてる感があって気持ち良いですよ。

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

【TypeScript】ifとimport

こんばんは、葛の葉です。

さて、TypeScriptで条件によってモジュールをインポートする、あるいはインポートしないようにしたい場合があるかと思います。

if(true){
    import {hoge, moge} from "./ex";
}

// error TS1232:  An import declaration can only be used in a namespace or module.

An import declaration can only be used in a namespace or module.とエラーを出されます。VSCodeでもエラーが出力されます。

f:id:Kuzunoha-NE:20191114203155j:plain
An import declaration can only be used in a namespace or module.

書いてあるとおり、名前空間かモジュール内じゃないとimport出来ないようです。

動的なimport

TypeScriptにおいては、プログラムを実行しつつモジュールをimportするにはimport()として関数を使用します。

developer.mozilla.org

import関数はPromiseが返却されますので、以下のように使うことが出来ます。なお、ex.tsは以下のようになっています。

// ex.ts
export const hoge:string = 'hogehoge';
export const moge:string = 'mogemoge';
// 呼び出すtypescript
if(true){
    import("./ex").then((value)=>{
        console.log(value);
    });
}
// { hoge: 'hogehoge', moge: 'mogemoge' }

もちろんasync await も使用可能です。

(async () => {
    if(true){
        const hogehoge = await import("./ex");
        console.log(hogehoge);
    }
})();
// { hoge: 'hogehoge', moge: 'mogemoge' }

が、残念ながらスコープ的にifの外から出ることはできません。

(async () => {
    if(true){
        const hogehoge = await import("./ex");
        console.log(hogehoge);
    }
    console.log(hogehoge);
})();
// error TS2304: Cannot find name 'hogehoge'.
// 
// 6     console.log(hogehoge);

webpackなどでインポートするだけなら以下のように記述することも出来ます。VSCode上では赤波線が出てきて警告を受けますが、問題なく使えます。

(async () => {
    if(true){
        await import("./ex.css");
    }
})();

【JavaScript】非同期処理とTryCatch

こんにちは、葛の葉です。

JavaScriptにおける非同期処理とtry catch文についてみてみましょう。一般的に、try文内で発生したErrorはcatch文に移動します。

try {
    throw new Error('Test Error');
    console.log('hogehoge');
} catch (error) {
    console.log('piyopiyo');
}
// piyopiyo

そのエラーを検知したとき、catch文にジャンプします。ご多分の通り、throwを使うことでエラーを発生することが出来ます。すなわち、try文内において、throw文以下の命令は絶対に呼び出されることはありません。VSCodeにおいても3行目であるconsole.log('hogehoge')は到達できないと表示されます。

f:id:Kuzunoha-NE:20191107224230j:plain
到達できないコードの検出

では以下のような非同期処理においてはどうでしょうか。

try{
    setTimeout(() => {
        throw new Error('hoge');
    }, 5 * 100);
} catch (e) {
    console.log('piyopiyo');
}
/*
 /home/~~~/a.js:3
        throw new Error('hoge');
        ^

Error: hoge
*/

このように、非同期処理中に発生したエラーについてはcatchできません。

そもそもJavaScriptにはコールスタックが働いて、エラー発生時において、関数の呼び出した元の関数まで遡っていくことができます。その遡り内にtry catchがあれば、そこでエラーを検知することが出来ます。非同期処理内において発生したエラーにおいても同様にコールスタックにてエラーが遡ることになるのですが、Javascript特有のイベントループにて、非同期処理を実行する環境は各イベントのフェーズがGlobalとなるため、普段書いている同期処理内のtrycatchに引っかかることがありません。

下記のようにasyncとawaitをPromiseと組み合わせることで同期処理内にてcatchすることが出来ます。

const promise = new Promise((resolve, reject)=>{
    const hoge = 'hogehoge';
    try{
        throw new Error('ERROR DESU');
        resolve(hoge);
    }catch(e){
        reject(e);
    }
});

(async() =>{
try{
    await promise.then();
} catch (e) {
    console.log('piyopiyo');
}
})();
// piyopiyo

イマイチこのあたりが完全に理解しきれていないので、今後きちんと勉強したいです。