Kuzunoha-NEのブログ

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

【TypeScript】CookieとSessionとSessionIDを使ったログイン情報の保持

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

今回はCookieとSessionとSessionIDを使ったログイン情報の保持を実施します。

前述

普段我々が使用しているWebページでは、ログインを必要とするページが多いかと思います。ログインに一回成功するとその成功したログイン状態というステータスになり、ちょっとした時間は改めてログインをする必要がありません。しかし、Httpはステートレスであり、状態を保持するということが出来ません。そのため、どこかにその情報を保存しておくのですが、今回はそのうち、SessionIDを使った方法を記載してみます。

今回のソースコード

github.com

Cookieとは

WebサーバーがWebブラウザにデータを送り、ブラウザがその値を保持します。それをCookieといいます。CookieWebブラウザの機能の一部であり、Cookieを使えないWebブラウザもあることでしょう(現行のWebブラウザはほとんど持っている)。CookieはNameとValueからなります。複数のNameを持つことが出来ます。また、Cookieには様々な設定を施すことができ、例えば保持する時間や、そのCookieを参照できるドメインの指定、Https通信の時のみ保持させる、と言ったものがあります。

Sessionとは

SessionとはWebサーバーがデータベースなどをもちいて、クライアントとサーバー間で保持しておくべきデータを一時的の保管するならわしをいいます。つまり、WebサーバーやWebブラウザにSessionという機能があるのではありません。Webサーバーがプログラムやミドルウェアを通じてデータベースにデータを保持するという点がCookieとの最大の違いになります。また、Sessionは基本的にログイン状態かどうかというステータスを保持するために使用され、長時間は保存されません。

SessionIDとは

SessionIDとはSessionで保存されたデータを取り出すためのKeyとなるIDのことであり、これはCookieとして保存されます。Cookieに保存されたSessionIDはWebブラウザに保存され、都度、Webサーバーから要求を受けるたびに送信します。WebサーバーはそのSessionIDがデータベース等に存在するかどうかを都度確認をし、存在すればログインは正常であるとして、ログイン状態である旨をクライアントに示します。SessionIDが漏洩したり、あるいは推測されてしまうような単純な値であると、悪意を持った人が、一般ユーザーのログイン状態であるステータスを盗むことが出来、それを使って悪事を働くことが出来てしまうため、留意する必要があります。

WebサーバーからCookieを保持させる。

今回もTypeScriptのWebサーバーhttpを使ってCookieを保持させてみます。

nodejs.org

import http from 'http';
const server = http.createServer();
server.on('request', (req:http.IncomingMessage, res:http.ServerResponse) => {
    res.writeHead(200, {'Set-Cookie': 'myCookie=hogehoge;'});
    res.end('200 OK\n');
    return;
});
server.listen(9000, ()=>{
    console.log('listen http://127.0.0.1:9000\n');
});

4行目res.writeHead(200, {'Content-Type':'text/plain', 'Set-Cookie': 'myCookie=hogehoge;'});こちらがHttpResponseとして返却するHeaderを記載している部分になります。このヘッダーを受け取ったWebブラウザはその情報をCookieに保存します。検証モードがついているWebブラウザならすぐに確認できます。Set-Cookieというレスポンスを受けたブラウザは、その値に書かれた<Name>=<Value>としてブラウザに保存します。先に、Cookieを保存する期限を設けることが出来ると記載しましたが、そのオプション指定が特別存在しない場合はブラウザが閉じられるまでの間となります。

f:id:Kuzunoha-NE:20200104121737p:plain
myCookieというNameとhogehogeという値を持つCookie

SessionIDの発行とCookieに保存。

Cookieの保存方法は先の記載のとおりなので、今回はSessionIDを保存させます。SessionIDはログイン状態を保持する重要なものなので、推測されないような値が正しいです。が今回は適当に作ります。

server.on('request', (req:http.IncomingMessage, res:http.ServerResponse) => {
    const sid = createSessionId();
    res.writeHead(200, {'Set-Cookie': `sessionId=${sid};`});
    res.end('200 OK\n');
    return;
});

const createSessionId:() => string = () => {
    const newSessionId =`hogehoge_${new Date().toISOString()}`;
    nodeCache.set(newSessionId, "", 60);
    return newSessionId;
}

今回はNodeCacheというライブラリを使います。KVSのようなもののようです。KeyにSessionIDを渡しておき、中身は空の値にしておきます。

10行目nodeCache.set(newSessionId, "", 60);set関数は以下になってます。

## 第一引数がkey
## 第二引数がvalue
## 第三引数がオプションで保存秒数

www.npmjs.com

SessionIDはNodeCache内とCookie内に保存されました。これをログインが成功したタイミングで渡すようにしてあげます。また、ログインが必要なページにはCookieからSessionIDを取得し、NodeCache内に検索をかけます。SessionIDがNodeCache内に存在していなければログイン状態が保持されていないことになるので、ログインを求めるような表示をします。

import http from 'http';
import NodeCache from 'node-cache';

const server = http.createServer();
const nodeCache = new NodeCache();

server.on('request', (req:http.IncomingMessage, res:http.ServerResponse) => {
    switch (req.url){
        case '/':
            const cookies = cookieParser(req.headers.cookie);
            const sessionId = cookies.sessionId;
            if (nodeCache.has(sessionId)){
                res.writeHead(200);
                res.end('200 Loggined\n');
            } else {
                res.writeHead(200);
                res.end('200 Not Loggined\n');
            }
        break;
        case '/login':
            const sid = createSessionId();
            res.writeHead(200, {'Set-Cookie': `sessionId=${sid};`});
            res.end('200 Cookie Set\n');
        break;
        default:
            res.writeHead(404);
            res.end('404 Not Found\n');
        break;
    }
    return;
});

interface Cookies {
    sessionId:string
}

const cookieParser:(str:string|undefined) => Cookies = (str)=> {
    if(!str) return {sessionId:''};
    const cookies = {sessionId:''};
    str.split(';').forEach((cookie)=>{
        let parts = cookie.split('=');
        let key = parts[0];
        let value = parts[1];
        let part = {[key]: value}
        Object.assign(cookies, part);
    });
    return cookies;
}

const createSessionId:() => string = () => {
    const newSessionId =`hogehoge_${new Date().toISOString()}`;
    nodeCache.set(newSessionId, "", 60);
    return newSessionId;
}

server.listen(9000, ()=>{
    console.log('listen http://127.0.0.1:9000\n');
});

http://127.0.0.1:9000にアクセスすると200 Not Logginedと表示されます。http://127.0.0.1:9000/loginにアクセスするとCookieにSessionIDがセットされます。再びhttp://127.0.0.1:9000にアクセスすると200 Logginedと表示されます。60秒放置した状態で3度、http://127.0.0.1:9000にアクセスすると200 Not Logginedとなります。

f:id:Kuzunoha-NE:20200110214156g:plain
擬似的にログインを再現する

まず、http://127.0.0.1:9000にアクセスするとCookie内のSessionIDを確認します。SessionIDがない場合やSessionIDがNodeCacheに存在しない場合は200 Not Logginedと表示しています。SessionIDが存在する場合は200 Logginedと表示されます。http://127.0.0.1:9000/loginにアクセスするとCookieとNodeCacheにSessionIDを設定します。

あとはユーザー情報とパスワード情報をデータベースから引っ張ってきて、正しい情報ならSessionIDをセットするようにして、そうでなければ認証に失敗した旨を返せば良いです。

ちょっとやってみておもしろかった

今回作成したものは結構単純なものだけど、かなり大変だった。こういったものはライブラリに任せるのが一番楽だと思う。一方で、中身がよくわかってない状態でライブラリを使うのは良くないと思ってた。本当はもう少し複雑なんだと思うけど、まずはこんな感じで終わりにしようと思う。

【TypeScript】TypeScriptでBasic認証をやってみる

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

今回はBasic認証をやってみます。

Basic認証とは

Basic認証とはHttpに搭載されている認証方法の一つになります。Httpヘッダーに認証のためのユーザー情報とパスワード情報をBase64にて暗号化して送付します。その値をサーバーが受け取って認証をすることが出来ます。

サーバーは対象となるページのヘッダーにWWW-Authenticateをつけることで、Basic認証にすることができます。クライアントは該当するページにアクセスした時、サーバーは401のステータスコードで返します。認証が成功すれば成功したページをステータスコード200で返します。401のステータスを受け取った時、クライアントは認証用の入力フォームを出力し、ユーザー名とパスワードをユーザーに入力させます。入力フォームを出力する機能はブラウザの機能です(ブラウザによっては出来ないものもあるということになります。)。その値はユーザー名:パスワードという一つの文字列になり、Base64にて暗号化され、HttpRequestのヘッダーAuthorizationの値としてサーバーに送信します。サーバーは受け取ったその値を使って認証を行い、成功不成功にあわせて返答します。このAuthorizationはブラウザを閉じるまで継続します。

f:id:Kuzunoha-NE:20200103201805g:plain

ソースコード

事前に必要なものは以下のコマンドになります。

$ node -v
v12.13.1
$ npm init -y
$ npm i -D typescript @types/node
$ npx tsc --init

httpでBasicAuth

今回はあえてexpressを使わずにnodeの標準でついているhttpモジュールでHttpサーバーを建ててみます。

nodejs.org

4行目const server = http.createServer();はserver変数にServerクラスのインスタンスを作成します。

6行目server.on('request', callback)はHttpRequestを受け取った際にcallbackを起動します。callbackの関数には(req:IncomingMessage, res:ServerResponse)の2つの引数を渡します。IncomingMessageはHttpRequestで、ServerResponseはサーバーとして返すHttpResponseになります。

7行目if(req.headers.authorization)はHttpRequestのヘッダーAuthorizationが存在するかしないかの条件分岐になります。ヘッダーが存在しない場合はundefinedとなるため、この条件は満たされません。条件が満たされた以降はreq.headers.authorizationを加工しているだけになります。

$ curl 127.0.0.1:9000 
401 not authenticated
$ curl -H 'Authorization:Basic a3V6dW5vaGE6aG9nZWhvZ2U=' http://127.0.0.1:9000
success

このように認証を行うことが出来ます。一方で、認証情報をBase64で暗号化するだけなので、簡単に暗号を解くことが出来ます。そのため、ほぼ平文での送信となり、盗聴された場合の安全性は足りないと言われています。そのため、Httpsとの併用が望ましいとことです。

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