コンポーネントをテストする運動をしてみた – フロントの設計、テストの工夫

はじめに

株式会社LITALICOでエンジニアをやっています。 @ti_aiuto です。
この記事は「LITALICO Engineers Advent Calendar 2020」の12日目の記事です。

LITALICO Engineers Advent Calendar 2020
https://qiita.com/advent-calendar/2020/litalico

昨年は初のアドベントカレンダーということもあり張り切って2記事書いてしまったりしたのですが、今年はちょうど来週にリリースがあってバタバタしているので、さくっと一本だけ書かせていただこうと思います。

ちなみに前回書いたこっちの記事は結構気に入っていて、今でもたまに読み返したりしています。今回の記事もこれの続きだったりします。

経緯

普段は主な業務としてRails製のWEBサービスの開発に携わっています。これまではフロントの開発というとピンポイントでjQueryやVue.jsを使う程度でしたが、去年の秋にリリースした新機能を皮切りに、フロントエンドの開発に本格的に力を入れるようになりました。

現時点で次のようなライブラリを導入しています。

  • Vue.js, Vuex, Vuetify
  • axios, class-transformer
  • TypeScript, Jest
  • apidoc, eslintほか

コンポーネントを作って組み合わせて…とやっていくのは楽しいは楽しいのですが、テストが全くない状況でロジックがどんどん増えていくとなると、さすがに不安も増していくな、ということで、フロントエンドのテストを導入しようという話になりました[^1]。

それが今年の1月頃のことで約1年経ったので、これまでの取り組みを簡単に紹介します。

たどり着いた工夫の数々

色々検討した結果、どのような工夫にたどり着いたのかを紹介していきます。
なお、テストフレームワークにはJestを使っています。またテストコードはvue-test-utilsのサンプルコードをベースにしています。

https://jestjs.io/ja/
https://vue-test-utils.vuejs.org/ja/

全コンポーネントで必ず書いているテストコード

色々な粒度のテストコードを書いていく中で、「最低限書いておいたほうがいいテストってなんだろう」というところを考えていったときに、次のテスト用共通メソッドにたどり着きました。
新しいコンポーネントを作ったら必ずこのメソッドを呼び出すようにしています。

export function testMountComponent(factory: () => Wrapper<any>) {
  describe('描画のテスト', () => {
    let consoleErrorSpy;
    beforeEach(() => {
      consoleErrorSpy = jest.spyOn(global.console, 'error').mockImplementation(() => undefined);
    });

    it('正常に描画できること', async () => {
      expect(() => factory()).not.toThrow();
      await Vue.nextTick();
      expect(consoleErrorSpy).not.toHaveBeenCalled();
    });

    afterEach(() => {
      consoleErrorSpy.mockRestore();
    });
  });
}

export type ComponentWrapper<T extends Vue = any> = Wrapper<T>;

コンポーネントのテストコードの例

// importは省略

const factory = (): ComponentWrapper => {
  return shallowMount(FormRequiredSign);
};

describe('FormRequiredSign', () => {
  testMountComponent(factory);

  // 必要なら以下にテストを書き足していく
  // describe('computed', () => {
  // ...
});

このテストコードがあることで、次のようなミスを検知できます。

  • createdmounted で例外が投げられたりコンソールにエラーが出力されるような変なコードを書いている
  • <template> で存在しないコンポーネントを呼び出したり不正な式を書いたりしている

成功の場合

例外が投げられた場合

不正なコンポーネント名を指定した場合

Rails&RSpecでいうと、RequestテストでControllerやViewのバグを検知できるのと同様の効果があります。

発展編

コンポーネント内に条件分岐があって、そのどっちもテストしたい場合は、(必要なら)次のように書いています。

const factory = ({
                   hogeFlag = true
                 } = {}): ComponentWrapper => {
  return shallowMount(TestSampleComponent, {
    propsData: {
      hogeFlag
    }
  });
};

describe('TestSampleComponent', () => {
  testMountComponent(() => factory({hogeFlag: true}));
  testMountComponent(() => factory({hogeFlag: false}));
});

このシンプルなテストがあるだけでも、凡ミスでページが表示されなくなる事態を防ぐことができているので、かなりコスパが高いコードだと思います。
shallowMount を使っているため実行はほとんど一瞬で終わります。

テスト以前の設計の問題

テストが書きにくいということもありましたが、そもそもコンポーネントが大岩になって保守しにくいという課題があったため、次のように設計を工夫するようになりました。

コンポーネントの役割分担

「Pageコンポーネント」と「それ以外のコンポーネント」

API通信やstore操作、パラメータの処理、デバイス判定、Storage操作など[^2]は、最上位のコンポーネントに任せて、それ以外の下位のコンポーネントには関わらせないようにしています。この「最上位のコンポーネント」を「Pageコンポーネント」[^3]と呼んでいます。
これにより、下位のコンポーネントは「propsに対してどういう表示をするか」に集中できるため、コンポーネント設計の全体の見通しがかなり良くなりました[^4]。

フォームの入力部品をコンポーネントとして切り出す運動

フォームの実装では、自力で実装した入力用のコンポーネントを v-model に対応させた上で切り出すことで、親側のフォームページのコンポーネントをかなり軽量にすることができました。
v-model 対応のコンポーネントは、 propsvalue を受け取って $emit('input', ...) するよう実装するだけで作れます。

コンポーネントの基本 — Vue.js
https://jp.vuejs.org/v2/guide/components.html#%E3%82%B3%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%8D%E3%83%B3%E3%83%88%E3%81%A7-v-model-%E3%82%92%E4%BD%BF%E3%81%86

Vuexの導入

API通信をコンポーネントから切り出そうと検討していたときに、そもそもそのページのメインのコンテンツはVuexの store に持たせて、通信も store に隠ぺいしてしまえばいいじゃないか、ということになりました。
これにより、コンポーネント内で保持していた表示用のデータや isLoading のようなフラグが取り除かれ、 created の仕事も「 action を呼ぶ、以上!」というシンプルな設計になりました。
storeactionmutation については、必ずテストを書くようにしています。

class-transformerの導入

API通信で受け取ったデータは、もともとは単なる Object で扱っていたんですが、これだと Entity に紐づくロジックが、色々なコンポーネントの色々な computedmethods に分散してしまっていました。いわゆる「ドメインモデル貧血症」というやつですかね。

そこで、 Entity ごとにクラスを定義して、そのクラスに関連するメソッドを定義するようにしました。
調べてみたところ、 class-transformer というライブラリが良さげだったので、これを導入しました。ネストされたJSONも芋づる式に全部変換してくれるので楽々です。

export class SampleClass {
  // ...

  // JSONでのキー名とメンバ名が違う場合は@Exposeで指定
  @Expose({name: 'hoge_column'})
  hogeColumn: string;

  // ネストされたJSONの変換先クラス指定
  @Type(() => FugaRelatedDataClass)
  @Expose({name: 'fuga_related_data'})
  fugaRelatedData: FugaRelatedDataClass;

  // JSONからの変換用のメソッドを定義
  static build(source: Partial<object>): SampleClass {
    return plainToClass(SampleClass, source);
  }

  // データに紐づくロジックの例
  // 従来はこういうロジックがコンポーネントに散らばっていた
  isPiyoAllowed(baseDate: Date): boolean {
    return moment(baseDate).isBefore(this.hogeColumn);
  }
}

JSONからの変換用のメソッドを static build として定義しておくことで、 class-transformer を使っているということをクラスの中に隠ぺいしています。もしほかのライブラリを使いたくなったら build メソッドだけ直せばOKです[^5]。

これとVuexで、コンポーネント内にあったロジックが激減しました。

なお、ふつうに導入するとIE11でページを開いたときに構文エラーで動かなくなるので要注意です[^6]。

ユーザストーリーの段階で受け入れ条件を明確にする&テストシナリオを整備する

単体テストをどう書くかという問題もありますが、自動化テストにこだわらずに、全体的に期待通りに動作をするかを手作業で確かめていくことが効果的な場合もあります。

今年の夏にリリースした新機能では、日頃からの単体テストに加えて、手作業で動作検証をするためのテストシナリオを丁寧に洗い出して、そのシナリオに沿って品質をチェックするという方法を組み合わせました。
これまではあまりこういう方法で検証項目を書き出して…、ということはやっていなかったのですが、リリース前の最終チェックやライブラリのアップデート時などにこういうシナリオがあると便利だということがわかりました。

以降の開発では、こうしたシナリオとしてまとめることも意識して、各ユーザストーリーの受け入れ条件(ユーザが何をできれば「正しい」挙動なのか)を整理するようにしています。

コンポーネントによって必要に応じて書いているテストコード

上記の検討をした結果、それでもコンポーネントの computedmethods にロジックを書くことになった場合は、テストのコスパを考えつつ単体テストを書いていきます。
テストを書くからには、同値分割・境界値分析でテストケースを洗い出した上で書くようにしています。

describe('コンポーネント名', () => {
  testMountComponent(factory);

  describe('computed', () => {
    describe('computedの定義名', () => {
      describe('propsで〇〇を渡した場合', () => {
        it('□□が返ること', () => {
          // ...
        });
      });

      describe('propsでXXを渡した場合', () => {
        it('△△が返ること', () => {
          // ...
        });
      });

      // ...
    }); 
  });
});

昔話

せっかく一年試行錯誤してきたので、どうして上記にたどり着いたのか、ということも少し書いておきます。

とりあえず片っ端からテストを書くのはコスパが悪い

テストを書き始めた当初は、「〇〇のメソッドを呼び出したら△△のクラスがついた要素が表示されること」のような細々としたことも含めて、かなり細かくテストを書いていました。
しかしこれだとテストを書くだけでも一苦労だし、割と変更が起きやすい箇所だし、本当にこんな面倒なことをする必要があるのか…?という気分になります。
そこで、そういうのは重要なところに絞ってE2Eテスト(自動でも手動でも)で検証することにして、特別な事情がない限りは「表示できればOK」くらいの粒度でテストすることにしました。その結果たどり着いたのが上記の testMountComponent メソッドです。

vue-test-utilsでmethodsのモックが非推奨になった事件

一時期までは、コンポーネントの各メソッドのテストを書くのに加えて、「特定のメソッドを jest.fn() でモックしておいて、created のときにそのメソッドが呼び出されること」のようなテストも書いていました。
これもメソッドに処理を委譲している以上は一応書いといてもいいだろうなという程度に思っていたのですが、 vue-test-utils のアップデートで methods をモックするテストコードが書けなくなったため、削除せざるを得なくなりました。どこかの解説に「モックしなきゃいけないような処理をコンポーネントに書くんじゃない」というようなことが書かれていたので、これはもうVuexを導入せよということかなと思い、導入することにしました。
結果、少し手間は増えましたが、コンポーネントの責務がかなりはっきり分かれて見通しがよくなりました。

重複するcomputedをmixinに切り出すのか問題

class-transformer を導入する前は、データを元に何らかの判定をするとか生成をするとかというときに、似たようなコンポーネントを書くと似たようなロジックがあちこちに現れるということが起きていました。
重複しているということはmixinにすれば切り出せるけど…なんかこれじゃない感があるよなあ…ということで、クラスという基本機能をちゃんと活用していこう、ということになりました。

初の2C向け有料サービスのリリース、しかも当初は全コンテンツデータをフロントで保持することになった問題

これは技術というよりも事業の話ですが、今年の春に2C向けの有料サービスをリリースしました。そのサービスですが、当初はどういう形のサービスになるか先行きが見えない部分があったのと、少しでも早くリリースしたかったということもあり、RDBのテーブルを含めバックエンドをほとんど作らずに、フロントでデータを保持する形で実装することになりました。実質的にSPAかつスタンドアロンなアプリケーションです。

有料サービスであるからにはますます余計に品質には気を遣わないといけないし(無償だと手を抜くとかではないですが)、しかも普通ならサーバにあるような複雑さがフロントに持ち込まれることになります。他方、うれしいことに新サービスはコードが少ない分設計の変更が柔軟にできます。そうした状況の中で、この記事で述べたような工夫を色々と試行錯誤しながら進めていくことができました。そういう事業やサービスの状況自体も、技術的に求められることや、とれる手段に影響してくるのだなとしみじみ思います。

終わりに

今後もフロントの開発効率化&品質改善運動の旅路は続いていくかと思いますが(特にVue.jsのバージョンを上げたときにどうなるか…?)また何か発見があったらシェアできればと思います。
長々読んでいただきありがとうございました。

明日は @tjinjin が執筆を担当します。

[^1]: そもそもなんでテストを書くのかというところですが、「コードが本当に動くのかは雑にでも一回くらいは走らせてみないとわからない」という思想がベースにあるのと、単純に後でうっかり期待する挙動を壊しちゃったら気づきたいじゃん、とかですかね。あとは他にもテストコードがあったほうが仕様が理解しやすいとかバグに気づきやすいとか色々あります。
[^2]: これを「コンポーネントの世界の外側」と呼んでいます。あんまり使わない表現ですが。。
[^3]: この設計は https://speakerdeck.com/simezi9/baseniokeru-vuekonponentoshe-ji-falsexian-zai
https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0 を参考にしました。
[^4]: しばらくはこれでカオスを回避できていたのですが、今度は下位のコンポーネントが乱立してきたので、もう少しコンポーネントの分類を細分化してatomicデザインか何かの体系を導入しようと検討しているところです。
[^5]: というよりは、最初はこのライブラリを使わずに build メソッド内を自力で実装していたのですが、良いライブラリがあったということで、導入した上でこのメソッドの中だけ書き換えました。
[^6]: webpack.config.js と babel.config.js の修正が必要でした。

PAGE TOP