最新版=>フロントエンドのテストについて考える
現在、新規プロジェクトで、Nuxt.js+TypeScriptを使ったSPAの開発を行っています。この記事では、そういったケースにおいての、Nuxt.jsアプリケーションでのテストに対する考え方と、その方法を紹介します。
TL;DR
- ユニットテスト/結合テスト=>Jest
- UIテスト=>Storybookをベースに、Jestで足りない部分を補う
プロジェクトの技術スタック
- Nuxt.js
- Vue.js
- Vuex
- TypeScript
- Docker
テスト種別の整理
簡単に、今回登場するテスト種別の整理をしておきます。より詳しくは、ググったり書籍を読んだりするのをおすすめします。
- ユニットテスト - 個別の関数やクラス、コンポーネントをテストする。
- 結合テスト - 複数の関数やコンポーネントをつなぎこんでテストする。
- UIテスト - ブラウザを使って見た目を含めてテストする。
- ブラックボックステスト - 機能の実際の振る舞いをベースにテストする手法
- ホワイトボックステスト - 機能のロジックや入出力をベースにテストする手法
これに加えて、フロントエンドのテストではブラウザ環境かNode環境かという違いも出てくるため、テストを導入する際はプロジェクトによって方針を決めておくのがいいと思います。
細かな注意としては、以下のような点です。
- コンポーネントはユニットテスト環境でもテストできる
- UIテストはブラックボックステストになりがちだが、ホワイトボックスだったり、ユニットテストに近い形でも利用できる
- すべてのテストを実装する必要はない。それぞれどこをテストするか責務を明確にし、コストを掛けすぎず、うまい%でテストへの実装工数を配分することが重要
新規プロジェクトにおけるテストの考え方
テストを書くことで多くのメリットがありますが、なぜか書かれないこともあります。その理由の一つには、「とにかく時間がない」というのがあるのではないでしょうか。特に、新規プロジェクトだと、テスト環境を整備するのにも工数がかかるため、とりあえず開発がゴーになってしまい、テストを導入する間もなく時間が過ぎてゆく、みたいなことはありがちです。
ただし、テストを書くことで開発中期以降、確実に楽になりますし、品質面でも、人間の手動チェックでは検出できない箇所は増えてきます。
そこで、個人的に思う新規プロジェクトにおけるテストの考え方を書いてみます。
(小規模なプロジェクトや継続的な開発がないプロジェクトは除きます)。
まず、ユニットテストは100%書くことをおすすめします。ロジックは網羅できていなくても、少なくともテストファイルを作成して簡単なアサーションを書いておく、これだけでもいいと思います。たしかに最初は少し工数を取られるとは思いますが、テストを書くことで、開発後期でも安心できますし、手動でのバグ検出時間を削減でき、効率も上がってきます。
次に、結合テストにはユニットテストほど時間を割くべきではないと考えます。ユニットテストを100%とすると、初期のプロジェクトでは10%-20%ほどが目安です。テストピラミッドは意識しますが、正三角形よりはアステロイドのようなイメージです。
ただし、これは以下の条件が前提となってきます。
- ユニットテストで機能を担保できるレベルで責務を切り分けられている
- あくまでシナリオベースの手動テストは最低限行っている
もちろん理想的には両方あるといいのですが、ユニットテストにある程度任せられるからこそ、結合テストは最小限にしたり後回しにすることができます。のちほど紹介しますが、例えばコンポーネントのテストで、子コンポーネントのテストを重点的に行っているのはそのためです。
長くなりましたが、後回しできるところは後回しにして、最小限のコストで、かつ、開発者も楽になれるようなテストとの付き合い方が、新規プロジェクトでは理想かと考えます。
テスト方針
TL;DRにも書きましたが、簡単にテスト方針をまとめると、以下のようになります。
- ユニットテスト/結合テスト=>Jest
- UIテスト=>Storybookをベースに、Jestで足りない部分を補う
それぞれ、テスト対象にわけて見てゆきます。
基本のテスト
基本はJestを使って普通にテストを書いてゆきます。
function sum(a, b) {
return a + b;
}
test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3);
});
これを応用して続くテストも書いてゆきます。
Vuex実装のテスト
Vuex実装のテストは、主にユニットテストでまかなうことができます。基本的にはReduxなどと同様に、以下のような観点でテストをしています。
- action - mutationをコミットし、状態管理ができること
- mutation - stateを更新できること
// actionのテスト例
it('リソース一覧を取得できること', async () => {
nock(apiServer())
.get(SOME_ENDPOINT)
.query(SOME_QUERY)
.reply(200, {
message: 'OK',
resources: [...]
});
const success = await testAction(
actions.getResources,
null,
STATE_MOCK,
[
{ type: 'request' },
{
type: 'success',
payload: {
resources: [...]
},
},
],
{ rootState: ROOT_STATE_MOCK },
);
expect(success).toBe(true);
}
詳細はドキュメントを見ると良いですが、actionのテストでは、mutationのcommit
の他に、別actionのdispatch
もテストできるようにtestAction
関数を調整しています。
また、ファットactionを防ぐため、共通のactionはヘルパーとして外出ししたり、バリデーションの処理などactionの責務に関係ないロジックを別の関数に移したり等をしています。そうすることで、テストの複雑度を上げずに網羅性を高めることにもつながります。
コンポーネントのテスト
コンポーネントのテストに関してはStorybookをベースとし、Jestで足りない部分を補っています。
大まかな方針として、コンポーネントをできるだけステートレスにして、親コンポーネントよりも子コンポーネントの振る舞いに注意してテストしてゆくということを考えました。具体的には以下のような事柄です。
- コンポーネントの責務をなるべく小さく保つ
- ロジックはできる限りVuex側に移譲する
- コンポーネントごとにStorybookとテストを書く
- DOMをすべてつなぎこんだ結合テストにこだわらない
子コンポーネントに着目するため、テストは自然とユニットテスト的、ホワイトボックス的になります。それでも機能が担保できるように、以下のような観点でテストができるといいかなと考えています。詳しく見てゆきます。
見た目のテスト
実際のDOMの見た目のテストです。これはStorybookの本来の目的で、まずはUIをドキュメントとして残しておくことができます。例えばフォームの正常系と異常系のドキュメントは以下のようになります。
2重管理は辛いので、描画するstoryはJestで書いたものを流用します。例えば、以下のようなテストケースがあったとしたら、
import { noop } from 'lodash';
import { shallowMount } from '@vue/test-utils';
import { usersStateMock } from '~/test/mock/';
import C from './users.vue';
const template = `<C :items="items" :get-list="getList" />`;
export const story = {
components: { C },
data() {
return {
items: usersStateMock(),
getList: noop,
};
},
template: template,
};
export const storyError = {
components: { C },
data() {
return {
items: usersStateMock({
errorMessage: 'ユーザーが見つかりません。',
}),
getList: noop,
};
},
template: template,
};
describe('components/users', function() {
it('ユーザーの一覧を描画できる', () => {
const wrapper = shallowMount(C, {
propsData: story.data(),
});
expect(wrapper.element).toMatchSnapshot();
});
it('エラーメッセージを描画できる', () => {
const wrapper = shallowMount(C, {
propsData: storyError.data(),
});
expect(wrapper.element).toMatchSnapshot();
});
});
Storybook側では以下のようにJestからテストケースごとのstoryをインポートします。こうすることでテストとUIドキュメントを連動させることができます。
import { storiesOf } from '@storybook/vue';
import { story, storyError } from '~/components/users.spec.ts';
storiesOf('components/users', module)
.add('正常系', () => {
return story;
})
.add('異常系', () => {
return storyError;
});
加えてこのテスト領域には、Visual Regressionテストもはいってくると思います。Visual Regressionテストはその名の通り、UIのスクリーンショットを比較して差分検出するテストのことです。数年前などはつらみのあったこの手法ですが、最近ではHeadless Chromeなどの恩恵もありずいぶんやりやすくなりました。まだ現在のプロジェクトには導入していませんが、プロジェクト後期にいれても問題はないと考えています。
振る舞いのテスト
クリックや入力などのDOMイベントに対する振る舞いのテストです。こちらがコンポーネントの主なユニットテストとなると思います。JestとVue Test Utilsを用いて、例えば以下のように実装します。
it('クリックの例', () => {
expect(wrapper.vm.count).toBe(0)
const button = wrapper.find('button')
button.trigger('click')
expect(wrapper.vm.count).toBe(1)
})
it('キーアップの例', () => {
const wrapper = mount(QuantityComponent)
wrapper.trigger('keydown.up')
expect(wrapper.vm.quantity).toBe(1)
})
it('イベント発行の例', () => {
expect(wrapper.vm.count).toBe(0)
const button = wrapper.find('button')
button.trigger('click')
// イベントの発行
expect(wrapper.emitted().foo).toBeTruthy()
// イベントの数
expect(wrapper.emitted().foo.length).toBe(2)
// イベントに渡されたデータ
expect(wrapper.emitted().foo[1]).toBe([123])
})
it('非同期なデータ取得の例', (done) => {
const wrapper = shallowMount(Foo)
wrapper.find('button').trigger('click')
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.value).toBe('value')
done()
})
})
describe('Vuexの例', () => {
let actions
let store
beforeEach(() => {
actions = {
actionClick: jest.fn(),
actionInput: jest.fn()
}
store = new Vuex.Store({
state: {},
actions
})
})
it('actionのdispatchができること', () => {
const wrapper = shallowMount(Actions, { store, localVue })
const input = wrapper.find('input')
input.element.value = 'input'
input.trigger('input')
expect(actions.actionInput).toHaveBeenCalled()
})
})
Storybookのアドオンでテストする方法もありますが、自動化を考えJestで実装しています。
また、Jest、Vue Test Utilsを使えば、ある程度複雑なメソッドやロジックもテスト可能ですが、コンポーネントは可能な限り、データの描画と、イベントのコールバック発火のみを責務とさせ、ビジネスロジックと言える実装は、Vuex側やサービスに切り出すことを意識しています。こうすることで責務が自明となり、テスト自体省略できるケースも多いです。
そして、ある程度機能がかたまってきた段階では、ブラウザで動作を結合させる、いわゆるE2Eテストも効果的かと思います。Nuxt.jsだと、Nuxt Classを使用して結合テストを行うこともできますが、ブラウザを通すのであればTestCafeなどが必要です。
DOM構造のテスト
DOM構造に関するテストで、これはいわゆるsnapshotテストと言い換えても問題ないと思います。描画パターンの保護と、修正に対する影響範囲を把握するのが主な目的です。
こちらはJestのsnapshotテストを使用しています。異常系も含めて、表示のテストはsnapshotの責務としています。
describe('components/users', function() {
it('ユーザーの一覧を描画できる', () => {
const wrapper = shallowMount(C, {
propsData: story.data(),
});
expect(wrapper.element).toMatchSnapshot();
});
it('管理者ユーザーを太字で描画できる', () => {
const wrapper = shallowMount(C, {
propsData: storyAdmin.data(),
});
expect(wrapper.element).toMatchSnapshot();
});
it('エラーメッセージを描画できる', () => {
const wrapper = shallowMount(C, {
propsData: storyError.data(),
});
expect(wrapper.element).toMatchSnapshot();
});
});
その他
Storybookと同時にテストケースも確認できれば、小さいコンポーネントをTDD的に開発を行うことができるだろう、と思い、Specificationsというアドオンを使用していましたが、公式のJestアドオンがVue.js対応されてから導入したほうが線が良さそうだったため、使用をやめました。詳しくはこちら…。
TypeScriptについて
テストとはまた別の話ですが、上記はTypeScriptで型付けしていることが前提となります。型があることで、保護テストを薄くしても、初期の仕様変更による影響範囲を拾いやすくなりますし、型を決めれば推論がされるので、何も考えずにコードを書くことができます。メリットはいうまでもないと思うのでここで詳しくは書きませんが、例えばVuexステートの実装は以下のようになります。
// ユーザー一覧の例
export interface RequestState {
errorMessage: string;
isFetching: boolean;
}
export interface PaginationState<L, Q> {
list: Array<L>;
query: Q;
currentPage: number;
totalPage: number;
totalCount: number;
}
enum UserRole {
Admin,
Editor,
Subscriber,
}
export interface User {
id: number;
name: string;
role: UserRole;
groups: Array<Group>;
}
// 検索用のクエリ
export interface UserQuery {
name: string;
role: UserRole;
}
// ユーザー一覧のVuexステート
export interface UsersState extends RequestState, PaginationState<User, UserQuery> {}
// ルートステート
export interface RootState {
users: UsersState;
}
型を作っておくことで、例えばUser
に新たに属性を付与する場合など、影響範囲が見えやすくなります。
また、推論もよくできていて、ActionHandler
のパラメーターには以下のActionContext
のinterfaceが割り当てられているので、
export interface ActionContext<S, R> {
dispatch: Dispatch;
commit: Commit;
state: S;
getters: any;
rootState: R;
rootGetters: any;
}
以下のようにActionTree<UsersState, RootState>
を指定するだけで、あとは勘で書いていっても実装できます。
import { ActionTree } from 'vuex';
export const actions: ActionTree<UsersState, RootState> = {
async getList({ commit, state }) { // ←ActionContext
try {
commit('request');
const result = await axios.get(ENDPOINT, { params: state.query });
commit('success', {
currentPage: result.data.current_page,
totalPage: result.data.total_page,
totalCount: result.data.total_count,
list: result.data.users
});
} catch (e) {
commit('failure', { errorMessage: e.response.data.errorMessage });
}
},
};
学習コストやコンパイル時間などはあるので、選定は必ずしも推奨するわけではありません。ただ、TypeScriptにせよFlowにせよ、導入自体は直ぐにできる、コンパイル後のコードには影響がない、(キレイではないけど)動的に書こうと思えば書ける、ということなどから、積極的に避ける理由はあまりないのかなと思います。Vue.js 3.xもコードベースはTypeScriptで開発されています。
まとめ
以上、Nuxt.jsの新規プロジェクトでのテスト環境でした。お気づきかもしれませんが、あんまりNuxt.jsとかVueだとかは関係ありません。
これが正解というわけではなく、目的によって、テストを厚くしたり薄くしたり、別のアプローチをとったりも考えられると思います。ただ、どんな場合でも、どう品質を保ってゆくか方針を決めておくことが大事だと考えます。参考になれば幸いです。
参考
- An Overview of JavaScript Testing in 2018 – Welldone Software – Medium
- An Overview of JavaScript Testing in 2017 – powtoon-engineering – Medium
- Use Enzyme for Integration Testing (end-to-end testing) · Issue #237 · airbnb/enzyme
- Visual Testing — the pragmatic way to test UIs – Chroma
- Visual Test Driven Development – Chroma
- Storybookとreg-suitで気軽にはじめるVisual Regression Testing - wadackel.me
- テスト自動化の理論と技術と戦略:LINE Developer Meetup Tokyo #39 - Testing & Engineering : LINE Engineering Blog
- アジャイルテストの4象限とテスト自動化のピラミッド - 野次馬エンジニア道
- TDD再考 (2) – 何故、ほとんどのユニットテストは無駄なのか? – ゆびてく
- TDDはあんまり使わなくなったけど心の中にある - Mitsuyuki.Shiiba
- The State of JavaScript 2017: Testing Tools – Results
- フロントエンドのテストに真面目に向き合う - Qiita
- Karmaで行なっていたUIテストにStorybookを導入した
- https://qiita.com/okmttdhr/items/c1e80353928e121c4761