背景
前回の記事にて、web上に自分のアプリを公開してみました。
そんな中、ちょっと不具合とかも見つかり修正したいと思いました。操作ステップの順番が多岐に渡るため、何か修正した時に、今まで動いていた操作順番での動作が崩れるのは避けたい所です。
既存の仕様をちゃんと定義しておいて、今後機能を追加する時でもそれを崩さない様にしながら行いたい所です。そんな頻繁にこのアプリに手を加える訳でもないので、時間が空いてしまった時などに仕様を思い出すのに時間がかかるというのも避けたい所です。
TDDをやるべきという事になります。
今回の記事の前提技術
- yarn
- Vue3
- Vuetify(2022年5月14日現在、β版です)
テスト実行準備
まず、Unitテストをを実行出来る様にするまで準備します。
jestインストール
Vueの公式ページではjestを使うのが一番シンプルだと言っています。
vueのプロジェクト作成(vue create )時、以下の様な選択肢が出てきたかと思いますが、作成当時Defaultを選択していると、test用フレームワークは入らない様です。プロジェクト作成時に指定するのが一番無難なはずなので、この記事を見た方でこれからVueプロジェクトを作成する場合にはシンプルにそこでtest用フレームワークを選択した方が良いと思います。
Vue CLI v5.0.4
? Please pick a preset: (Use arrow keys)
❯ Default ([Vue 3] babel, eslint)
Default ([Vue 2] babel, eslint)
Manually select features
公式ページ通りにやってい行きます。
yarn add --dev @vue/test-utils@next
vue-jestのVer5を使うのがよいという事ですが、Vueの公式ページのリンクから飛べるvue-jestのgitリポジトリによると最新のjestではパッケージ名が@vue/vue3-jest
となる様です。
yarn add @vue/vue3-jest --dev
これだけだと、テスト実行用コマンドが実行出来ません。ダミープロジェクト作成時にManually select features
を選択すると、機能が選べるのでUnit Testing
を選択、Pick a unit testing solution:
でJest
を選択します。そうして出来たプロジェクトのpackage.json
をみるといくつか必要なモジュールがありそうだったので、それをインストールします。
yarn add @types/jest @vue/cli-plugin-unit-jest babel-jest jest ts-jest --dev
ここまででyarn jest
が実行出来る様になりました。テストケースが無くてエラーになってるという状態ですね。
yarn run v1.22.18
$ vue-cli-service test:unit
No tests found, exiting with code 1
Run with `--passWithNoTests` to exit with code 0
In /vue/cubetrain
17 files checked.
testMatch: **/__tests__/**/*.[jt]s?(x), **/?(*.)+(spec|test).[tj]s?(x) - 0 matches
testPathIgnorePatterns: /node_modules/ - 17 matches
testRegex: - 0 matches
Pattern: - 0 matches
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
メッセージからすると正規表現**/__tests__/**/*.[jt]s?(x), **/?(*.)+(spec|test).[tj]s?(x)
に合致するファイルがテストの対象となる事が解ります。
テストケース追加
公式ページにある下記内容でsrc/components/__tests__/SampleTest.ts
ファイルを追加します。
その後、yarn jest
を実行します。
import { mount } from '@vue/test-utils'
// The component to test
const MessageComponent = {
template: '<p>{{ msg }}</p>',
props: ['msg']
}
test('displays message', () => {
const wrapper = mount(MessageComponent, {
props: {
msg: 'Hello world'
}
})
// Assert the rendered text of the component
expect(wrapper.text()).toContain('Hello world')
})
エラー解消
エラーが出てしまったので解消していきます。
● displays message
The error below may be caused by using the wrong test environment, see https://jestjs.io/docs/configuration#testenvironment-string.
Consider using the "jsdom" test environment.
ReferenceError: document is not defined
8 |
9 | test('displays message', () => {
> 10 | const wrapper = mount(MessageComponent, {
| ^
11 | props: {
12 | msg: 'Hello world'
13 | }
at mount (node_modules/@vue/test-utils/dist/vue-test-utils.cjs.js:7840:14)
at Object.<anonymous> (src/components/__tests__/WasmScreenTest.ts:10:19)
package.jsonの修正
先ほどと同様に、最初からjestを導入したダミープロジェクトを調べると、以下の差分がpackage.json
にあったので追加してみます。
{
//中略
"jest": {
"preset": "@vue/cli-plugin-unit-jest/presets/typescript-and-babel"
}
//中略
}
jestの公式ページによるとjest用に事前準備された設定群の様です。名前からしてもVueでjestを使う為の設定セットという事になると思います。これを追記しておく必要があるという事ですね。
再度yarn jest
を実行すると違うエラーが出るようになりました。確かにVSCode上でもtestとexpectには赤下線が出ていました。
● Test suite failed to run
src/components/__tests__/WasmScreenTest.ts:9:1 - error TS2593: Cannot find name 'test'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha` and then add 'jest' or 'mocha' to the types field in your tsconfig.
9 test('displays message', () => {
~~~~
src/components/__tests__/WasmScreenTest.ts:17:3 - error TS2304: Cannot find name 'expect'.
17 expect(wrapper.text()).toContain('Hello world')
~~~~~~
tsconfig.jsonの修正
もう少し先ほどのダミープロジェクトとの差分を探ってみるとtsconfig.jsonにjestに関係ある差分があったので追加してみます。
{
"compilerOptions": {
"target": "esnext",
// 中略
"baseUrl": ".",
"types": [
"webpack-env",
"jest" // この行を追加
],
TypeScriptの公式ページの説明によるとTypeScriptでnode_modules/@types
配下のファイルを読み込んでおくための設定の様です。これも追記しておく必要があるという事ですね。
再度yarn jest
を実行した所成功しました。
# yarn jest
yarn run v1.22.18
$ vue-cli-service test:unit
PASS src/components/__tests__/SampleTest.ts
✓ displays message (20 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 2.752 s, estimated 5 s
Ran all test suites.
Done in 4.21s.
テスト技術要素確認
テスト実行が成功する様になったので、作り方を確認していきます。
サンプル確認
jestを初期導入しているダミープロジェクトでは、テストケースもついています。その中身を見ていきます。先ほど使ったテストと書き方が違う部分があります。
import { shallowMount } from '@vue/test-utils'
import HelloWorld from '@/components/HelloWorld.vue'
describe('HelloWorld.vue', () => {
it('renders props.msg when passed', () => {
const msg = 'new message'
const wrapper = shallowMount(HelloWorld, {
props: { msg }
})
expect(wrapper.text()).toMatch(msg)
})
})
describe
テストケースを含有している形で記述されています。
jestの公式ページによると、必須ではないが、グルーピングするのに便利との事です。ネストする事も出来たり、describe.eachなど派生関数?もあり同じテストを繰り返す時とかに便利そうです。最低でもコンポーネント毎に一つのdescribeでまとめておいた方が良さそうです。
it
同じくjest公式ページによると、test
の別名との事です。testは別名という形の記述なので、it
の方が主流なのかな、と思います。
基本命令確認
UIのテストをする上で、どんな事が出来るのか確認します。ポイントとしては以下の点になるかと思います。
- UIのどんなイベントを発生させることが出来るのか
- その結果としてどんな情報を取得する事が出来るのか
- 予測と結果の比較ではどんな関数が用意されているのか
Vue公式ページの説明から読み取っていきます。
import { mount } from '@vue/test-utils'
import TodoApp from './TodoApp.vue'
test('creates a todo', () => {
const wrapper = mount(TodoApp)
expect(wrapper.findAll('[data-test="todo"]')).toHaveLength(1)
wrapper.get('[data-test="new-todo"]').setValue('New todo')
wrapper.get('[data-test="form"]').trigger('submit')
expect(wrapper.findAll('[data-test="todo"]')).toHaveLength(2)
})
イベント発火前
mount
mountを使う事で、コンポーネントのレンダリングをする事が出来る様です。
要素取得(findAll、getなど)
Vue公式ページ - Wrapper methodsを見ると、findAll、getなどの関数でhtml要素の取得が出来る様です。
要素への情報設定(setValue)
Vue公式ページ - Wrapper methods - setValueを見ると、先ほどの要素取得関数でDOM要素を取得した後、そのDOM要素に値を設定する事が出来る様です。
初期Vue属性設定(data、props)
VueではDOM要素に直接値を設定するのでなく、propsやdataで情報をセットする事が多いと思います。
同、data、propsを使うとそれらの値設定が可能な様です。
Vue属性設定(setData、setProps)
前述の初期設定だけでなく、レンダリングした後にもそれらの値を変更するケースがあると思います。
同、setData、setPropsを使うとそれらの値設定が可能な様です。
イベント発火(trigger)
同、triggerメソッドで各種イベントを発生させるようです。wrapperとして各種命令が用意されているのではなく、trigger関数の引数で発生させるイベントを指定する形ですね。
結果取得
一通り見ると以下の手法が用意されている様です。
DOM要素属性(attrs)
DOM要素の選択は値セットの時に出てきましたが、その属性値を取得してテストする事が出来る様です。
同attrsで行う事になると思います。
可視性(isVisible)
同isVisibleで、指定要素が見えるかのチェックが行える様です。
文字列(html、text)
同html、textで、指定要素のhtml文字列やtextのチェックが行えるようです。
Vue属性
ボタンを押してdataなどが変わるケースがあると思いますが、それを直接チェックするメソッドは見た所なさそうです。そのdataによりレンダリングが変更されたDOM要素の各種要素をチェックする方向になりそうです。もしくは公式ページで説明されてる「vm」を上手く使う事になりそうです。
結果チェック(expect)
jestの公式ページに各種関数のリストがありました。サンプルで使用されていたtoHaveLength
も記載されています。
テスト作成
当然ながら、UIのテストをするのに十分な命令がそろっていると思いました。自分の作ったコンポーネントに対してテストケースを作成してみます。実際にはいろいろなケースを作りますが、この記事では以下のテストをするテストケースを作りたいと思います。
- 指定してある手数に基づいてスクランブルした時、操作履歴数がその手数と同じになるか
- 3手戻した時、内部で持っているステータスが3手分戻っているか
実行エラー解消
使ってるコンポーネントと導入してテスト作成したら、以下のエラーが出てしまいました。先ほどのダミープロジェクトからソースとテストケースを持ってきても同じエラーが出ます。ネットを調べると、バージョンの組み合わせが関係していそうです。そういえば必要モジュールをインストールした時、微妙な順番になっていたと思います。
● Test suite failed to run
TypeError: babelJest.getCacheKey is not a function
at Object.getCacheKey (node_modules/@vue/vue3-jest/lib/index.js:13:19)
at ScriptTransformer._getCacheKey (node_modules/@jest/transform/build/ScriptTransformer.js:281:41)
at ScriptTransformer._getFileCachePath (node_modules/@jest/transform/build/ScriptTransformer.js:352:27)
at ScriptTransformer.transformSource (node_modules/@jest/transform/build/ScriptTransformer.js:595:32)
at ScriptTransformer._transformAndBuildScript (node_modules/@jest/transform/build/ScriptTransformer.js:765:40)
at ScriptTransformer.transform (node_modules/@jest/transform/build/ScriptTransformer.js:822:19)
2022年5月15日現在で、先ほどのダミープロジェクトのpackage.jsonでのバージョンを参考に、使用している側のpakage.jsonのバージョンを以下の通りに修正しました。まだVue3用のテストは発展途中と思われ組み合わせによる不具合がありそうです。とりあえずこのバージョンで進めていきます。
// 関係部分のみ抜粋
"devDependencies": {
"@types/jest": "^27.5.1",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-plugin-typescript": "~5.0.0",
"@vue/cli-plugin-unit-jest": "^5.0.0",
"@vue/test-utils": "^2.0.0-0",
"@vue/vue3-jest": "^27.0.0-alpha.1",
"babel-jest": "^27.0.6",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3",
"jest": "^27.0.5",
"ts-jest": "^27.0.4",
"typescript": "~4.5.5",
}
その後以下の手順を踏みました。
-
node_modules
以下全削除 -
yarn.lock
削除 -
yarn instal
実行
そして、yarn jest
したらテスト実体が何もないテストケース(mountでのレンダリング処理のみ)が成功する様になりました。
wasmモジュール読み込みエラー
何かやり方あるかもしれませんが、wasmモジュールを読み込むところでエラーが出てしまいました。package.jsはwasmのコンパイルモジュールなので基本触れないファイルです。一旦wasmモジュールを組み込んでいるコンポーネントは諦める事にします。
※後に解消しました。拙記事「jestでAPIなど他リソースが必要な機能のテストをしてみる」
/vue/cubetrain/src/wasm/package.js:352
input = new URL('package_bg.wasm', import.meta.url);
^^^^
SyntaxError: Cannot use 'import.meta' outside a module
vuetifyのコンポーネントレンダリングでワーニング
別の基本的なコンポーネントで試してみます。
compilerOptions.isCustomElement
を設定すれば良さそうなメッセージが出てきます。
console.warn
[Vue warn]: Failed to resolve component: v-container
If this is a native custom element, make sure to exclude it from component resolution via compilerOptions.isCustomElement.
at <ControlPanel defspeed=40 defscramblestep=20 ref="VTU_COMPONENT" >
at <VTUROOT>
しかし、詳細には記載しませんが、関係していそうなGithubのissueを参考に色々やってみましたが駄目でした。
ElementUIという別のUIライブラリで同じようなケースがある様です。
How to solve ElementUI unknown element warning in unit tests?
vuetifyの公式ページ確認
vuetify2.6.5の公式ページのGetting StartedブロックにはUnit Testingがあります。
Vuetify3.0.0Betaの公式ページのGetting Startedブロックにはなさそうです。
現時点ではVuetify関係のテストは出来ないと思って良さそうです。なんとなくこれがまだVuetifyがβ版である最大の理由では無いかと思います。
試しにやってみる
レンダリングが出来なければボタンなどDOM情報を取得出きず、当然そのイベント発火が出来ないので諦めかけていました。
ただ、出ていたメッセージがWarningだったのでちょっとやってみようと思いました。
すると、Warningは出るものの、テスト自体は成功しました。
import { shallowMount } from '@vue/test-utils'
import ControlPanel from '@/components/ControlPanel.vue'
describe('Test ControlPanel.vue', () => {
// clickイベントでawaitを使うので、asyncを指定します。
it('success to fire scramble', async () => {
const wrapper = shallowMount(ControlPanel, {
props: {
defspeed: 40,
defscramblestep: 20
}
})
expect(wrapper).toBeDefined();
// ボタンには「controlpanel_scramble」クラスが指定してあります。
const scramblebtn = wrapper.find('.controlpanel_scramble');
expect(scramblebtn).toBeDefined();
// clickイベントを発火します
await scramblebtn.trigger('click')
console.log(wrapper.emitted().controlAction[0]);
// ボタンclickでemitが発生するのでその中身をチェックします。
// { controlAction: [ [ 'scramble', 20 ] ], click: [ [ [MouseEvent] ] ] }
expect(wrapper.emitted().controlAction[0]).toStrictEqual(['scramble', 20]);
})
})
本体モジュールのテストケース作成時に発生したエラー
import.meta.urlでエラー
● Test suite failed to run
Jest encountered an unexpected token
Jest failed to parse a file. This happens e.g. when your code or its dependencies use non-standard JavaScript syntax, or when Jest is not configured to support such syntax.
Out of the box Jest supports Babel, which will be used to transform your files into valid JS based on your Babel configuration.
By default "node_modules" folder is ignored by transformers.
Here's what you can do:
• If you are trying to use ECMAScript Modules, see https://jestjs.io/docs/ecmascript-modules for how to enable it.
• If you are trying to use TypeScript, see https://jestjs.io/docs/getting-started#using-typescript
• To have some of your "node_modules" files transformed, you can specify a custom "transformIgnorePatterns" in your config.
• If you need a custom transformation specify a "transform" option in your config.
• If you simply want to mock your non-JS modules (e.g. binary assets) you can stub them out with the "moduleNameMapper" config option.
You'll find more details and examples of these config options in the docs:
https://jestjs.io/docs/configuration
For information about custom transformations, see:
https://jestjs.io/docs/code-transformation
Details:
/vue/cubetrain/src/wasm/package.js:356
input = new URL('package_bg.wasm', import.meta.url);
^^^^
SyntaxError: Cannot use 'import.meta' outside a module
4 | <script lang="ts">
5 | import { defineComponent, toRefs, onMounted, ref } from 'vue';
> 6 | import init, { start } from '@/wasm/package.js';
| ^
7 |
8 | export default defineComponent({
9 | name: "WasmScreen",
こんなエラーが出てきました。もちろんトップのコンポーネントはwasmモジュールを子コンポーネントとして含むので、そのインターフェースである所のpackage.jsファイルでエラーが出ているという状況の様です。先ほどは諦めましたが、やりたい所です。
エラーメッセージから似たケースを探していたら以下のページにたどり着きました。package.jsのimport.meta.url
をimport_meta_url
に変更してみます。
TextDecoderでエラー
import_meta_urlで変更したら別のエラーが出てきました。TextDecoderが無いとかの話です。
● Test suite failed to run
ReferenceError: TextDecoder is not defined
10 | // }
11 |
> 12 | let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
| ^
13 |
14 | cachedTextDecoder.decode();
こちらのページが参考になりました。
yarn add util
実行実行した後、package.jsの冒頭に以下を記載する事で対応しました。
※記事ではsetupJest.tsに記載しています。
if (typeof global.TextEncoder === 'undefined') {
global.TextEncoder = require('util').TextEncoder
}
if (typeof global.TextDecoder === 'undefined') {
global.TextDecoder = require('util').TextDecoder
}
fetchでエラー
同じようなエラーが出てきました。下記ページが参考になりました。
yarn add -D whatwg-fetch
実行して、package.jsの冒頭に以下を記載する事で対応しました。
if (typeof global.fetch === 'undefined') {
const fetchPolifill = require('whatwg-fetch')
global.fetch = fetchPolifill.fetch
}
wasm関数の読み込みでエラー
undefinedエラーが出てしまった部分があり、package.jsの該当部分でif文で回避しました。この関数を使用しているアニメーション部分では正常にテスト出来なくなるのですが、一旦先に進みます。
export function on_animation() {
if (wasm) {
var ret = wasm.on_animation();
return ret;
}
}
本体モジュールのテストケース作成
これでようやく本題に入れます。先に結果から記載します。
import { mount } from '@vue/test-utils'
import flushPromises from 'flush-promises'
import App from '@/App.vue'
describe('Test App.vue', () => {
it('success to fire scramble and number of history', async () => {
const wrapper = mount(App, {
props: {}
})
expect(wrapper).toBeDefined();
const STEP = 10;
// set speed
const speed_input = wrapper.find('.controlpanel_speedval');
expect(speed_input).toBeDefined();
speed_input.setValue(100);
const speed_btn = wrapper.find('.controlpanel_speed');
expect(speed_btn).toBeDefined();
await speed_btn.trigger('click');
// set valute to step
const scramblestep = wrapper.find('.controlpanel_scramblestep');
expect(scramblestep).toBeDefined();
scramblestep.setValue(STEP);
// fire scramble
const scramblebtn = wrapper.find('.controlpanel_scramble');
expect(scramblebtn).toBeDefined();
await scramblebtn.trigger('click');
// check number of steps
expect(wrapper.vm.rotateStepList.length).toBe(STEP);
await flushPromises();
// check playback status
const playbackbtn = wrapper.find('.app_playbackonestep');
expect(playbackbtn).toBeDefined();
await playbackbtn.trigger('click');
await playbackbtn.trigger('click');
await playbackbtn.trigger('click');
await flushPromises();
expect(wrapper.vm.rotateStepList[STEP - 4].rotateStatus).toBe("done");
expect(wrapper.vm.rotateStepList[STEP - 3].rotateStatus).toBe("bef");
})
})
ポイント
事前にある程度知識を整理したつもりですが、実際にはまったポイントがいくつかありました。
Vueインスタンスの変数
今回のケースでは、内部で保持されているステータスを取得する必要がありました。それを表示するDOM要素を頑張って取得しても良いですが、内部変数でチェックしたい所です。上でちょっとチェックした「vm」を使ってwrapper.vm.rotateStepList
の様に指定して取得出来ました。
// 中略
setup(){
// 中略
const rotateStepList = ref<Array<RotateStep>>(stepManager.getCurrentStepList());
// 中略
}
})
非同期処理
Promise系処理が終わっていないケースがあるので、await flushPromises()
で待つ必要があります。
yarn add --dev flush-promises
他のコンポーネントをラッピングする場合
ここまできてもハマった点があったので共有しておきます。vuetifyのv-tooltip
を使ったケースです。
修正前の書き方だと、wrapper.findでDOMが取得できません。どうやら、tooltipコンポーネントの修正前の書き方だとjestでmountした時にDOMとして存在しない様です。v-btnが表に来る書き方に修正して成功しました。
別のコンポーネントでも同じようなケースがあると思います。
<v-tooltip anchor="start">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" width="30" flat @click="onPlaybackOneStep" class="app_playbackonestep">
<v-icon>mdi-chevron-left</v-icon>
</v-btn>
</template>
<div>1ステップ戻します</div>
</v-tooltip>
<v-btn v-bind="props" width="30" flat @click="onPlaybackOneStep" class="app_playbackonestep">
<v-icon>mdi-chevron-left</v-icon>
<v-tooltip activator="parent" anchor="start"><div>1ステップ戻します</div></v-tooltip>
</v-btn>
残問題
色々package.jsに修正を入れましたが、このファイルはwasmのビルドモジュールです。基本的に修正したくありません。
lint対応の為、冒頭に/* eslint-disable */
を入れる処理をしていたので、その処理に組み込む事にします。
多分将来的に整備されていくと思うので、その時には外したいと思います。
また、wasmモジュールの読み込みがやっぱりうまく行ってません。どうやらfetchで失敗しているようです。自分の以前の記事でもnginxを使って読み込んでいたのでした。
今回の目的と外れそうなので、これに関してはまた別の記事で頑張ってみたいと思います。
TypeError: Network request failed
at node_modules/whatwg-fetch/dist/fetch.umd.js:535:18
at Timeout.task [as _onTimeout] (node_modules/jsdom/lib/jsdom/browser/Window.js:516:19)
その他
公式ページからリンクされていたYouTube動画がありました。今後深い事をする場面が出てくると思うので、一通り見ておきたい所です。英語ですけど。