JestのSnapshotテストに感動した話

June 4th, 2019

Introduction

恥ずかしながらJavascriptでUnitテストを書いたことがなかったんですが、ふとと思いたって導入してみました。 PHPのUnitテストやE2Eは書いたことあるんですが、どれもメリデメが大きいというか、「正しく投資すれば正しくメリットを享受できる」っていうイメージでした。

そもそもフロントにテストいれてどの程度有用なのかっていうのがイメージしづらいなーって思ってました。 Viewの開発時にテスト駆動開発みたいなのやろうと思うと「それブラウザ見たほうが早くね?」みたいな。

でもまぁものは試し、と思ってStorybookやReduxにテスト入れてみようと思ったらこれが非常に便利。 ということで非常に感動したSnapshotテストについて書いていこうと思います。

どんなテストが有用か

そもそもJavascriptでテストを書く場合、どういうテストがあるんだろう? 「ボタンをクリックしたらCallbackが呼ばれる」みたいなテストをこのサイトに導入してもあんまメリットなさそうだなぁ。。。 なんて思いながら色々調べてみたら、Snapshotテストなるものにたどり着きました。

Snapshotテスト

Snapshotテストって聞くと、「キャプチャとって比較すんの?」ってイメージする人も多いかもしれません。 まぁあながち間違いじゃないというか、かなり近しいです。が、キャプチャをとるテストのことじゃありません。

Snapshotテストとは、「テスト実行時にSnapshot(何かしらの実行結果)を作成し、前回のSnapshotがあれば比較することでテストを行う」ものになります。 このSnapshotはキャプチャでもいいし、Stringでも言いわけです。 なので例えばReactだとComponentをシリアライズした結果をファイルにして、前後比較したりします。

いわゆるリグレッションテストですね。

ComponentのSnapshotテスト

たしかにSnapshotテストはこのサイトでも非常に有用なきがします。 本サイトはReact製なので、ComponentのSnapshotテストから書いていこうと思います。

さて、どう書いていこうかなーなんて調べてたら、前回導入したStorybookのアドオンでSnapshotテストが簡単にかけるらしい。 ということで早速環境構築。

Jest

まずSnapshotをするためにJestを導入します。 JestはReact同様Facebookが開発してるオープンソースです。 詳しい説明は省きますが、Reactでテスト書こうと思ったらとりあえずJestから始めるのがスタンダードかと思います。

npm i -D jest

適宜jest-configなど修正しつつ、試しに実行。

import * as React from "react"
import * as renderer from "react-test-renderer"

import MainTitle from "./index"

describe("MainTitle", () => {
  it("renders correctly", () => {
    const tree = renderer
      .create(<MainTitle title="Multi Title" category="BLOG" />)
      .toJSON()
    expect(tree).toMatchSnapshot()
  })
}) 

無事__snapshots__というディレクトリにSnapshotファイルが生成され、変更時にエラーが発生するようになりました。

@storybook/addon-storyshots

無事実行できたのはいいんですが、どうやらStorybookのアドオンでいい感じにテストができるらしい。 ということでアドオン導入してみました。

npm i -D @storybook/addon-storyshots
import initStoryshots from '@storybook/addon-storyshots';

initStoryshots({ /* configuration options */ });

これだけでStoryごとのSnapshotテストをしてくれるというお手軽さ。 簡単すぎて拍子抜けですが、そもそもStory自体が半ばテストみたいなところがあるのに別途テストも書くって、って本体より書く量ふえるやん!みたいなしんどさが発生しないから非常に有用ですね。

Reducerのテスト

Componentのテストを書いたらあとは書くところといえばやはりReduxですよね。 本サイトのaction creatorは特にロジックもなくpayload受け取って流すだけ、かつTypescriptによる型制約もあるので予期せぬ型が入ることもまずない。 ということでテストは書かずともまぁまずバグは起こらなそう。

なので本サイトでは、Reducerにのみテストを書いていこうと思います。

本サイトのRedux構成

以前のエントリでも書いたんですが、本サイトではReduxは以下のような構成を取っています。 今回の話からは少しそれますが、サンプルが本サイトのものなので、一応記載しておきます。

  • typescript
  • typescript-fsa
  • typescript-fsa-reducers
  • ImmutableJS
  • ducks(ファイル構成)

ReducerのSnapshot

本サイトのReducerとテストを実際に書いてみました。

import { Record } from 'immutable'
import actionCreatorFactory from 'typescript-fsa'
import { reducerWithInitialState } from 'typescript-fsa-reducers'

// model
export interface IFormMember {
  isChanged: boolean;
}

export const FormModel = Record<IFormMember>({
  isChanged: false,
})

// action
const actionCreator = actionCreatorFactory()

enum ActionType {
  Change = 'FORM/CHANGE',
}

export const changeAction = actionCreator(ActionType.Change)

// reducer
const reducer = reducerWithInitialState(new FormModel())
  .case(changeAction, (state) => state.set('isChanged', true))

export default reducer
import * as snapshotDiff from 'snapshot-diff'
import reducer, { FormModel, changeAction } from './form'

const initialState = new FormModel()

test('[form ui state]: init', () => {
  expect(
    snapshotDiff(undefined, reducer(undefined, { type: '@@INIT' }))
  ).toMatchSnapshot()
})

test('[form ui state]: change', () => {
  expect(
    snapshotDiff(initialState, reducer(initialState, changeAction()))
  ).toMatchSnapshot()
})

@@INITはReduxの初期化時に発火する特殊なactionです。 上記のサンプルはpayloadがないactionだから1個ですが、何かしらpayload渡す場合もexpected値を書く必要がないので基本コピペで十分ですね。

感想

Snapshotテスト、すごいですね。 すごく浅いこというと、今時なテスト感ありますよね。

しかもこれ、うまくやればテスト駆動しながら使えそう。 調べたらPHPやRubyでもSnapshotテストのライブラリなどもあるみたいなので、サーバーサイドのテストでもちょっと使ってみようかと思います。