Raspbianの初期起動でのrootfsパーティション自動拡張の仕組みを調べた
環境
試したRaspbianのイメージは2018-11-13-raspbian-stretch-lite.img
。母艦はLinux PCで調べた。
きっかけ
Raspbian Jessieのいつ頃からか忘れたけど、イメージをSDカードに焼いてRaspberry PIに入れて起動すると、 自動でrootfsのパーティションがSDカードの容量全体まで拡張されるようになった。
RaspbianのOSイメージをSDカードに焼いた直後のパーティションはfdiskで確認すると、次のようになっている。
$ sudo fdisk -l /dev/mmcblk0 Disk /dev/mmcblk0: 3.7 GiB, 3965190144 bytes, 7744512 sectors Units: sectors of 1 * 512 = 512 bytes Sector size (logical/physical): 512 bytes / 512 bytes I/O size (minimum/optimal): 512 bytes / 512 bytes Disklabel type: dos Disk identifier: 0x7ee80803 デバイス 起動 Start 最後から セクタ Size Id タイプ /dev/mmcblk0p1 8192 98045 89854 43.9M c W95 FAT32 (LBA) /dev/mmcblk0p2 98304 3645439 3547136 1.7G 83 Linux
これをRaspberry PIに入れて起動すると、自動拡張のメッセージが表示されて、再起動後に次のようになる。
$ sudo fdisk -l /dev/mmcblk0 Disk /dev/mmcblk0: 3.7 GiB, 3965190144 bytes, 7744512 sectors Units: sectors of 1 * 512 = 512 bytes Sector size (logical/physical): 512 bytes / 512 bytes I/O size (minimum/optimal): 512 bytes / 512 bytes Disklabel type: dos Disk identifier: 0x7273b820 デバイス 起動 Start 最後から セクタ Size Id タイプ /dev/mmcblk0p1 8192 98045 89854 43.9M c W95 FAT32 (LBA) /dev/mmcblk0p2 98304 7744511 7646208 3.7G 83 Linux
/dev/mmcblk0p2
の容量が1.7Gから3.7Gに広がっているのがわかる。
これはこれで便利なのだけれど、rootfsとは別のパーティション作ったり色々やりたいことがあるので、 そもそもこれはどうやって実現しているのだろうと思って調べてみた。
/boot/cmdline.txt
を調べる
ブート時になにかやっているのだから/boot/cmdline.txt
をとりあえず見てみようと思い、
イメージをSDカードに焼いてLinux PCからマウントしてファイルを覗いてみた。
/boot/cmdline.txt
の中身
dwc_otg.lpm_enable=0 console=serial0,115200 console=tty1 root=PARTUUID=7ee80803-02 rootfstype=ext4 elevator=deadline fsck.repair=yes rootwait quiet init=/usr/lib/raspi-config/init_resize.sh
末尾に、なんかそのものズバリになりそうなinit=/usr/lib/raspi-config/init_resize.sh
を発見。
/usr/lib/raspi-config/init_resize.sh
を調べる
今度はrootfsをマウントして/usr/lib/raspi-config/init_resize.sh
の中身を覗いてみた。
init_resize.shの中身は次の通り。
#!/bin/sh reboot_pi () { umount /boot mount / -o remount,ro sync if [ "$NOOBS" = "1" ]; then if [ "$NEW_KERNEL" = "1" ]; then reboot -f "$BOOT_PART_NUM" else echo "$BOOT_PART_NUM" > "/sys/module/${BCM_MODULE}/parameters/reboot_part" fi fi echo b > /proc/sysrq-trigger sleep 5 exit 0 } check_commands () { if ! command -v whiptail > /dev/null; then echo "whiptail not found" sleep 5 return 1 fi for COMMAND in grep cut sed parted fdisk findmnt partprobe; do if ! command -v $COMMAND > /dev/null; then FAIL_REASON="$COMMAND not found" return 1 fi done return 0 } check_noobs () { if [ "$BOOT_PART_NUM" = "1" ]; then NOOBS=0 else NOOBS=1 fi } get_variables () { ROOT_PART_DEV=$(findmnt / -o source -n) ROOT_PART_NAME=$(echo "$ROOT_PART_DEV" | cut -d "/" -f 3) ROOT_DEV_NAME=$(echo /sys/block/*/"${ROOT_PART_NAME}" | cut -d "/" -f 4) ROOT_DEV="/dev/${ROOT_DEV_NAME}" ROOT_PART_NUM=$(cat "/sys/block/${ROOT_DEV_NAME}/${ROOT_PART_NAME}/partition") BOOT_PART_DEV=$(findmnt /boot -o source -n) BOOT_PART_NAME=$(echo "$BOOT_PART_DEV" | cut -d "/" -f 3) BOOT_DEV_NAME=$(echo /sys/block/*/"${BOOT_PART_NAME}" | cut -d "/" -f 4) BOOT_PART_NUM=$(cat "/sys/block/${BOOT_DEV_NAME}/${BOOT_PART_NAME}/partition") OLD_DISKID=$(fdisk -l "$ROOT_DEV" | sed -n 's/Disk identifier: 0x\([^ ]*\)/\1/p') check_noobs ROOT_DEV_SIZE=$(cat "/sys/block/${ROOT_DEV_NAME}/size") TARGET_END=$((ROOT_DEV_SIZE - 1)) PARTITION_TABLE=$(parted -m "$ROOT_DEV" unit s print | tr -d 's') LAST_PART_NUM=$(echo "$PARTITION_TABLE" | tail -n 1 | cut -d ":" -f 1) ROOT_PART_LINE=$(echo "$PARTITION_TABLE" | grep -e "^${ROOT_PART_NUM}:") ROOT_PART_START=$(echo "$ROOT_PART_LINE" | cut -d ":" -f 2) ROOT_PART_END=$(echo "$ROOT_PART_LINE" | cut -d ":" -f 3) if [ "$NOOBS" = "1" ]; then EXT_PART_LINE=$(echo "$PARTITION_TABLE" | grep ":::;" | head -n 1) EXT_PART_NUM=$(echo "$EXT_PART_LINE" | cut -d ":" -f 1) EXT_PART_START=$(echo "$EXT_PART_LINE" | cut -d ":" -f 2) EXT_PART_END=$(echo "$EXT_PART_LINE" | cut -d ":" -f 3) fi } fix_partuuid() { DISKID="$(fdisk -l "$ROOT_DEV" | sed -n 's/Disk identifier: 0x\([^ ]*\)/\1/p')" sed -i "s/${OLD_DISKID}/${DISKID}/g" /etc/fstab sed -i "s/${OLD_DISKID}/${DISKID}/" /boot/cmdline.txt } check_variables () { if [ "$NOOBS" = "1" ]; then if [ "$EXT_PART_NUM" -gt 4 ] || \ [ "$EXT_PART_START" -gt "$ROOT_PART_START" ] || \ [ "$EXT_PART_END" -lt "$ROOT_PART_END" ]; then FAIL_REASON="Unsupported extended partition" return 1 fi fi if [ "$BOOT_DEV_NAME" != "$ROOT_DEV_NAME" ]; then FAIL_REASON="Boot and root partitions are on different devices" return 1 fi if [ "$ROOT_PART_NUM" -ne "$LAST_PART_NUM" ]; then FAIL_REASON="Root partition should be last partition" return 1 fi if [ "$ROOT_PART_END" -gt "$TARGET_END" ]; then FAIL_REASON="Root partition runs past the end of device" return 1 fi if [ ! -b "$ROOT_DEV" ] || [ ! -b "$ROOT_PART_DEV" ] || [ ! -b "$BOOT_PART_DEV" ] ; then FAIL_REASON="Could not determine partitions" return 1 fi } check_kernel () { local MAJOR=$(uname -r | cut -f1 -d.) local MINOR=$(uname -r | cut -f2 -d.) if [ "$MAJOR" -eq "4" ] && [ "$MINOR" -lt "9" ]; then return 0 fi if [ "$MAJOR" -lt "4" ]; then return 0 fi NEW_KERNEL=1 } main () { get_variables if ! check_variables; then return 1 fi check_kernel if [ "$NOOBS" = "1" ] && [ "$NEW_KERNEL" != "1" ]; then BCM_MODULE=$(grep -e "^Hardware" /proc/cpuinfo | cut -d ":" -f 2 | tr -d " " | tr '[:upper:]' '[:lower:]') if ! modprobe "$BCM_MODULE"; then FAIL_REASON="Couldn't load BCM module $BCM_MODULE" return 1 fi fi if [ "$ROOT_PART_END" -eq "$TARGET_END" ]; then reboot_pi fi if [ "$NOOBS" = "1" ]; then if ! parted -m "$ROOT_DEV" u s resizepart "$EXT_PART_NUM" yes "$TARGET_END"; then FAIL_REASON="Extended partition resize failed" return 1 fi fi if ! parted -m "$ROOT_DEV" u s resizepart "$ROOT_PART_NUM" "$TARGET_END"; then FAIL_REASON="Root partition resize failed" return 1 fi partprobe "$ROOT_DEV" fix_partuuid return 0 } mount -t proc proc /proc mount -t sysfs sys /sys mount -t tmpfs tmp /run mkdir -p /run/systemd mount /boot mount / -o remount,rw sed -i 's| init=/usr/lib/raspi-config/init_resize.sh||' /boot/cmdline.txt if ! grep -q splash /boot/cmdline.txt; then sed -i "s/ quiet//g" /boot/cmdline.txt fi sync echo 1 > /proc/sys/kernel/sysrq if ! check_commands; then reboot_pi fi if main; then whiptail --infobox "Resized root filesystem. Rebooting in 5 seconds..." 20 60 sleep 5 else sleep 5 whiptail --msgbox "Could not expand filesystem, please try raspi-config or rc_gui.\n${FAIL_REASON}" 20 60 fi reboot_pi
partedを使って$ROOT_DEV
をゴニョゴニョしている部分があるのでビンゴっぽい。
whiptail --infobox "Resized root filesystem. Rebooting in 5 seconds..." 20 60
ってのがリサイズ完了メッセージを出すやつみたい。
面白いのは174行目から178行目あたり。
sed -i 's| init=/usr/lib/raspi-config/init_resize.sh||' /boot/cmdline.txt if ! grep -q splash /boot/cmdline.txt; then sed -i "s/ quiet//g" /boot/cmdline.txt fi sync
/boot/cmdline.txt
からinit=/usr/lib/raspi-config/init_resize.sh
を消して次から実行されないようにしている。
自動拡張を無効にしてみる
とりあえず/boot/cmdline.txt
からinit=/usr/lib/raspi-config/init_resize.sh
を削除して、
Raspberry PIに入れて起動してみた。
初回起動を終えた後に改めてfdiskで確認すると、自動拡張されていないことが確認できる。
$ sudo fdisk -l /dev/mmcblk0 Disk /dev/mmcblk0: 3.7 GiB, 3965190144 bytes, 7744512 sectors Units: sectors of 1 * 512 = 512 bytes Sector size (logical/physical): 512 bytes / 512 bytes I/O size (minimum/optimal): 512 bytes / 512 bytes Disklabel type: dos Disk identifier: 0x7ee80803 デバイス 起動 Start 最後から セクタ Size Id タイプ /dev/mmcblk0p1 8192 98045 89854 43.9M c W95 FAT32 (LBA) /dev/mmcblk0p2 98304 3645439 3547136 1.7G 83 Linux
この状態で、raspi-configの7 Advanced Options -> A1 Expand Filesystem
すれば、昔のRaspbianのように手動で拡張できる。
resize2fsの実行はどうなっているか調べる
通常、パーティションをリサイズしたあとはOSを再起動してからresize2fsをしなければいけないはずなんだけど、 それはどこでやっているのだろうと思ってraspi-configのExpand Filesystem機能を調べてみた。
raspi-config自体はgithubにリポジトリがあったのでそこのraspi-configのソースを読んだ。
Expand Filesystem機能はdo_expand_rootfs
で実装されていて、その中のコードを読んでみたら、fdiskでパーティションを拡張した後に、/etc/init.d/resize2fs_once
を作成してupdate-rc.dでサービスに登録し、再起動後に実行させていることがわかった。
resize2fs_once
はサービスが起動されたら、resize2fsをrootfsパーティションに対して行い、自分自身のサービス登録を解除して、ファイル削除するようになっている。
RaspbianのOSイメージを焼いたばかりのSDカードでrootfsをマウントして/etc/init.d
の中を調べてみると、確かにresize2fs_once
が入っていた。
OSイメージに入っていた/etc/init.d/resize2fs_once
はこんな感じになっていた。
#!/bin/sh ### BEGIN INIT INFO # Provides: resize2fs_once # Required-Start: # Required-Stop: # Default-Start: 3 # Default-Stop: # Short-Description: Resize the root filesystem to fill partition # Description: ### END INIT INFO . /lib/lsb/init-functions case "$1" in start) log_daemon_msg "Starting resize2fs_once" ROOT_DEV=$(findmnt / -o source -n) && resize2fs $ROOT_DEV && update-rc.d resize2fs_once remove && rm /etc/init.d/resize2fs_once && log_end_msg $? ;; *) echo "Usage: $0 start" >&2 exit 3 ;; esac
Raspbianの初回起動時には必ずこのサービスが動くので、rootfsのパーティションに対してresize2fsが行われる。
/etc/init.d/resize2fs_once
もスパイ映画の「なお、このテープは自動的に消滅する」みたいな作りになっていて面白い。
まとめ
Raspbianのrootfsの初回起動時自動拡張は/usr/lib/raspi-config/init_resize.sh
と/etc/init.d/resize2fs_once
の合わせ技で実現されていることがわかった。
Elmのupdate: Msg -> Model -> (Model, Cmd Msg)にあこがれてJavaScriptの状態管理用のライブラリ作った
作ったのはこれ。
動機
普段Reduxで非同期なことをやる時にredux-thunkミドルウェアを使っている。
で、次のようなことをするときにいつも悩む。
- 非同期にデータを取ってくるアクションで取得前にローディングマスクを表示し、取得完了後にローディングマスクを非表示にする。
- なんらかのメッセージ(完了したとかエラーになったとか)をToast的に表示して、数秒後に消す。
例えばローディングマスクのやつだと、だいたい次のように実装している。
import { createStore, applyMiddleware } from 'redux'; import thunk from 'redux-thunk'; const fetchMessage = () => { return new Promise(resolve => { // サンプルなのでsetTimeoutでごまかし setTimeout(() => { resolve('hello'); }, 1000); }); }; const load = () => { return async dispatch => { dispatch({ type: 'prepare-for-loading' }); const msg = await fetchMessage(); dispatch({ type: 'loaded', payload: msg }); }; }; const store = createStore((state = { msg: '', loading: false }, action) => { switch (action.type) { case 'prepare-for-loading': { return { ...state, loading: true }; } case 'loaded': { return { msg: action.payload, loading: false }; } default: { return state; } } }, applyMiddleware(thunk)); store.subscribe(() => { console.log(store.getState()); }); store.dispatch(load());
このとき気になるのが、load
の実装。
const load = () => { return async dispatch => { dispatch({ type: 'prepare-for-loading' }); const msg = await fetchMessage(); dispatch({ type: 'loaded', payload: msg }); }; };
thunkなアクションとしてdispatchを受け取る関数を返し、非同期API実行の前後で2回dispatchしている。 これが1つのアクションで2つの仕事をしているように見えて、なんだかなーと思っていた。 テストを書くときも、この関数は2つアクションを返して、1つ目は...みたいになっていて、ますますなんだかなーと。
次のように中身を分離して使う側でそれぞれ呼び出すという手もあるが、これを並べて同じタイミングで実行するのか、
それとも1つ目のdispatchで変更されたStateのloading:true
が来たら2つ目を別のタイミングで実行するべきかなどで悩ましい。
store.dispatch({ type: 'prepare-for-loading'}); store.dispatch(() => { return async dispatch => { const msg = await fetchMessage(); dispatch({ type: 'loaded', payload: msg }); } });
そんな感じで悶々としていた。
Elmとの出会い
ReduxがElmを参考にしたという話は知っていたけど、特別触ってみたりはしていなかった。 ある日rebuild.fmのエピソードで取り上げられているのを聴いて、ちょっと興味が湧いたので勉強してみた。
Elmアーキテクチャの簡単なやつを触って、update
のインターフェイスがupdate : Msg -> Model -> Model
な辺りまでは、
確かにReduxっぽい...みたいな印象だった。
その後、commandを使う辺りまで進んで、インターフェイスがupdate : Msg -> Model -> (Model, Cmd Msg)
になった時に、自分がやりたかったのこれだなーと思った。
ローディングのやつの例でElmだと次のようになると思う(実際は非同期取得の結果の成否とかもっとcase増えると思うけど)。
update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of PrepareForLoading -> ( { model | loading = True }, load ) Loaded payload -> ( { model | msg = payload }, Cmd.none )
で、JavaScriptでこんな感じにやるには...と考えて冒頭に貼ったcycloneというライブラリを作った。
cycloneを使った場合
Reduxがupdate<S, A>(state: S, action: A) => S
なのに対して、cycloneはupdate<S, A>(state: S, action: A) => [S, A]
というインターフェイスになっていて、更新された状態と次に実行するアクションをタプルで返すようにしてある。次に実行するアクションがない場合はnone
を返す。これはElmでのCmd.none
を意識した。
上のローディングマスクの例をcycloneで書き直すと次のようになる。
import { createStore, none } from '@ushiboy/cyclone'; const fetchMessage = () => { return new Promise(resolve => { setTimeout(() => { resolve('hello'); }, 1000); }); }; const prepareForLoading = () => ({ type: 'prepare-for-loading' }); const load = async () => { const msg = await fetchMessage(); return { type: 'loaded', payload: msg }; }; const store = createStore({ msg: '', loading: false }, (state, action) => { switch (action.type) { case 'prepare-for-loading': { return [ { ...state, loading: true }, load() ]; } case 'loaded': { return [ { msg: action.payload, loading: false }, none() ]; } default: { return [state, none()]; } } }); store.subscribe(() => { console.log(store.getState()); }); store.dispatch(prepareForLoading());
prepareForLoading
を実行すると、update
はloadingをtrueにし、次に実行するload
を返す。
storeは更新された状態を通知しつつ、次に実行すべきアクションを実行する。
load
の前に実行する関数の名前をどうするか、名前お悩み問題が増えたような気がするけど、
アクション的には1つのアクションで1つの仕事をするようになったので、概ね満足。
その他の機能
Reducer
ついでにつけた機能として、Reducerでの分割統治はできるようにしておこうということでcombine
とreducer
というAPIを用意した。
使用例は次の通り。
import { createStore, none, combine, reducer } from '@ushiboy/cyclone'; const store = createStore({ a: 0, b: 0, c: '' }, combine( reducer('a', (state, action) => { switch (action.type) { default: { return [state, none()]; } } }), reducer('b', (state, action) => { switch (action.type) { default: { return [state, none()]; } } }), reducer('c', [ 'a', 'b' ], (state, action, a, b) => { switch (action.type) { default: { return [state, none()]; } } }) ));
Reducerの定義はreducer
関数に対象とするStateのキー名とupdate関数を渡して行うようにした。それをcombine
関数で統合してひとつのupdate関数にする。
Reducerで分割統治すると、後々この状態はこっちでも参照したい...がでるので、reducer
関数の第2引数に参照したい他の状態のキー名リストを渡すとupdate関数の第3引数以降で受け取れるようにした(上の例だとc
のReducerでa
とb
の状態を参照している)。
Extra Argument
もうひとつ、redux-thunkのwithExtraArgument的な機能も足した。
createStore
の第3引数にアクション実行時に利用したいオブジェクトなどを設定すると、アクション側で受け取って使用できる。
これはWebAPIみたいな、テストの時はインターフェイス同じなモックに差し替えたいものを使うことを目的としている。
サンプルコードは次の通り。
const greet = () => async ({ sleep }) => { await sleep(1000); return { type: 'greet', payload: { msg: 'hello' } }; }; const store = createStore( { word: '', waiting: false }, (state, action) => { switch (action.type) { default: { return [state, none()]; } } }, { sleep(time) { return new Promise(resolve => { setTimeout(() => { resolve(); }, time); }); } } );
greet
アクションはcreateStoreの第3引数で指定したsleep
APIを持つオブジェクトを受け取って使用している。
まとめ
Elmの学習はupdateのインターフェイス以外にも参考になることが結構あったのでやってよかった。
作ったライブラリは、ひとまず意図した動きになっているけど、中身の実装でまだ迷っている部分があるので、 その辺はドッグフーディングしながら様子見。
E2Eテストの実行時間を1/2とか1/4に縮めた
前回も触れたけど、普段Seleniumを使ってpythonでE2Eテストを書いている。
E2Eテストの接続先サーバはdocker-composeを使って必要なものをガッと立ち上げていて、 テスト内でredisやDBに直接接続してデータの初期化をしながらやっている。
使っているdocker-compose.yml
はだいたい次のような感じ。
version: '2' services: web: container_name: "test-web" image: nginx:1.11 ports: - "8080:80" volumes: - /etc/localtime:/etc/localtime - ./build:/usr/share/nginx/html - ./fixture/nginx.conf:/etc/nginx/conf.d/default.conf depends_on: - app app: container_name: "test-app" image: myapp volumes: - /etc/localtime:/etc/localtime - ./fixture/run.sh:/myapp/run.sh - ./fixture/production.ini:/myapp/production.ini depends_on: - redis - db redis: container_name: "test-redis" ports: - "6379:6379" volumes: - /etc/localtime:/etc/localtime image: redis db: container_name: "test-db" image: postgres:9.5 ports: - "5432:5432" volumes: - /etc/localtime:/etc/localtime - ./fixture/postgresql/initdb:/docker-entrypoint-initdb.d environment: - POSTGRES_PASSWORD=postgres - POSTGRES_USER=postgres
webサーバとしてnginxがあり、WEB APIのためのappサーバへのリクエストをリバースプロキシする。 appサーバはAPIのリクエストが来たら、dbやredisに対してアレコレする。
これをdocker-compose up -d
したあとにpy.test test
して実行する...という感じにやっていた。
あるアプリケーションで、機能が増えるに連れてだんだんとテストの量が増えていき、 現在は150件くらいのテストを実行するのに15分近くかかるようになっていた。
DBコンテナの/var/lib/postgresql/data
をtmpfsにし、selenium.webdriver.support.ui.WebDriverWait
のpoll_frequency
パラメータ(UIの変化を待機する条件をチェックする周期)をデフォルトの0.5秒から小さくして、
トータル時間12分位までには縮まったものの、劇的な効果は得られなかった。
そこで以前から考えていたpytest-xdistを使ったテストの並列化を試してみた。
pytest-xdistとdocker-composeの複数起動
pytest-xdistを使うとpy.test -n 2 test
のようにして、-n
オプションでワーカーの数を指定してテストを並列に実行できるようになる。
ただし、E2Eテストでこれをそのままやってしまうと、あるワーカーがテストを行っている最中に別のワーカーがDBの初期化をしてしまうので、 ワーカー毎に接続先のサーバ側を分ける必要があり、そのままでは使えない。
docker-composeは-p
オプションでプロジェクト名を指定して複数起動することができるので、
docker-compose.yml
に環境変数で各種ポートを指定できるように修正を加えて、プロジェクトごとにポートを変えて起動できるようにした。
修正したdocker-compose.yml
ファイルは次のような感じになった。
version: '2.1' services: web: container_name: "test-web-${ID:-1}" image: nginx:1.11 ports: - "${WEB_PORT:-8080}:80" volumes: - /etc/localtime:/etc/localtime - ./build:/usr/share/nginx/html - ./fixture/nginx.conf:/etc/nginx/conf.d/default.conf depends_on: - app app: container_name: "test-app-${ID:-1}" image: myapp volumes: - /etc/localtime:/etc/localtime - ./fixture/run.sh:/myapp/run.sh - ./fixture/production.ini:/myapp/production.ini depends_on: - redis - db redis: container_name: "test-redis-${ID:-1}" ports: - "${REDIS_PORT:-6379}:6379" volumes: - /etc/localtime:/etc/localtime image: redis db: container_name: "test-db-${ID:-1}" image: postgres:9.5 ports: - "${DB_PORT:-5432}:5432" tmpfs: - /var/lib/postgresql/data volumes: - /etc/localtime:/etc/localtime - ./fixture/postgresql/initdb:/docker-entrypoint-initdb.d environment: - POSTGRES_PASSWORD=postgres - POSTGRES_USER=postgres
コンテナ名が被らないように名称の末尾に付けるためのID
と各ポートWEB_PORT
、DB_PORT
、REDIS_PORT
を環境変数で渡せるようにした。
これで次のようにコマンドを叩けば同じ構成の環境をポートを変えて複数立ち上げられる。
$ ID=1 WEB_PORT=8080 DB_PORT=54320 REDIS_PORT=63790 docker-compose -p s1 up -d $ ID=2 WEB_PORT=8081 DB_PORT=54321 REDIS_PORT=63791 docker-compose -p s2 up -d
pytest-xdistによって、テスト実行中のワーカーにはPYTEST_XDIST_WORKER
環境変数が渡される。
ここから、gw0
やgw1
といった自分のワーカーIDを取得できる。
このワーカーIDと接続するサーバのポートを関連付けて使うようにすれば、ワーカー毎に異なるサーバへ接続して互いに干渉することなくテストできる。
ワーカーと接続サーバの関連付け
docker-composeの環境変数に渡す値と、ワーカー内で使用するポートの値を関連付けて管理するために、次のようなe2e.config.yml
設定ファイルを用意した。
workers: - web_port: 8080 redis_port: 63790 db_port: 54320 - web_port: 8081 redis_port: 63791 db_port: 54321 - web_port: 8082 redis_port: 63792 db_port: 54322 - web_port: 8083 redis_port: 63793 db_port: 54323
ワーカーごとにweb_port
、redis_port
、db_port
を記述できるようにした。
そして、この設定ファイルを読み込んで使うためのAPIをenvironment.py
として用意した。
import yaml import subprocess import os def load_e2e_config(filepath): with open(filepath, 'r') as f: data = yaml.load(f, Loader=yaml.SafeLoader) config = dict() workers = data.get('workers', []) for i, w in enumerate(workers): w['id'] = i + 1 config['gw%d' % i] = w return config def docker_compose_up(config): for name, c in config.items(): env = os.environ.copy() env['ID'] = str(c['id']) env['WEB_PORT'] = str(c['web_port']) env['REDIS_PORT'] = str(c['redis_port']) env['DB_PORT'] = str(c['db_port']) subprocess.run(['docker-compose', '-p', name, 'up', '-d'], env=env) def docker_compose_down(config): for name, c in config.items(): subprocess.run(['docker-compose', '-p', name, 'down'])
load_e2e_config
関数はe2e.config.yml
を読み込んでワーカーIDをキーとした設定オブジェクトに変換する。
docker_compose_up
関数はload_e2e_config
関数で作った設定オブジェクトを利用して、必要な数だけdocker環境を立ち上げる。
docker_compose_down
関数は立ち上げていたdocker環境を停止するために使う。
これを使ってサーバ起動用スクリプトをstartup-servers
として用意した。
#!/usr/bin/env python3 from myapp.environment import load_e2e_config, docker_compose_up if __name__ == '__main__': config = load_e2e_config('./e2e.config.yml') docker_compose_up(config)
同じくサーバ停止用スクリプトをhalt-servers
として用意した。
#!/usr/bin/env python3 from todo.environment import load_e2e_config, docker_compose_down if __name__ == '__main__': config = load_e2e_config('./e2e.config.yml') docker_compose_down(config)
テストケースのコードではsetUpメソッドでload_e2e_config
関数を使って設定ファイルを読み込み、PYTEST_XDIST_WORKER
環境変数から自身のワーカーIDを取得して、接続先のWeb、DB、Redisのポートを取り出して使うようにした。
def setUp(self): target_port = '8080' db_port = '5432' redis_port = '6379' worker = os.environ.get('PYTEST_XDIST_WORKER') if worker is not None: config = load_e2e_config(E2E_CONFIG_PATH) c = config[worker] target_port = str(c['web_port']) db_port = str(c['db_port']) redis_port = str(c['redis_port'])
テストの実行
用意したスクリプトと設定で、テストの実行を行った。
$ ./startup-servers $ HEADLESS=1 py.test -n 4 test $ ./halt-servers
並列化なしで12分位だった実行時間が、ワーカー数2つの並列化で6分位、ワーカー数4つで3分位で終わるようになった。
ちなみに使ったWebDriverはChromeだけで、FirefoxとIE、Edgeは試していない。 Firefoxは大丈夫そうな気がするけど、IEとEdgeは駄目なんじゃないかと予想している。
まとめ
並列化して実行時間をいい感じに縮められた。
前回の参考リポジトリにも反映しておいたけど、こちらはそもそもDBとか使わないやつなのであまり恩恵はない。 とりあえずテンプレート用に入れておいた。
こちらは並列化なしの結果が次のような感じ。
$ HEADLESS=1 py.test todo/ ============================================================== test session starts =============================================================== platform linux -- Python 3.7.1, pytest-4.2.1, py-1.8.0, pluggy-0.9.0 Using --randomly-seed=1551505883 rootdir: /work/my-todo-2019-early/e2e, inifile: plugins: xdist-1.26.1, randomly-1.2.3, forked-1.0.2 collected 5 items todo/specs/todos_test.py ..... [100%] =========================================================== 5 passed in 16.32 seconds ============================================================
並列化あり(ワーカー数4つ)だと結果が次のような感じになる。
$ HEADLESS=1 py.test -n 4 todo/ ============================================================== test session starts =============================================================== platform linux -- Python 3.7.1, pytest-4.2.1, py-1.8.0, pluggy-0.9.0 Using --randomly-seed=1551505959 rootdir: /work/my-todo-2019-early/e2e, inifile: plugins: xdist-1.26.1, randomly-1.2.3, forked-1.0.2 gw0 [5] / gw1 [5] / gw2 [5] / gw3 [5] ..... [100%] ============================================================ 5 passed in 6.59 seconds ============================================================
2019年現時点の自分のWebフロントエンド開発環境
数年前、年末にその時点での自分のWebフロントエンド開発まわりの環境を記録用にまとめていたけど、 それからすっかり怠けてしまい、去年の暮れには久しぶりにやろうと思っていたものの、気がついたらすっかり年が明けていた。
最近色々ずっとアレな感じでメンタル低空飛行気味なんだけど、このまま朽ち果てていくのは嫌なので、気分転換兼ねてまとめてみた。
今回はサンプルとしてTodoアプリケーションを作りながらまとめた。
前置き
自分が普段開発しているWebフロントエンドのボリュームは2,3万行くらいのコード量のSingle Page Application。 一般公開されるWebサービスではなくて、ログインして使う業務アプリケーションみたいなのが多め。 Web API叩いて必要なデータをバックエンドとやりとりしながら、JavaScriptで動的にすべての画面を描いていく感じ。 初期描画のパフォーマンス要求とかは今のところそこまで気にしなくていい程度のもの。 それをフロントエンドチーム(自分一人)で開発・メンテしてる。
開発スタイルはLinuxデスクトップで、フルスクリーン表示したターミナルをtmuxで画面分割して、コマンド叩きつつvimでコード書いてる。 動作確認はChromeでDevTools開きながらやって、大枠できてから他のブラウザで確認する感じ。
そんな人間の環境なので、これだとできないことがあるとか、チームでやったらやりにくい、うまくいかないとかそういうのはあると思う。
偏りがあるのはさておき、とりあえずJavaScript、CSS、開発Webサーバ、テストあたりについて順にまとめていく。
JavaScriptまわり
「FlowからTypeScriptへ移行した」みたいな記事をよく見かけるようになった昨今で、
@babel/preset-typescript
が出てきたというのもあり、先のこと考えたらTypeScriptに行っとくべきかなーと思いつつ、
とりあえずBabel + Flowで型付けたECMAScriptで書き続けている。
babel.config.js
の設定はこんな感じ。
module.exports = { 'presets': [ '@babel/preset-env', '@babel/preset-react', '@babel/preset-flow' ], 'env': { 'development': { 'presets': [ 'power-assert' ] }, 'production': { 'plugins': [ [ 'react-remove-properties', { 'properties': ['data-test'] } ] ] } } }
ViewのライブラリにReactを使ってるので@babel/preset-react
を追加している。power-assert
とreact-remove-properties
に関しては、後でテストの方で説明する。
Flowで外部ライブラリの型定義を扱うときに、flow-typedを真面目には使っていなくて、必要に応じてリポジトリから取ってきて定義ファイルを設置してみているけど、ほとんどはdeclare module.exports: any;
で握りつぶしている。
バンドルはwebpackのbabel-loader頼み。
webpack.config.js
のbabel-loader部分はこんな感じ。
module: { rules: [ { test: /\.js$/, exclude: /node_modules/, loader: 'babel-loader' },
特に変わったことはしていない。
コードフォーマットをPrettierにまかせていて、ESlintは細かく設定していない。
.eslintrc.json
はこんな感じ。
{ "env": { "browser": true, "commonjs": true, "es6": true, "node": true, "mocha": true }, "globals": { "ENABLE_SERVICE_WORKER": true }, "extends": [ "eslint:recommended", "plugin:react/recommended" ], "parser": "babel-eslint", "parserOptions": { "ecmaFeatures": { "jsx": true }, "ecmaVersion": 2018, "sourceType": "module" }, "plugins": [ "flowtype", "react" ], "rules": { }, "settings": { "react": { "version": "detect" } } }
ReactとFlow使ってるぶんの設定があるくらい。no-unused-vars
の検出できれば良いくらいの感覚で使っている。
実はコードフォーマット系は苦手意識があったけど、去年Elmの勉強した時にelm-format使ったらすごい楽で、 フォーマットに頭使わなくていいことの良さを思い知って、JavaScriptでもやるようになった。
Prettierはシングルクォートについて以外、特に指定してなくてnpm script
で使うためにpackage.jsonに次のように設定している。
"scripts": { "lint": "eslint src", "format-js": "prettier --single-quote --write 'src/**/*.js'",
ファイル保存時に自動整形はやってなくて、gitコミットする前にフォーマットして、テストとか通るの確認してからコミットしてる。
CSSまわり
LESSをやめて、Sassをやめて、PostCSSを使うようにしているけど、Bootstrap依存症なのでSassも入れることになり、結局Sassで書いている。 Bootstrapが次のバージョンからPostCSSにするとか言ってる記事を見た気がするので、次バージョンあげたらSassはやめるかもしれない。
バンドルはwebpack頼み。昔はwebpackはJavaScriptのバンドルのみに使って、CSSまわりはgulpと色々組み合わせてやっていたけど、後で書く開発Webサーバのこともあり、もうwebpackで全部やってる。
Reactを使っているので、styled-componentsとかやったほうがいいのかなーと思いつつも、 そもそもデザインやCSS得意じゃないので、デザイナーが作ってくれたCSSを取り込みながらやるスタイルを想定してCSSは別にしてる。 スコープ問題とかで苦しみたくないし、BEM記法は性に合わないので色々工夫してみていたけど、ひとまずCSS Modulesってやつでやってみている。
webpack.config.js
のその辺りの設定は次の通り。
module: { rules: [ { test: /src\/app\.scss$/, use: [ { loader: MiniCssExtractPlugin.loader }, { loader: 'css-loader' }, { loader: 'postcss-loader', options: { plugins: function () { return [ require('precss'), require('autoprefixer') ]; } } }, { loader: 'sass-loader' } ] }, { test: /\.(css|scss)$/, include: /src\/presentation/, use: [ { loader: MiniCssExtractPlugin.loader }, { loader: 'css-loader', options: { modules: true, localIdentName: mode === 'production' ? '[hash:base64]' : '[path][name]-[local]-[hash:base64:5]' } }, { loader: 'postcss-loader', options: { plugins: function () { return [ require('precss'), require('autoprefixer') ]; } } }, { loader: 'sass-loader' } ] }
src/app.scss
とsrc/presentation
配下の.(css|scss)
で分けてルールを設定している。
src/app.scss
の方は次のようになっていて、BootstrapやFontAwesomeのスタイルはCSS Modulesの対象外にしたいので、こんな分け方になっている。
@import "./scss/_variables.scss"; @import "~bootstrap/scss/bootstrap"; $fa-font-path: "../node_modules/@fortawesome/fontawesome-free/webfonts"; @import "~@fortawesome/fontawesome-free/scss/fontawesome.scss"; @import "~@fortawesome/fontawesome-free/scss/solid.scss"; @import "~@fortawesome/fontawesome-free/scss/brands.scss"; @import "~react-toastify/scss/main.scss";
アプリケーションのViewコンポーネントのスタイルはsrc/presentation
配下にコンポーネントごとにディレクトリを切って、対になるJavaScriptファイルと並べておいている。
Header ├── Header.js └── Header.scss
こちらはCSS Modulesの対象にしている。
例えばHeader.scss
の内容は次のようになっている。
@import "../../../scss/_variables.scss"; .head { background-color: $header-bg-color; .sub { color: $brand-title-color; &:hover { color: $brand-title-color; } } }
これをHeader.js
側で次のようにimport style from './Header.scss';
して使っている。
/* @flow */ import React from 'react'; import style from './Header.scss'; import '../../../images/icon.svg'; export const Header = () => { return ( <nav className={`navbar navbar-light ${style.head}`}> <span className={`navbar-brand ${style.sub}`}> <img src="images/icon.svg" width="30" height="30" className="d-inline-block align-top" alt="" />{' '} Todo </span> </nav> ); };
mini-css-extract-plugin
を使って、ビルドしたらcssファイルとして出力されるようにしているけど、webpackのバージョンが上がる度にこのあたりは躓いているので、毎回ヒヤヒヤする。
開発Webサーバ
以前はgulpの中でconnectとかlive-reloadとか使ってやっていた。今ではwebpack-dev-serverでWeb APIのプロキシとか、 History APIのフォールバックとか全部任せてやっている。
これとCSSの件を合わせて、gulpを完全にやめるきっかけとなり、npm scriptとwebpackでだいたい何とかするようになった。
webpack.config.js
のwebpack-dev-server周りの設定は次の通り。
devServer: { contentBase: './src', inline: true, host: '0.0.0.0', port: 8080, disableHostCheck: true, historyApiFallback: true, stats: { version: false, hash: false, chunkModules: false } }, devtool: 'source-map'
これをnpm start
で起動できるようにpackage.json
に次の通り設定している。
"scripts": { "start": "webpack-dev-server",
テストまわり
単体テスト
Jestの話題を見かけるようになったなーと思いつつ、あいかわらずmochaとpower-assertでやってる。spyやstubが必要になったらsinonも使っている。
そのためbabel.config.js
にpower-assert
の設定がしてある。
以前はtestファイルの置き場はアプリケーションとは別にtest
ディレクトリを切って、そちらにアプリケーションと同じ階層で置くようにしていた。
しかし、importの都合とかで最終的に面倒になってアプリケーションのファイルと同列に*.spec.js
として置くようになった。
domain ├── Todo.js └── Todo.spec.js
*.spec.js
ファイルはESLintの対象にしたくないので、.eslintignore
ファイルに次のように設定している。
src/**/*.spec.js
テストの実行はnpm test
で行うため、package.json
に次のように設定してある。
"scripts": { "test": "mocha --require src/testSetup.js --recursive './src/**/*.spec.js'",
テストもECMAScriptで書きたいので、mochaに渡している--require
パラメータのsrc/testSetup.js
ファイルで次のようにしている。
require('@babel/register')(); require('@babel/polyfill');
テストの実行はNode環境でのみにしていて、ブラウザ環境で実行するKarmaやtestemは使わなくなった。 ViewのテストはEnzymeとjsdomで頑張ってみたこともあるけど、極力しないようになった。
AjaxなどのWEB API叩く系は、以前はfetch-mockを使ってfetchを差し替えたりしていたが、 今はアプリケーションのコードでfetchを直接使わずに一枚かぶせたインターフェイスを定義して、テストの時だけ差し替えるようにしている。
例えば、今回のTodoアプリケーションだとlocalStorageを直接使わずにStorageInterfaceのオブジェクトとして、 テストの時はMapをベースにしたMemoryStorageに差し替えている。
src/infrastructure/LocalStorageRepository.js
の次のあたり。
export class LocalStorageRepository implements TodoRepository { _storage: StorageInterface; constructor(storage: StorageInterface) { this._storage = storage; } // 中略 } interface StorageInterface { +length: number; getItem(key: string): ?string; setItem(key: string, data: string): void; clear(): void; removeItem(key: string): void; } export class MemoryStorage implements StorageInterface { _map: Map<string, string>; constructor() { this._map = new Map(); } get length(): number { return this._map.size; } getItem(key: string): ?string { return this._map.get(key); } setItem(key: string, data: string): void { this._map.set(key, data); } clear(): void { this._map.clear(); } removeItem(key: string): void { this._map.delete(key); } key(index: number): ?string { return Array.from(this._map.keys())[index]; } }
E2Eテスト
Seleniumを使ってE2Eテストをわりと書いている。 E2Eテストはコスト高いけど、手作業でする簡単な動作確認を自動で繰り返しできるようにしておく程度のモチベーションで書いている。
SeleniumはJavaScriptで使うとAPIが非同期系多すぎて書きづらいので、pythonで書いている。テストにはpy.testを使っている。
環境はe2eディレクトリ配下にまとめている。
e2e ├── drivers │ ├── chromedriver │ └── geckodriver ├── requirements.txt ├── todo │ ├── __init__.py │ ├── datas │ ├── page_objects │ └── specs └── venv
driversディレクトリにはWebドライバーをダウンロードしてきたものを置く。ブラウザがアップデートするとドライバーが合わなくなるので、 都度更新できるように中身はgitignore対象にしてある。
テストケースはe2e/todo/specs
下に置いてある。E2Eテストを書くときにドライバーでのDOMの取得をテストにベタ書きすると
後々非常に辛くなるので、ページオブジェクトパターンを使ってテストケースではDOMを直接触らずに、
ページオブジェクト経由でシナリオに沿って画面操作して取得した結果をアサーションにかけている。
ページオブジェクトはe2e/todo/page_objects
配下に置いてあり、例えばTodo一覧のページオブジェクト(todo_page.py
)は次のようになっている。
from . import PageObject class TodoListPage(PageObject): @property def el(self): return self.driver.find_element_by_css_selector('div[data-test="todo-list"]') def wait_show_todo_list(self): self._wait_show('div[data-test="todo-list"]') return self def wait_loading_complete(self): return self._wait_loading_complete(self.el) def get_todo_rows(self): rows = self.el.find_elements_by_css_selector('tr[data-test="todo-list-row"]') return list(map(lambda el: TodoListRow(self.driver, el), rows)) def click_create_todo_button(self): b = self.el.find_element_by_css_selector('a[data-test="create-todo-button"]') b.click() return TodoFormPage(self.driver) def click_all_tab(self): t = self.el.find_element_by_css_selector('a[data-test="all-tab"]') t.click() return self def click_active_tab(self): t = self.el.find_element_by_css_selector('a[data-test="active-tab"]') t.click() return self def click_completed_tab(self): t = self.el.find_element_by_css_selector('a[data-test="completed-tab"]') t.click() return self
これを使ったTodo一覧の表示確認用のテスト(todos_test.py
)は次の通り。IndexPageのopenでTodoListPageが取得できるようになっている。
class TodosTest(E2ETest): def test_show_todo_list_rows(self): p = IndexPage(self.driver, self.target_host)\ .open() init_data(self.driver) p = p.reload()\ .wait_show_todo_list() rows = p.get_todo_rows() assert len(rows) == 3 r1, r2, r3 = rows; assert r1.get_title() == 't1' assert r2.get_title() == 't2' assert r3.get_title() == 't3'
画面のHTMLの構造が変わっても、機能自体が変わっていなければ、ページオブジェクトの修正だけでテストは変えずにすませられる。 それと、テストケース間でページオブジェクトを使いまわせるので重宝している。
CSS Modulesを使っている関係上、DOMへのアクセスにセレクタでCSSクラスを使いにくい(テスト用の余計なCSSクラスとか付けたくない)
のでDOMにはdata-test
属性を付けてアクセスできるようにしている。
TodoListのViewだと次のようにdata-test="todo-list"
とかしている。
/* @flow */ // 省略 export class TodoList extends React.Component<Props> { // 省略 render() { const { visibleRecords, loading, filter } = this.props.todos; const rows = visibleRecords.map((t, i) => { return <TodoRow key={i} todo={t} />; }); return ( <div className={styles.todoList} data-test="todo-list"> <div className="card"> <div className="card-header"> <ul className="nav nav-tabs card-header-tabs"> // 省略
babel.config.js
でreact-remove-properties
を利用していて、productionビルドの時はdata-test
属性が取り除かれるようにしてある。
ビルド用のタスクはpackage.jsonに次のように設定している。
"scripts": { "clean": "rm -rf build", "build": "npm run clean && NODE_ENV=production webpack", "build-testing": "npm run clean && NODE_ENV=testing webpack",
npm run build
でビルドするとproductionビルドをする。
npm run build-testing
でビルドするとdata-test
属性がついたままのビルドになるので、E2Eテストの時はこちらを使う。
今回のサンプルアプリケーションではWEB APIを叩くことも無いし、バックエンドにDBやredisがあったりするわけでもないので、 npmで立ち上げたWebサーバにアクセスするだけにしているけど、 業務ではバックエンドをDockerで動かせるようにしておいて、docker-composeでサーバ環境を立ち上げて、 テスト実行ごとにDB初期化とかデータ投入とか色々やって動かしている。
フロントエンドチーム外のメンバーへの提供
フロントエンドのgitリポジトリをクローンしてもらって各自にビルドしてもらうやり方だと、普段フロントエンドの開発をしてないメンバーの環境ではNodeのバージョンが古かったり、npmのバージョンが古かったりして、「ビルドできないんだけど...」と言われることが多かった。
そこでDocker内でビルドできるようにビルド用のDockerイメージを作成するスクリプト(create_docker_image.sh
)と、
そのイメージを使ってビルドするスクリプト(build_on_docker.sh
)を同梱するようにしている。
ビルド用のDockerイメージを作成するcreate_docker_image.sh
は次のようにしてある。
#!/bin/sh SCRIPT_DIR=$(cd $(dirname ${0}) && pwd) cd "$SCRIPT_DIR/.." DOCKER_IMAGE_NAME="nodejs10-for-frontend-build" IMAGE=`docker images | awk '{print $1}' | grep $DOCKER_IMAGE_NAME` if [ "$IMAGE" != "$DOCKER_IMAGE_NAME" ]; then docker build -t $DOCKER_IMAGE_NAME . fi
作成するDockerイメージのDockerfileは次の通り。
FROM ubuntu:18.04 RUN apt-get update && \ apt-get install -y locales && \ locale-gen en_US.UTF-8 ENV LANG en_US.UTF-8 ENV LANGUAGE en_US:en ENV LC_ALL en_US.UTF-8 RUN sed -i.bak -e "s%http://archive.ubuntu.com%http://jp.archive.ubuntu.com%g" /etc/apt/sources.list && \ apt-get update && \ apt-get install -y unzip wget git curl gnupg && \ curl -sL https://deb.nodesource.com/setup_10.x | bash - && \ apt-get install -y nodejs && \ mkdir /.npm && \ chmod 777 /.npm && \ mkdir /.config && \ chmod -R 777 /.config
node用の公式イメージでも良さそうだけど、とりあえず昔からの流れでubuntuベースで作ってる。
Docker内でフロントエンドのビルドをするbuild_on_docker.sh
は次の通り。
#!/bin/sh SCRIPT_DIR=$(cd $(dirname ${0}) && pwd) cd "$SCRIPT_DIR/.." DOCKER_IMAGE_NAME="nodejs10-for-frontend-build" PROJECT_ROOT_DIR=`pwd` USER_ID=`id -u` GROUP_ID=`id -g` RUN_ACTION=`cat << EOS cd /tmp/frontend && \ npm install && \ npm run build EOS` docker run -u $USER_ID:$GROUP_ID \ -v $PROJECT_ROOT_DIR:/tmp/frontend:rw \ --rm $DOCKER_IMAGE_NAME /bin/sh -c "$RUN_ACTION" rm -rf Todo Todo.zip Todo-*.zip mv build Todo REV=`git rev-parse HEAD` echo $REV > Todo/GIT_REVISION zip Todo.zip -r Todo cp Todo.zip "Todo-$REV.zip"
ビルドのついでにzipで固めることと、gitのリビジョンつけたzipも作るようにしてある。
とりあえずこれらを使ってもらうようにしたが、そもそもビルド済みのを配れるようにしておけば良いのでは...と思い、 JenkinsなどのCIでこのスクリプトを使ってビルドし、zipをs3とかにアップロードして配布している。
まとめ
昔と比べるとgulpを捨ててwebpackベッタリになった。
こうして書き出してみると、偏った環境でやっているせいか、甘えてゆるふわになっている部分が結構ある気がする。 必要に応じてそのあたりを引き締めていくのが今後の課題となりそう。
とりあえず色んな動向をうかがいつつ、自分のペースで調整していくつもり。
Selenium WebDriverでテスト書くのにAsync/Awaitを使ってみた
E2Eテストを書くときにSelenium WebDriverを使っているんだけど、テストをJavaScriptで書くと、下みたいな感じでPromiseのチェーンだらけになるのがあまり好みじゃなかった。
const assert = require('power-assert'); const chrome = require('selenium-webdriver/chrome'); const {Builder, By, Key} = require('selenium-webdriver'); describe('Promiseベースで書いたテスト', function () { this.timeout(20000); let driver; beforeEach(() => { driver = new Builder() .forBrowser('chrome') .setChromeOptions(new chrome.Options().headless()) .build(); }); afterEach(() => { driver.quit(); }); it('then,then,then', () => { return driver.get('https://www.google.com').then(() => { driver.findElement(By.name('q')).sendKeys('webdriver'); return driver.sleep(1000); }).then(() => { driver.findElement(By.name('q')).sendKeys(Key.TAB); driver.findElement(By.name('btnK')).click(); return driver.sleep(1000); }).then(() => { return driver.getTitle(); }).then((title) => { assert(title === 'webdriver - Google 検索'); }); }); });
なので、普段はPythonとか別の言語で書いていた。 ECMAScript2017でAsync/Await入るの見た時に、雰囲気変わるかなーと思いつつ試さずに放置してたので、最近はじめた気分転換のついでに試してみた。
こんな感じになった。
const assert = require('power-assert'); const chrome = require('selenium-webdriver/chrome'); const {Builder, By, Key} = require('selenium-webdriver'); describe('Async/Awaitで書いたテスト', function () { this.timeout(20000); let driver; beforeEach(() => { driver = new Builder() .forBrowser('chrome') .setChromeOptions(new chrome.Options().headless()) .build(); }); afterEach(() => { driver.quit(); }); it('await,await,await', async () => { await driver.get('https://www.google.com'); driver.findElement(By.name('q')).sendKeys('webdriver'); await driver.sleep(1000); driver.findElement(By.name('q')).sendKeys(Key.TAB); driver.findElement(By.name('btnK')).click(); await driver.sleep(1000); const title = await driver.getTitle(); assert(title === 'webdriver - Google 検索'); }); });
多少マシになった感じ?
ちなみにPythonで書くとこんな感じ。
import unittest from selenium import webdriver from selenium.webdriver.common.keys import Keys import os import time class GoogleSearch(unittest.TestCase): def setUp(self): driver_path = os.path.join(os.path.dirname(__file__), 'chromedriver') options = webdriver.chrome.options.Options() options.add_argument('--headless') self.driver = webdriver.Chrome(executable_path=driver_path, chrome_options=options) def test_search_in_google_com(self): driver = self.driver driver.get('https://www.google.com') q = driver.find_element_by_name('q') q.send_keys('webdriver') time.sleep(1) q.send_keys(Keys.TAB) driver.find_element_by_name('btnK').click() time.sleep(1) assert driver.title == 'webdriver - Google 検索' def tearDown(self): self.driver.close() if __name__ == '__main__': unittest.main()
もっと込み入った感じのを書いてみないとなんとも言えないけど、ありかなー?どうかなー?って感じ。
observable-storeとfreezable-storeというのを作ってみた話
きっかけ
ビューを作るのにReactを使っているとpropsで渡すものはなるべくシンプルなオブジェクトにしたいかな...みたいな気分になる。
(雑な例であまり良くないのだけれど)例えば、カウンタアプリケーションで現在のカウントを表示するビューみたいなのを作る時に、propsで渡されるのはCounterモデルのインスタンスで...とかではなくて、単純にcountプロパティを持つオブジェクトってだけにしたい。
// こうじゃなくて function CounterView(props) { const { counter } = props; return <div>{counter.getCount()}</div>; } // こんな感じにしたい function CounterView(props) { const { count } = props; return <div>{count}</div>; }
propsで渡ってくるのはそういうオブジェクトだよということをPropTypesとかflowで保証して、キー名typoして残念undefined...みたいなのは防いでおく。ビュー側では参照しかしないので、とりあえず構造が合っていればモデルのこと知らなくていいみたいな感じにしたい。
逆に参照ではなくて状態を変えるところ、例えばカウンタのカウントを増やすみたいなのは、むき出しのJSONこねこねします...みたいなのじゃなくて、ちゃんとCounterモデルの操作にしたい。
またしても雑な例にすると、次みたいな感じでincrementメソッド呼ぶみたいにしたい。
const counter = new CounterModel(/* 初期化パラメータとか */); counter.increment();
で、その辺りひっくるめてちょっと自分なりのやり方探してみようかなーと、モデルとビューの間に入って橋渡しするくんとしてobservable-storeというのを作ってみた。
observable-store
機能は次のような感じ。
- 状態を保持する。
- 保持している状態は参照可能にする。
- 保持している状態は特定の方法でのみ変更可能にする。
- 保持している状態が変わったらオブザーバーに通知する。
APIはObject.assignと短命に終わったObject.observeの間の子みたいにして、実装にはProxy使ってゴニョゴニョやった。
単純な使い方は次のような感じ
import createObservableStore from '@ushiboy/observable-store'; // observable-storeのインスタンスを生成 const store = createObservableStore({ count: 0 }); // 保持している状態にはstateプロパティからアクセスする const state = store.state; console.log(`count: ${state.count}`); // count: 0; // 状態の編集はassignメソッドから行う store.assign({ count: state.count + 1 }); console.log(`count: ${state.count}`); // count: 1; // stateプロパティを直接変更しようとするとエラーになる try { state.count = state.count + 1; // throw Error } catch (e) { console.log(`${e}`); // Error: Should use assign } // 変更を監視するオブザーバーの登録はobserveメソッドで行う const observer1 = () => { console.log(`change: count:${state.count}`); }; store.observe(observer1); // オブザーバーの解除はunobserveメソッドで行う store.unobserve(observer1);
当初はモデルを保持してモデルの変更を監視して...とか、参照の時にモデルのtoJSON呼んで...とか色々考えていたんだけど、モデル側の作り方を制限したくなくなってバッサリやめた。
で、これを使ってやりたかった感じにカウンタアプリ作ってみると次のようになった。
Reactと合わせて使う上でのポイントは次みたいな感じ。
- Applicationコンポーネントでstoreの監視して、変更があったらsetStateして自身に反映する。
- CounterViewコンポーネントには直接storeは渡さずにstateのみを渡す。
- ボタンがクリックされた時の処理はモデルの操作をユースケースで包んで置いて直接は行わないようにする。
単純なサンプル過ぎて参考にならないんだけど、とりあえずやりたい感じになったと言えばなった。
ただ、observable-storeはassignが行われた時に保持している状態に変更があるかゴリゴリチェックしているんだけど、ここで頑張らなくてもReactの方に頼ってしまって良いかも...と感じた。
そこで、状態の変更をチェックするのをやめて、assignされたからどこか変わっているかもよ...とオブザーバーに知らせるだけに機能を削ってfreezable-storeというのを作ってみた。
freezable-store
使い方はstoreの生成方法が変わるだけで、他は一緒。
import createFreezableStore from '@ushiboy/freezable-store'; // freezable-storeのインスタンスを生成 const store = createFreezableStore({ count: 0 });
Reactと合わせてやる分にはこれでも良いかもなーという印象。
今のところの感想
試しに作ったのがカウンタアプリ程度で扱う状態が単純すぎてなんともなーみたいな感じだけど、状態が複雑になってくるとassignのところとかヤバイかなーどうかなーみたいな感じ。とりあえずもうちょっとドッグフーディング。
Proxy使ったの初めてだった。
PhinxでマイグレーションしつつEloquentのモデルのテストをする方法
PhinxでDBのマイグレーションしているけど、モデルにはIlluminate\Database\Eloquentを利用していて、 PHPUnitで単体テストするときにSQLiteのオンメモリでやりたいなと思ったのでソース見ながら方法探してた。
やり方まとまったのでメモしておく。
こんな感じのモデルを用意して、
こんな感じにテストする。
ポイントはテストのsetUpで、 Illuminate\Database\Capsule\Managerのインスタンスを作って各種設定をしたあとにPDOのインスタンスを取り出して、 それをPhinx\Migration\Managerのインスタンスにセットしてマイグレーションを実行する。
DB設定は一応phinx.ymlから参照するようにしたけど、どうせSQLiteの:memory:しか使わないのであればベタ書きで良いかも。
サンプルコードはこちら。 github.com