そうだ、教祖になろう。出エジプト記 第4章8節 クライアントサイドのE2Eテストを実装する
知らず知らず歩いてきた細く長いこの道
すっかり休み気分も抜けた頃ですが、あけましておめでとうございます。
お正月は異教の宗教施設に行ってきました。
やはり、朝の寺社仏閣は心が洗われます。
2020年も新たな気持ちで本ブログに取り組んでいきたいと思います。
振り返れば遥か遠く 故郷が見える
昨年の第4章7節 クライアントサイドのユニットテストをカバレッジ100%にするでJestでVue.jsのユニットテストを実装しました。
Jestはモジュール単位のテストですが今回はEnd to End Test、つまりブラウザからリクエストを送ったのをシミュレートして、返ってきたレスポンスをアサーションするテストを実装しようと思います。
使うのはNightwatchパッケージです。
まあこれが新年一発目のブログが遅れた理由です。
もちろん、正月ボケもその一因ですが。
Nightwatchは疑似的なサーバから受け取ったレスポンスをブラウザにレンダリングさせます。
Cloud9の実体はEC2インスタンスなのでAmazon Linuxになるわけですが、
GUI関連のライブラリが入っておらず、まずChromeのインストールがひと苦労でした。
試行錯誤をいちいち書いていると長くなりますし、細かいことは忘れてしまったので、要点だけ書き留めます。
まずyarn test:e2e
を起動すると「何言ってんだい!うちにゃぁ、Chromeなんてないよ!嫌ならGoogleさんちの子になんな!」と怒られてしまいます。
Error: An error occurred while retrieving a new session: "unknown error: cannot find Chrome binary"
ああ川の流れのように
まずはChromeを入れるとこからです。
UIを伴わないヘッドレスChromeを導入します。
参考にさせていただだきました。
必要な依存パッケージを全部入れます。
$ sudo yum install -y cups-libs dbus-glib libXrandr libXcursor libXinerama cairo cairo-gobject pango ipa-gothic-fonts ipa-mincho-fonts $ sudo rpm -ivh --nodeps http://mirror.centos.org/centos/7/os/x86_64/Packages/atk-2.28.1-1.el7.x86_64.rpm $ sudo rpm -ivh --nodeps http://mirror.centos.org/centos/7/os/x86_64/Packages/at-spi2-atk-2.26.2-1.el7.x86_64.rpm $ sudo rpm -ivh --nodeps http://mirror.centos.org/centos/7/os/x86_64/Packages/at-spi2-core-2.28.0-1.el7.x86_64.rpm $ sudo rpm -ivh --nodeps http://dl.fedoraproject.org/pub/archive/fedora/linux/releases/20/Fedora/x86_64/os/Packages/g/GConf2-3.2.6-7.fc20.x86_64.rpm $ sudo rpm -ivh --nodeps http://dl.fedoraproject.org/pub/archive/fedora/linux/releases/20/Fedora/x86_64/os/Packages/l/libXScrnSaver-1.2.2-6.fc20.x86_64.rpm $ sudo rpm -ivh --nodeps http://dl.fedoraproject.org/pub/archive/fedora/linux/releases/20/Fedora/x86_64/os/Packages/l/libxkbcommon-0.3.1-1.fc20.x86_64.rpm $ sudo rpm -ivh --nodeps http://dl.fedoraproject.org/pub/archive/fedora/linux/releases/20/Fedora/x86_64/os/Packages/l/libwayland-client-1.2.0-3.fc20.x86_64.rpm $ sudo rpm -ivh --nodeps http://dl.fedoraproject.org/pub/archive/fedora/linux/releases/20/Fedora/x86_64/os/Packages/l/libwayland-cursor-1.2.0-3.fc20.x86_64.rpm $ sudo rpm -ivh --nodeps http://dl.fedoraproject.org/pub/archive/fedora/linux/releases/20/Fedora/x86_64/os/Packages/g/gtk3-3.10.4-1.fc20.x86_64.rpm $ sudo rpm -ivh --nodeps http://dl.fedoraproject.org/pub/archive/fedora/linux/releases/16/Fedora/x86_64/os/Packages/gdk-pixbuf2-2.24.0-1.fc16.x86_64.rpm
$ sudo touch /etc/yum.repos.d/google-chrome.repo $ echo -e "[google-chrome]\nname=google-chrome\nbaseurl=http://dl.google.com/linux/chrome/rpm/stable/\$basearch\nenabled=1\ngpgcheck=1\ngpgkey=https://dl-ssl.google.com/linux/linux_signing_key.pub" | sudo tee -a /etc/yum.repos.d/google-chrome.repo $ cat /etc/yum.repos.d/google-chrome.repo [google-chrome] name=google-chrome baseurl=http://dl.google.com/linux/chrome/rpm/stable/$basearch enabled=1 gpgcheck=1 gpgkey=https://dl-ssl.google.com/linux/linux_signing_key.pub
Chromeの安定版をインストールします。
$ sudo yumdownloader google-chrome-stable Loaded plugins: priorities, update-motd, upgrade-helper amzn-main | 2.1 kB 00:00:00 amzn-updates | 2.5 kB 00:00:00 google-chrome | 1.3 kB 00:00:00 4668 packages excluded due to repository priority protections google-chrome-stable-79.0.3945.117-1.x86_64.rpm | 59 MB 00:00:01 $ ls -l google-chrome-stable-79.0.3945.117-1.x86_64.rpm -rw-r--r-- 1 root root 62377282 Jan 6 23:36 google-chrome-stable-79.0.3945.117-1.x86_64.rpm $ sudo rpm -i --nodeps google-chrome-stable-79.0.3945.117-1.x86_64.rpm Error: Could not find xdg-icon-resource warning: %post(google-chrome-stable-79.0.3945.117-1.x86_64) scriptlet failed, exit status 1
xdg-icon-resource
が見つからないという警告が出ますが、ヘッドレスChromeなので構いません。
インストールされたバージョンを確認します。
$ /opt/google/chrome/chrome --version /opt/google/chrome/chrome: /lib64/libdbus-1.so.3: no version information available (required by /usr/lib64/libatk-bridge-2.0.so.0) /opt/google/chrome/chrome: /lib64/libdbus-1.so.3: no version information available (required by /usr/lib64/libatspi.so.0) Google Chrome 79.0.3945.117 unknown
なにやら警告が2行出ていますが、バージョンが出ているのでとりあえずよし。
Googleトップページのスクショを撮ります。
/opt/google/chrome/chrome --headless --disable-gpu --window-size=1024,768 --hide-scrollbars --screenshot='google.png' https://www.google.co.jp/
たくさんメッセージが出ますが、Written to file google.png.
が出てればOK。
PNGファイルを開いてみると、HTTPレスポンスが画像化されています。
とめどなく空が黄昏に染まるだけ
これで文句ないだろう!とyarn test:e2e
を起動すると
「おっとっと、どうやらChromeが起動しなかったみたいだねぇ(ニヤニヤ)」と嫌らしい笑みを浮かべています。
ああ、憎たらしい!
神様、なぜ新年からこのような奴と餅を吸わねばならんのでしょう!
Error: An error occurred while retrieving a new session: "unknown error: Chrome failed to start: exited abnormally"
しかし、エラーメッセージにヒントがありました。
あぁ、神は我を見放したもうたりはしなかった。
ERROR Error: Command failed: /home/ec2-user/environment/test/node_modules/nightwatch/bin/nightwatch --config /home/ec2-user/environment/test/node_modules/@vue/cli-plugin-e2e-nightwatch/nightwatch.config.js --env chrome
/home/ec2-user/environment/test/node_modules/@vue/cli-plugin-e2e-nightwatch/nightwatch.config.js
を見てみると、環境変数からヘッドレスChromeを使うかどうか判断しているようです。
… const startHeadless = process.env.VUE_NIGHTWATCH_HEADLESS === '1' … if (startHeadless) { chromeArgs.push('headless') geckoArgs.push('--headless') } …
この環境変数を設定してみます。
$ export VUE_NIGHTWATCH_HEADLESS=1 $ yarn test:e2e
やっと実行できました!
一発目からこれでは今年のブログライフの先が思いやられますが、これを~/.bashrc
に設定しておけば安心です。
ちなみにテストソースは以下のような感じです。
3秒以内にbodyが現れ、メッセージが一致していることを確認しています。
module.exports = { 'default e2e tests': browser => { browser .init() .waitForElementVisible('#app') .assert.elementPresent('div#life') .waitForElementVisible('body', 3000) .assert.elementPresent('div#life') .assert.containsText( 'div#life ul', 'あなたはアジアの小国の王様に生まれ変わりました。' ) .end() }, }
もっと画面にボタンなどが増えてくれば活躍してくれそうです。
では、本年もご愛顧を賜りますようお願い申し上げます。
そうだ、教祖になろう。出エジプト記 第4章7節 クライアントサイドのユニットテストをカバレッジ100%にする
寝乱れて 隠れ宿
現在、年末年始休みで東北に来ております。
近年は暖冬で雪が少ないものの、川とランデブーする単線の列車は旅情がありますね。
九十九折り 浄蓮の滝
第4章6節 クライアントサイドを自動テストするでJestを導入し、ユニットテストを実施しました。
カバレッジ100%になってなかったので、続きをやっていきます。
別に100%にしないといけないわけじゃないのですが、最初なんで今後のサンプルということで。
まずは、src/store/index.js
をテストするtests/unit/store/index.spec.js
です。
import store from '@/store' import * as types from '@/store/mutation-types' const state = store.state describe('store/index.js', () => { beforeEach(() => { jest.spyOn(console, 'log') jest.spyOn(console, 'error') }) afterEach(() => { console.log.mockRestore() console.error.mockRestore() }) it('正:初期状態', () => { expect(state.list).toEqual([]) }) it('正:list格納', () => { let list = [ 'あなたはアジアの小国の王子に生まれ変わりました。', 'アジアの小国の王子は今から980年前に生まれました。', '民に敬われた王の嫡男として生き', '50歳で死にました。', ] store.commit(types.STORE_LIST, {list: list}) expect(state.list).toEqual(list) }) })
STORE_LIST
をコミットして与えたリストと同じであることをアサーションしています。
console.log()
やconsole.error()
もspyOn
して想定外の出力がないことを確認します。
続いて、src/main.js
をテストするtests/unit/main.spec.js
です。
import axios from 'axios' jest.mock('axios') describe('main.js', () => { beforeEach(() => { jest.spyOn(console, 'log') jest.spyOn(console, 'error') axios.get.mockResolvedValue({}) }) afterEach(() => { console.log.mockRestore() console.error.mockRestore() }) it('正:初期状態', () => { /* eslint-disable no-unused-vars */ var main = require('@/main') expect(console.log).not.toHaveBeenCalled() expect(console.error).toHaveBeenCalledTimes(1) }) })
同じようにconsole.log()
をspyOn
。
Ajax処理が走らないようにaxios.get
モックします。
ま、今のプログラムはLife.vue
以外ほとんど何もしていないのでこんなもんです。
テストを実行します。
めでたくカバレッジ100%になりました。
あーすっきり。
あなたと越えたい 天城越え
もうすぐ年越しですね。
みなさまの令和二年が素晴らしい年になりますように。
そうだ、教祖になろう。出エジプト記 第4章6節 クライアントサイドを自動テストする
Deine Zauber binden wieder,(汝が魔力は再び結び合わせる)
第3章8節 サーバサイドを自動テストするではPythonで書いたLambda処理のユニットテストを作りました。
今回は第4章5節 Veu.jsでクライアントサイドを実装するで実装したVue.jsのコードを自動テストしていきたいと思います。
Vue.jsのユニットテストはJestかMocha+Chaiって感じらしいのですが、よりお手軽なJestを使います。
vue create
がJestをインストールしてくれてtests/unit/sample.spec.js
というテストモジュールができています。
テスト対象のHelloWorld.vue
はすでに消してしまいましたが、試しにテストを起動してみます。
$ yarn test:unit
はい、「んなもんねえよ!」と怒られました。
これを利用してテストモジュールを作ってみます。
名前をtests/unit/Life.spec.js
に変更。
とりあえずで書いたのがこちら。
import axios from 'axios' import Vue from 'vue' import Life from '@/components/Life.vue' import {shallowMount} from '@vue/test-utils' jest.mock('axios') describe('Life.vue', () => { beforeEach(() => { let response = [ 'あなたはアジアの小国の王様に生まれ変わりました。', 'アジアの小国の王様は今から1000年前に生まれました。', '安定した治世で民に敬われながら生き', '40歳で死にました。', ] axios.get.mockResolvedValue(response) }) it('正:疎通', () => { let wrapper = shallowMount(Life, {}) expect(wrapper.find('div#life').exists()).toBe(true) expect(wrapper.find('div#life ul').exists()).toBe(false) }) })
axiosをJestのモックオブジェクトに差し替えてaxios.get
で任意の値を受信するようにしてます。
it()
がテストメソッドですね。
shallowMount
でダミーマウントしたら、生成されたHTMLにタグが存在するかexport().to~()
でアサーションしてます。
beforeEach()
は各テストメソッド実行前に呼ばれるやつです。
これを実行すると、
成功で通りました。
ちなみに、存在しないHTMLタグをアサーションすると、
「そんなのないよー。ぷんぷん。」と優しく怒ってくれます。
実行時になにやら警告が出ています。
(node:5064) UnhandledPromiseRejectionWarning: TypeError: Cannot read property 'commit' of undefined (node:5064) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). (rejection id: 1) (node:5064) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code. PASS tests/unit/Life.spec.js
Veux系の警告のよう。
そういえば、まだデータストアをモック化してませんでした。
import axios from 'axios' import Vuex from 'vuex' import Life from '@/components/Life.vue' import {shallowMount, createLocalVue} from '@vue/test-utils' const localVue = createLocalVue() localVue.use(Vuex) jest.mock('axios') describe('Life.vue', () => { let store let storeList beforeEach(() => { let response = [ 'あなたはアジアの小国の王様に生まれ変わりました。', 'アジアの小国の王様は今から1000年前に生まれました。', '安定した治世で民に敬われながら生き', '40歳で死にました。', ] axios.get.mockResolvedValue(response) storeList = jest.fn() store = new Vuex.Store({ state: { list: [], }, mutations: { STORE_LIST: storeList }, }) }) it('正:疎通', () => { const wrapper = shallowMount(Life, { store, localVue }) expect(storeList).not.toHaveBeenCalled() expect(wrapper.find('div#life').exists()).toBe(true) expect(wrapper.find('div#life ul').exists()).toBe(false) }) })
データストアを丸々モック化しました。
警告が消えました。
Was die Mode streng geteilt;(時流が強く切り離したものを)
ところで、現在のアサーションは画面表示直後を前提にしています。
mounted()
で呼ばれるaxios
のAjax処理を実行する前ですね。
<ul>
タグもなければMutationも呼ばれてません。
expect(storeList).toHaveBeenCalledTimes(0) expect(wrapper.find('div#life ul').exists()).toBe(false)
Ajax処理が実行されたあとの表示もアサーションしましょう。
Vueの$nextTick()
を使います。
HTMLのDOMが変化するまでまってくれます。
import axios from 'axios' import Vuex from 'vuex' import Life from '@/components/Life.vue' import {shallowMount, createLocalVue} from '@vue/test-utils' const localVue = createLocalVue() localVue.use(Vuex) jest.mock('axios') describe('Life.vue', () => { let store let storeList let wrapper let list beforeEach(() => { list = [ 'あなたはアジアの小国の王子に生まれ変わりました。', 'アジアの小国の王子は今から980年前に生まれました。', '民に敬われた王の嫡男として生き', '50歳で死にました。', ] axios.get.mockResolvedValue({data: list}) storeList = jest.fn((state, payload) => { state.list = payload.list }) store = new Vuex.Store({ state: { list: [], }, mutations: { STORE_LIST: storeList, }, }) }) it('正:mounted', done => { wrapper = shallowMount(Life, { store, localVue, }) expect(storeList).not.toHaveBeenCalled() expect(wrapper.find('div#life').exists()).toBe(true) expect(wrapper.find('div#life ul').exists()).toBe(false) wrapper.vm.$nextTick(() => { expect(storeList).toHaveBeenCalledTimes(1) expect(wrapper.find('div#life ul').exists()).toBe(true) for (let i = 0; i < list.length; i++) { expect( wrapper .findAll('div#life ul li') .at(i) .text() ).toEqual(list[i]) } done() }) })
mounted()
がデータストアのlistにAjax通信の結果を格納したのちは<ul>
タグが現れます。
<ul>
配下の<li>
のテキストを1行ずつアサーションしています。
Alle Menschen werden Brüder,(すべての人々は兄弟となる)
トランジションもアサーションしたいのですが一度にやるとテストケースが長すぎるので、afterEnter()
とafterLeave()
を別々にテストします。
2つテストケースを足しました。
it('正:afterEnter', done => { let mounted = jest.spyOn(Life, 'mounted').mockImplementation(() => {}) wrapper = shallowMount(Life, { data() { return { show: true, } }, store, localVue, }) wrapper.vm.afterEnter() setTimeout(() => { expect(storeList).not.toHaveBeenCalled() expect(wrapper.find('div#life ul').exists()).toBe(false) done() }, 3000) }) it('正:afterLeave', done => { let mounted = jest.spyOn(Life, 'mounted').mockImplementation(() => {}) wrapper = shallowMount(Life, { store, localVue, }) wrapper.vm.afterLeave() setTimeout(() => { expect(storeList).toHaveBeenCalledTimes(1) expect(wrapper.find('div#life ul').exists()).toBe(true) for (let i = 0; i < list.length; i++) { expect( wrapper .findAll('div#life ul li') .at(i) .text() ).toEqual(list[i]) } done() }, 2000) })
いずれもjest.spyOn
でmounted()
をモックで抑止しています。
正:afterEnter
ではshow
がtrue
から始めてafterEnter()
を呼んだあと、一定時間待ってから<ul>
タグがないことアサーションしています。
正:afterLeave
ではshow
がfalse
から始めてafterEnter()
を呼んだあと、一定時間まってから<ul>
タグがあるをアサーションしています。
実行すると、カバレッジ100%になりました。
ちなみにさっきからカバレッジを取得できてるのは--coverage
オプションをpackage.json
に入れたからです。
… "scripts": { … "test:unit": "vue-cli-service test:unit --coverage", … }, …
よくみると、実行結果の1か所が赤くなっています。
PASS tests/unit/Life.spec.js (5.866s)
「いいけど、時間かかりすぎ-」ということです。
5秒以上だと怒られるようです。
実際にsetTimeout
の遅延時間分だけ待っているので当然です。
ので、テスト用にsetTimeout
の遅延時間をいじれるようにします。
テスト対象のsrc/components/Life.vue
で、遅延時間をdata
に入れます。
<template> <div id="life"> <transition name="fade" @after-enter="afterEnter" @after-leave="afterLeave"> <ul v-if="show"> <li v-for="msg in list" :key="msg"> {{ msg }} </li> </ul> </transition> </div> </template> <script> import axios from 'axios' import {mapState} from 'vuex' import * as types from '@/store/mutation-types' const AFTER_ENTER_DELAY = 3000 const AFTER_LEAVE_DELAY = 2000 export default { name: 'Life', data() { return { show: false, delayAfterEnter: AFTER_ENTER_DELAY, delayAfterLeave: AFTER_LEAVE_DELAY, } }, computed: { ...mapState(['list']), }, mounted() { this.rebirth() }, methods: { rebirth() { axios.get('/api/rebirth').then(response => { this.$store.commit(types.STORE_LIST, { list: response.data, }) this.show = true }) }, afterEnter() { var _this = this setTimeout(() => (_this.show = false), this.delayAfterEnter) }, afterLeave() { var _this = this setTimeout(() => _this.rebirth(), this.delayAfterLeave) }, }, } </script> <style lang="stylus"> #life padding 10% li list-style-type none .fade-enter-active, .fade-leave-active transition all .8s ease .fade-enter, .fade-leave-to opacity 0 </style>
ほんで、テストモジュールでdata
の値を差し替えつつ、自分の遅延時間も減らします。
it('正:afterEnter', done => { const AFTER_ENTER_DELAY = 30 let mounted = jest.spyOn(Life, 'mounted').mockImplementation(() => {}) wrapper = shallowMount(Life, { data() { return { show: true, delayAfterEnter: AFTER_ENTER_DELAY, } }, store, localVue, }) wrapper.vm.afterEnter() setTimeout(() => { expect(storeList).not.toHaveBeenCalled() expect(wrapper.find('div#life ul').exists()).toBe(false) done() }, AFTER_ENTER_DELAY) }) it('正:afterLeave', done => { const AFTER_LEAVE_DELAY = 20 let mounted = jest.spyOn(Life, 'mounted').mockImplementation(() => {}) wrapper = shallowMount(Life, { data() { return { delayAfterLeave: AFTER_LEAVE_DELAY, } }, store, localVue, }) wrapper.vm.afterLeave() setTimeout(() => { expect(storeList).toHaveBeenCalledTimes(1) expect(wrapper.find('div#life ul').exists()).toBe(true) for (let i = 0; i < list.length; i++) { expect( wrapper .findAll('div#life ul li') .at(i) .text() ).toEqual(list[i]) } done() }, AFTER_LEAVE_DELAY) })
0.1秒程度に縮まりました。
Wo dein sanfter Flügel weilt.(汝の柔らかな翼が留まる所で)
そういえば、カバレッジがコンソールにしか出ていませんでした。
HTMLにも出力してみます。
package.json
に以下を追加します。
"jest": { … "collectCoverage": true, "collectCoverageFrom": ["src/**/*.{js,vue}"], "coverageReporters": ["text", "html"], "coverageDirectory": "<rootDir>/tests/unit/coverage" }
実行すると、対象ソース全体に対してのカバレッジが表示されます。
tests/unit/coverage/index.html
をプレビューすると、フォルダ/ファイルごとのカバレッジ詳細を確認できます。
いやー、やっぱりユニットテストは苦行ですね。
jest.spyOn()
の利便性を思い出すまで3日掛かりました。
今年中にカバレッジを100%にしたいんだけど、あと1日でできるんだろうか。
無理だった場合は次回が来年になります。
みなさま良いお年を。
そうだ、教祖になろう。出エジプト記 第4章5節 Veu.jsでクライアントサイドを実装する
Freude, schöner Götterfunken,(歓喜よ、美しき光よ)
仕事納めも済ませ、すっかり年末感が強くなりました。
第4章4節 ESLint設定を整えるでリント環境を整えたので、今回は歓喜のうちにクライアントサイドをコーディングしていきます。
軽くコンポーネントを整理しました。
AboutとFaqは後々やるとして、メインコンテンツを実装していきます。
ソースツリーはこちら。
デフォルトのソースはさっぱり消しました。
今年の汚れは今年のうちに。
$ tree src src ├── App.vue ├── components │ └── Life.vue ├── main.js ├── router │ └── index.js ├── store │ └── index.js └── views └── Rebirth.vue $ tree public public ├── api │ └── rebirth ├── favicon.ico └── index.html
Tochter aus Elysium,(楽園エリジウムの娘よ)
まず、サーバサイドがないのでダミーのレスポンスを作ります。
public/api/rebirth
です。
[ "あなたはアジアの小国の王様に生まれ変わりました。", "アジアの小国の王様は今から1000年前に生まれました。", "安定した治世で民に敬われながら生き", "40歳で死にました。" ]
これを読んで表示するsrc/components/Life.vue
です。
複数行をv-for
で繰り返し表示しています。
<template> <div id="life"> <ul> <li v-for="msg in list" :key="msg"> {{ msg }} </li> </ul> </div> </template> <script> import axios from 'axios' export default { name: 'Life', data() { return { list: [], } }, mounted() { axios.get('/api/rebirth').then(response => (this.list = response.data)) }, } </script> <style lang="stylus"> #life padding 10% li list-style-type none </style>
その外側のビューsrc/views/Rebirth.vue
です。
<template> <div class="rebirth"> <Life /> </div> </template> <script> import Life from '@/components/Life.vue' export default { name: 'Rebirth', components: { Life, }, } </script>
src/router/index.js
はさっぱり他のリンクを削除。
import Vue from 'vue' import VueRouter from 'vue-router' Vue.use(VueRouter) const routes = [ { path: '/', name: 'rebirth', component: () => import('@/views/Rebirth.vue'), }, ] const router = new VueRouter({ mode: 'history', base: process.env.BASE_URL, routes, }) export default router
src/App.vue
で統合。
<template> <div id="app"> <router-view /> </div> </template> <style lang="stylus"> #app font-family 'Avenir', Helvetica, Arial, sans-serif -webkit-font-smoothing antialiased -moz-osx-font-smoothing grayscale text-align center color #2c3e50 margin-top 60px </style>
他は変えていません。
プレビューしてみます。
表示されました。
Wir betreten feuertrunken,(我らは炎に酔って踏み入る)
これでもいいのですが、データが一つのコンポーネントに閉じられているので、他コンポーネントで使うときに不便です。
クライアントサイドのデータストアを一元管理して各コンポーネントで利用できるようにしたいと思います。
Vuexで実現します。
Vuexでは最低限、StateとMutationを定義します。
Stateはデータです。
各コンポーネントのdata
と同じと思っていいでしょう。
動的に<template>
配下に反映されます。
Mutationはデータの変更処理です。
変更処理をVuexモジュール内に閉じ込めてカプセル化します。
まず、Mutationの種別を定義します。
新規にsrc/store/mutation-types.js
を作成します。
export const STORE_LIST = 'STORE_LIST'
次にsrc/store/index.js
で実際のStateとMutationを定義します。
state
は単純なリストであるlist
のみ。
mutation
は変数を受けてstate.list
に代入する関数がひとつ。
import Vue from 'vue' import Vuex from 'vuex' import * as types from '@/store/mutation-types' Vue.use(Vuex) export default new Vuex.Store({ state: { list: [], }, mutations: { [types.STORE_LIST](state, payload) { state.list = payload.list }, }, actions: {}, modules: {}, })
最後にデータストアを参照するsrc/components/Life.vue
です。
直にdata
で定義していたlist
を、データストアのlist
からmapState
で取得するよう変更しています。
また、レスポンス受信時の処理はthis.$store.commit
でMutationを起動しています。
<template> <div id="life"> <ul> <li v-for="msg in list" :key="msg"> {{ msg }} </li> </ul> </div> </template> <script> import axios from 'axios' import {mapState} from 'vuex' import * as types from '@/store/mutation-types' export default { name: 'Life', computed: { ...mapState(['list']), }, mounted() { axios.get('/api/rebirth').then(response => { this.$store.commit(types.STORE_LIST, { list: response.data, }) }) }, } </script> <style lang="stylus"> #life padding 10% li list-style-type none </style>
これでさきほどと同じ結果が得られるのですが、試しに他のコンポーネントでもデータストアを参照してみます。
src/views/Rebirth.vue
でlist
の件数を表示してみましょう。
<template> <div class="rebirth"> <Life /> line: {{ list.length }} </div> </template> <script> import {mapState} from 'vuex' import Life from '@/components/Life.vue' export default { name: 'Rebirth', components: { Life, }, computed: { ...mapState(['list']), }, } </script>
Vuexをインポートして、mapState
でlist
を取得し、list.length
で件数を表示しています。
プレビューすると複数コンポーネントでlist
を参照できていることを確認できます。
Himmlische, dein Heiligtum!(天なるもの、汝の聖所へ)
これでは一度読み込んで終わりなので、メッセージがゆっくり現れて消えるようアニメーションをつけたいと思います。
Vue.jsのトランジションを使います。
トランジションでは現れるアニメーションをEnterで、消えるアニメーションをLeaveで定義します。
アニメーション開始時点のEnter/Leaveから、終了時点のtoに至る過程がアニメーションを行う期間activeです。
試しにスライドで現れるEnterだけ定義してみます。
<ul>
全体の表示を制御するshow
をMutationコミット後にtrue
にします。
アニメーション開始時点は透明度100%で右に10pxずれた状態、0.8秒で正規の透明度と位置に遷移します。
<template> <div id="life"> <transition name="slide-fade"> <ul v-if="show"> <li v-for="msg in list" :key="msg"> {{ msg }} </li> </ul> </transition> </div> </template> <script> import axios from 'axios' import {mapState} from 'vuex' import * as types from '@/store/mutation-types' export default { name: 'Life', data() { return { show: false, } }, computed: { ...mapState(['list']), }, mounted() { axios.get('/api/rebirth').then(response => { this.$store.commit(types.STORE_LIST, { list: response.data, }) this.show = true }) }, } </script> <style lang="stylus"> #life padding 10% li list-style-type none .slide-fade-enter-active transition all .8s ease .slide-fade-enter transform translateX(10px) opacity 0 </style>
次にEnterのアニメーションが終わってから一定時間経過後にLeaveのアニメーションを開始したいと思います。
<transition>
タグに@after-enter
属性をつけました。
Enterのアニメーションが終わったら、methods
に追加したafterEnter
を呼び出されてshow
をfalse
にするとLeaveアニメーションが始まります。
同じくLeaveのアニメーションが終わったら、methods
のrebirth
に移動したAjax処理を呼び出します。
これで延々と表示・非表示を繰り返すようになりました。
transform translateX(10px)
は消しました。
<template> <div id="life"> <transition name="fade" @after-enter="afterEnter" @after-leave="afterLeave" > <ul v-if="show"> <li v-for="msg in list" :key="msg"> {{ msg }} </li> </ul> </transition> </div> </template> <script> import axios from 'axios' import {mapState} from 'vuex' import * as types from '@/store/mutation-types' export default { name: 'Life', data() { return { show: false, } }, computed: { ...mapState(['list']), }, mounted() { this.rebirth() }, methods: { rebirth() { axios.get('/api/rebirth').then(response => { this.$store.commit(types.STORE_LIST, { list: response.data, }) this.show = true }) }, afterEnter() { var _this = this setTimeout(() => (_this.show = false), 3000) }, afterLeave() { var _this = this setTimeout(() => _this.rebirth(), 2000) }, }, } </script> <style lang="stylus"> #life padding 10% li list-style-type none .fade-enter-active, .fade-leave-active transition all .8s ease .fade-enter, .fade-leave-to opacity 0 </style>
基本の動きはできました。
まだ画像や凝ったアニメーションはありませんが、やり出すと時間ばかりかかるので、一旦この辺で。
次回はクライアントサイドの自動テストを考えたいと思います。
そうだ、教祖になろう。出エジプト記 第4章4節 ESLint設定を整える
我々の街と塔を作ろう。塔の先が天に届くほどの。
これまで第4章1節 Cloud9にVue.js開発環境を導入するなどでトランスパイラに触れました。
Vue.jsのトランスパイラは「Babel」です。
BabelはJavaScriptトランスパイラで、本家サイトでは「次世代JavaScriptのコンパイラ」と説明されています。
"babble"と発音するらしいです。
見よ、その塔はシナルの地、アッシリアとバビロンの中間にあった。
第4章3節 Vue.jsでAjax通信するで出たWarningを解消するのに、このBabelが関係してきます。
Vue.jsプロジェクトをトランスパイルする前にESLintというリンタを使います。
リンタとは、プログラムソースがコーディング規約に則しているかチェックするツールです。
vue create プロジェクト名
でVue CLIを導入したときにESLint + Prettierを導入しました。
Prettierはコードフォーマッターです。
登場人物が多いですね。
Prettierはコーディング規約に沿うようにコードを整形してくれます。
ESLintもオプションでコードを整形できますが、Prettierの方が簡単により見やすくしてくれるといったところです。
とりあえずPrettierのことはおいといて、ESLintを使ってWarningが出ないようにコードを修正していきます。
参考にさせていただいたサイトです。
.vue
ファイルでESLintが利くようにeslint-config-vue
パッケージを導入します。
参考サイトにある他のパッケージはvue create
で入っているようです。
$ cd clientside/ $ yarn add -D eslint-config-vue $ ./node_modules/.bin/eslint src/**/*.js src/**/*.vue
デフォルト設定だとこんな感じの結果になります。
設定をカスタマイズするため、プロジェクト直下に'.eslintrc.js`というファイルを作ります。
Vueの推奨設定を読み込みます。
module.exports = { "extends": ["vue", "plugin:vue/recommended"], "env": { "browser": true }, "rules": {} }
これで走らせると80個ほどエラーと警告が出ます。
1つずつエラーを確認しながら修正したいので、今出たエラーと警告の右側に表示されているルール名を全部”rules”
に"off"
で追加します。
module.exports = { "extends": ["vue", "plugin:vue/recommended"], "env": { "browser": true }, "rules": { "indent": "off", "semi": "off", "space-in-parens": "off", "quotes": "off", "vue/html-closing-bracket-newline": "off", "vue/html-indent": "off", "vue/html-self-closing": "off", "vue/max-attributes-per-line": "off", "vue/name-property-casing": "off", "vue/require-default-prop": "off", } }
一回走らせます。
まだ1つ出てます。
これは”rules”
では消せないのでbabel-eslintで抑止します。
babel-eslintはESLintのためのBabel Parserのラッパーです。
Babel ParserはBabelの中にあるJavaScriptパーサーで、昔はBabylonと呼ばれていたそうです。
いよいよ混乱してきましたね。
"parserOptions"
を追加してもう一回走らせます。
module.exports = { … "parserOptions": { "parser": "babel-eslint", }, … }
babel-eslintはデフォルトで"allowImportExportEverywhere": false
になっており、Import
に対するエラーが消えます。
これで一旦エラーがない状態になりました。
その為に、この街はバベルと名付けられた。
では、ルールを1つずつ修正していきたいと思います。
デフォルト以外にしたい設定は"off"
の部分を修正したい設定値に変えていきます。
インデントは2にしてみます。
"indent": "indent": ["error", 2],
リントします。
$ ./node_modules/.bin/eslint src/**/*.js src/**/*.vue
17か所エラーになってますね。
--fix
オプションをつけて修正します。
$ ./node_modules/.bin/eslint --fix src/**/*.js src/**/*.vue
これが、
... const routes = [{ path: "/", name: "home", component: Home }, ...
こうなりました。
... const routes = [{ path: "/", name: "home", component: Home }, ...
続いて、セミコロンをなしにします。
"semi": ["error", "never"],
import Vue from "vue"; import VueRouter from "vue-router"; import Home from "../views/Home.vue";
↓
import Vue from "vue" import VueRouter from "vue-router" import Home from "../views/Home.vue"
どんどんいきます。
好き嫌いが分かれるでしょうが、複数行の場合の末尾のカンマを必須にします。
"comma-dangle": ["error", "always-multiline"],
export default new Vuex.Store({ state: {}, mutations: {}, actions: {}, modules: {} })
↓
export default new Vuex.Store({ state: {}, mutations: {}, actions: {}, modules: {}, })
()
の内側のスペースなしはデフォルトなのでこの行は消します。
"space-in-parens": "off",
component: () => import ( /* webpackChunkName: "about" */ "../views/About.vue"),
↓
component: () => import (/* webpackChunkName: "about" */ "../views/About.vue"),
クォーテーションはダブルクォーテーション。
"quotes": ["error", "double"],
これはエラーなし。シングルクォーテーションはなかったようです。
ここから.vue
ファイルのHTML部分の規則です。
HTMLの>
位置はデフォルトなので削除。
"vue/html-closing-bracket-newline": "off",
<a href="https://cli.vuejs.org" target="_blank" rel="noopener" >vue-cli documentation</a >.
↓
<a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.
HTML属性位置はデフォルトなので削除。
"vue/max-attributes-per-line": "off",
<a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.
↓
<a href="https://cli.vuejs.org" target="_blank" rel="noopener" >vue-cli documentation</a>.
HTMLのインデントもデフォルトで削除。
"vue/html-indent": "off",
<li> <a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener" >babel</a> </li>
↓ 微妙!(>
が2つ上がってる)
<li> <a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener" >babel</a> </li>
HTMLタグの閉じタグ許可。
"vue/html-self-closing": ["error", { "html": { "normal": "always", "void": "always", "component": "always" }, }],
これもエラーなし。
プロパティ名はデフォルト(キャメルケース)なので削除。
"vue/name-property-casing": "off",
name: "ajax",
↓
name: "Ajax",
プロパティのrequired属性必須はデフォルトなので削除。
"vue/require-default-prop": "off",
props: { msg: String }
あれ、これは--fix
で修正されないようです。
手で修正。
msg: { type: String, required: true }
最終的に.eslintrc.js
はこうです。
module.exports = { "extends": ["vue", "plugin:vue/recommended"], "env": { "browser": true }, "parserOptions": { "parser": "babel-eslint", }, "rules": { "comma-dangle": ["error", "always"], "indent": ["error", 2], "semi": ["error", "never"], "quotes": ["error", "double"], "vue/html-self-closing": ["error", { "html": { "normal": "always", "void": "always", "component": "always" }, }], } }
主がそこで、全地の言葉を乱し、そこから人を全地に散らされたからである。
手間はかかりましたが一応設定が整いましたね。
では改めてsrc/router/index.js
をいじって保存すると、
おっと、設定と異なるインデントで自動フォーマットされてエラーが大量発生しますね。
あれ、これ涙?
Cloud9のJSBeutify設定とESLintの設定が合っていないからっぽいです。
ので、保存時にESLintを使ってフォーマットしてくれるように変更します。
PreferenceのProject Setting > JavaScript Support > Custom Code Formatterの欄に以下のコマンドを設定します。
保存したファイルのディレクトリに下りてって、eslint --fix
を実行するコマンドです。
npmのパッケージ実行コマンドであるnpxで起動しています。
うまく動いてないときのトレースのためにホームディレクトリにログファイルを吐いてます。
cd `dirname "$file"`;npx eslint --fix `basename "$file"` &> ~/eslint.log
これでsrc/router/index.js
を保存しなおすと、エラーがなくなりました。
npxを起動するのが遅いのか、若干タイムラグがあるのが気になります。
Cloud9にJavaScriptと認識されてるファイルでしかフォーマッタのトリガがかからないので、.vue
ファイルのエディタの右下にあるファイル種別を「JavaScript」に変更します。
これで保存時に.vue
ファイルもフォーマットされますが、JavaScriptファイルには存在しない<script>
のあたりでParsing error
が発生します。
ので、Hints&Warning > Ignore Messages Matchingの欄にParsing error
と入力して無視します。
これやっちゃうと例えば<script>
が抜けたりしてもコーディング時点では分からないのですが、ビルド時点で気づけるのでまあよしとしましょう。
ついでに、Mark missing semicolonsを外してエディタの横に出ている「i」マークが表示されないようにします。
見よ、人の子らはシナルの地に自分たちの都市と塔を建てようというそのふとどきなはかりごとのゆえに邪悪になった。
なんとかESLintの設定を整えてきたのですが、はっきり言って結構手間です。
Prettierだともちょっと楽に設定できるようで、今後のためにPrettier設定をESLintに読ませる形に全面見直したいと思います。
つまり、今の設定はほぼ全部壊します。
大変参考になりました。
修正した.eslintrc.js
がこれ。
ESLint、Prettier、Vueの推奨を利用。
module.exports = { parser: 'vue-eslint-parser', parserOptions: { parser: 'babel-eslint', sourceType: 'module', }, env: { browser: true, node: true, }, extends: [ 'eslint:recommended', 'plugin:prettier/recommended', 'plugin:vue/recommended', 'prettier/vue', ], rules: {}, }
新規作成したPrettierの設定である.prettierrc.js
がこれ。
シングルクォーテーションにしました。
module.exports = { printWidth: 80, tabWidth: 2, singleQuote: true, semi: false, trailingComma: 'es5', bracketSpacing: false, }
上記の2ファイルも整形できるよう、PreferenceのCustom Code Formatterに--ignore-pattern
オプションを追加しました。
デフォルトだと対象外のファイルもリントしてくれます。
cd `dirname "$file"`;npx eslint --fix `basename "$file"` --ignore-pattern '!.*.js' &> ~/eslint.log
苦労しましたが、これでやっとESLint設定が整いました。
次回はHTMLをスッキリ書けるPugを入れたかったので、またフォーマッタ設定を変えないといけないんかいとビクビクでしたが、色々サポートが利かなくなるし、特段コーディング量も減らないし、あまりメリットがなさそうなのでやめておこうと思います。
2019/12/27追記
Pugをちょっと入れてみましたが、eslint --fix
が利かないようで断念しました。残念。
yarn add -D pug pug-plain-loader eslint-plugin-pug
plugins: ['pug'],
<template> <div class="home"> <img alt="Vue logo" src="../assets/logo.png" /> <HelloWorld msg="Welcome to Your Vue.js App" /> </div> </template>
↓
<template lang="pug"> div.home img(alt="Vue logo" src="../assets/logo.png" HelloWorld(msg="Welcome to Your Vue.js App") </template>
そうだ、教祖になろう。出エジプト記 第4章3節 Vue.jsでAjax通信する
Аксиос!(適任!)
第2章2節 CloudFrontでAPI Gatewayを同一ドメイン化するでjQueryを利用してサーバとAjax通信しました。
Vue.jsではaxiosというライブラリを使います。
Wikipediaによると
アクシオスは元々はギリシャ語で「価値が有る」「ふさわしい」「値する」の意。新約聖書にも複数個所に使われている。正教会では新たに聖職者が叙聖される時に使われる事から「適任」という訳が当てられることもある。
ということです。
このaxiosがそっから来てるのかはわかりませんが。
動きを確認してみましょう。
axiosをインストールします。
$ yarn install -D axios
サーバサイド処理の代わりに静的ファイルを置いてみます。
public
の下にtest.json
を作ります。
{ "key1": "value1", "key2": "value2" }
src/views/Home.vue
をコピーしてsrc/views/Ajax.vue
を作ります。
データのinfo
を<div>
タグにバインドしています。
画面が読み込まれたときにtest.json
を取得するようにしてみます。
<template> <div> {{ info }} </div> </template> <script> import axios from "axios" export default { name: "ajax", data () { return { info: null } }, mounted () { axios .get("/test.json") .then(response => (this.info = response)) } } </script>
src/router/index.js
にAjax.vue
の設定を追加します。
{ path: "/ajax", name: "ajax", component: () => import ("../views/Ajax.vue") }
最後に src/App.vue
にリンクを追加します。
<router-link to="/ajax">Ajax</router-link>
画面を確認してみます。
「Ajax」リンクをクリックすると、
レスポンスのステータス200
のヘッダとボディが<div>
タグの中に表示されました。
動いたには動いたんですが、ビルドの際にWarningが出てしまいます。
次回はこれを何とかしたいと思います。
そうだ、教祖になろう。出エジプト記 第4章2節 Vue.jsを思い出す
いつのことだか 思い出してごらん
第4章1節 Cloud9にVue.js開発環境を導入するでは
vue create プロジェクト名
でVue.jsを導入しました。
色々入ってます。
Vue.jsに触ったのは2年前でどういう構造になっていたのか忘れてしまいましたので、おさらいしてみましょう。
ディレクトリ構成はこんな感じです。
あんなこと こんなこと あったでしょう
1ファイルずつ見ていきましょう。
public/index.html
public配下はトランスパイルされない、そのまま静的コンテンツになるソースです。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width,initial-scale=1.0"> <link rel="icon" href="<%= BASE_URL %>favicon.ico"> <title>clientside</title> </head> <body> <noscript> <strong>We're sorry but clientside doesn't work properly without JavaScript enabled. Please enable it to continue.</strong> </noscript> <div id="app"></div> <!-- built files will be auto injected --> </body> </html>
<div id="app"></div>
の部分にビルドされたJavaScriptがバインドされます。
src/main.js
import Vue from "vue"; import App from "./App.vue"; import router from "./router"; import store from "./store"; Vue.config.productionTip = false; new Vue({ router, store, render: h => h(App) }).$mount("#app");
index.html
の#app
にApp.uve
をバインドするメインのJavaScriptです。
Vue Routerのrouter
とVuexのstore
を読み込んでいます。
src/App.vue
<template> <div id="app"> <div id="nav"> <router-link to="/">Canaan</router-link> | <router-link to="/about">Egipt</router-link> </div> <router-view /> </div> </template> <style lang="stylus"> #app font-family 'Avenir', Helvetica, Arial, sans-serif -webkit-font-smoothing antialiased -moz-osx-font-smoothing grayscale text-align center color #2c3e50 margin-top 60px </style>
.vue
ファイルの構成です。
<template>
でHTML、<script>
でJavaScript、<style>
でCSSを定義します。
このファイルがJavaScriptを使ってないんで<script>
はありません。
<template>
のrouter-link
はVue Routerのパスです。
Vue Routerは、SPAでサーバとの通信なしで画面を切り替える仕組みです。
ビルドすると<a>
タグになります。
選択したパスに対応するコンポーネントが<router-view />
に表示されます。
パスやコンポーネントはsrc/router/index.js
で定義されます。
<style>
はStylusで定義されます。
CSSメタ言語のStylusはトランスパイスされてCSSになります。
直接CSSを書くときの{}
や;
が不要で、変数を使えたりもします。
src/router/index.js
import Vue from "vue"; import VueRouter from "vue-router"; import Home from "../views/Home.vue"; Vue.use(VueRouter); const routes = [ { path: "/", name: "home", component: Home }, { path: "/about", name: "about", // route level code-splitting // this generates a separate chunk (about.[hash].js) for this route // which is lazy-loaded when the route is visited. component: () => import(/* webpackChunkName: "about" */ "../views/About.vue") } ]; const router = new VueRouter({ mode: "history", base: process.env.BASE_URL, routes }); export default router;
Vue Routerのパスとコンポーネントの対応を定義します。
"/"
に対してはHome
、"/about"
に対しては"../views/About.vue"
を動的にインポートしています。
SPAでは画面を切り替えないのでブラウザの履歴機能をシミュレートするためにのmode: "history"
を指定しています。
スライドショー作るだけなら要らなかったかな。
まあ使わなかったら消しましょう。
src/store/index.js
import Vue from "vue"; import Vuex from "vuex"; Vue.use(Vuex); export default new Vuex.Store({ state: {}, mutations: {}, actions: {}, modules: {} });
VuexのStateやActionを定義します。
本家によると「 Vuex は Vue.js アプリケーションのための 状態管理パターン + ライブラリです。」
SPAだとJavaScriptでデータを保持していろんなトリガで画面に反映するので、ばらばらに実装するとすぐにコードがスパゲッティになります。
ので、一元的なデータストアから画面に直接反映させる仕組みです。
ちょっとよくわからないので、実装するときに詳しく見ていきましょう。
src/view/Home.vue
<template> <div class="home"> <img alt="Vue logo" src="../assets/logo.png" /> <HelloWorld msg="Welcome to Your Vue.js App" /> </div> </template> <script> // @ is an alias to /src import HelloWorld from "@/components/HelloWorld.vue"; export default { name: "home", components: { HelloWorld } }; </script>
Vue Routerで"/"
が選択されたときに読み込まれるコンポーネントです。
子コンポーネントのHelloWorld
をインポートして<HelloWorld>
タグとして配置しています。
引数としてmsg
を渡してます。
src/components/HelloWorld.vue
<template> <div class="hello"> <h1>{{ msg }}</h1> (略) </div> </template> <script> export default { name: "HelloWorld", props: { msg: String } }; </script> <!-- Add "scoped" attribute to limit CSS to this component only --> <style scoped lang="stylus"> (略) </style>
引数のmsg
を受け取って<h1>
タグに埋め込んでます。
大体の構造は思い出した気がしますので、次回から実装に移っていきたいと思います。