React + MUI DataGrid 用に作成した Custom Operator を、Jest + jest-mock-extended でテストしてみた

以前、 MUI DataGrid の Custom Operator を作りました。
React + MUI のDataGridにて、ある列が複数の日付を持つデータに対し、valueFormatter・sortComparator・filterModelを使って表示・ソート・フィルタしてみた - メモ的な思考的な

 
この時に作成した Custom Operator の getApplyFilterFn に対して Jest でテストコードを書こうと思ったところ、いろいろ悩んだためメモを残します。

 
目次

 

環境

  • React.js 17.0.2
  • React Router 6.0.1
  • @mui/x-data-grid 5.0.1
  • date-fns 2.26.0
  • TypeScript 4.5.2
  • Vite.js 2.6.14
  • Jest 27.3.1
  • jest-mock-extended 2.0.4

 
なお、以前の記事後、 @mui/x-data-grid が正式バージョンの 5.0.1 になったため、バージョンアップをしておきます。

また、以前のコードで date-fns の import が

import isSameDay from 'date-fns/isSameDay'

となっている部分があったため、今回作成するテストコードを実行すると

TypeError: (0 , isSameDay_1.default) is not a function

というエラーになってしまいます。

stackoverflowなどによると

  • import方法を変える
    • import {isSameDay} from 'date-fns'
  • tsconfig.json"esModuleInterop": false から "esModuleInterop": true へ変更する

のどちらかを行えば良いようです。

そこで今回は前者の import 方法を変更しておきます。

 

Jest + ts-jest のセットアップ

前回のコードには Jest をインストールしていなかったため、追加します。

Jest まわりをインストールします。

なお、Transformer には色々ありますが、今回は ts-jest を使用します。

# インストール
% yarn add -D jest @types/jest ts-jest

# 初期設定
% jest --init                          

The following questions will help Jest to create a suitable configuration for your project

✔ Would you like to use Jest when running "test" script in "package.json"? … yes
✔ Would you like to use Typescript for the configuration file? … no
✔ Choose the test environment that will be used for testing › node
✔ Do you want Jest to add coverage reports? … yes
✔ Which provider should be used to instrument code for coverage? › v8
✔ Automatically clear mock calls and instances between every test? … no

✏️  Modified path/to/react_mui_with_vite/package.json

📝  Configuration file created at path/to/react_mui_with_vite/jest.config.js

 
生成された jest.config.js に以下を追記します。

module.exports = {
  moduleNameMapper: {
    '^@/(.+)': '<rootDir>/src/$1'
  },

  roots: ['<rootDir>/src'],

  testPathIgnorePatterns: ['/node_modules/'],

  transform: {
    '^.+\\.(ts|tsx)$': 'ts-jest'
  }
}

 

テスト方法を考える

getApplyFilterFn

getApplyFilterFn: (filterItem: GridFilterItem) => {
  if (!filterItem.columnField || !filterItem.value || !filterItem.operatorValue) {
    return null
  }

  return (params: GridCellParams): boolean => {
    if (!params.value || !Array.isArray(params.value)) {
      return false
    }

    return params.value.filter((v) => isSameDay(v, toFilterValue(filterItem))).length > 0
  }
},

と定義していました。

そのため、テストコードは

  • GridFilterItem 型の引数を渡して getApplyFilterFn() を呼ぶ
  • 関数が戻ってくるので、 GridCellParams 型の引数を渡してその関数を呼ぶ
  • 戻り値の boolean を検証する

とすれば良さそうでした。

 
型定義を見てみると、 GridFilterItem にはプロパティが4つありました。
https://github.com/mui-org/material-ui-x/blob/v5.0.1/packages/grid/modules/grid/models/gridFilterItem.ts

一方、 GridCellParams にはプロパティや関数が数多くありました。テストで使うものは GridCellParams.value だけですが、 interface の制約に従うと他にも実装が必要そうでした。
https://github.com/mui-org/material-ui-x/blob/v5.0.1/packages/grid/modules/grid/models/params/gridCellParams.ts

 
そこで今回は、モックによるテストコードを考えることにしました。

 

ダメだったモックの方法

Jest では、標準のモックがいくつも用意されています。

 
まずはモック関数 jest.fn() を使ってみることにしました。

jest.fn()value だけ値を返すように

const m = jest.fn<GridCellParams, []>().mockImplementation(() => {
  return {
    value: [new Date(2021, 10, 27)]
  }
})

と使ってみたところ

TS2345: Argument of type '() => { value: Date; }' is not assignable to parameter of type '() => GridCellParams<any, any, any>'.   Type '{ value: Date; }' is missing the following properties from type 'GridCellParams<any, any, any>': id, field, formattedValue, row, and 6 more.

とエラーになりました。 value 以外にも id などの実装が必要そうでした。

 
続いて jest.spyOn()

const n = jest.spyOn(GridCellParams, 'value').mockReturnValue('')

としたところ、

TS2693: 'GridCellParams' only refers to a type, but is being used as a value here.

というエラーになりました。

公式ドキュメントによると、第1引数は object なので interface は渡せないようです。
https://jestjs.io/ja/docs/jest-object#jestspyonobject-methodname

 
他に Jest の標準で使えそうなものを探しましたが、見当たりませんでした。

もし使えそうなものをご存じの方がいれば、教えていただけるとありがたいです。

 

jest-mock-extended でモックを実装すればOK

Jest で interface をモックする方法を調べたところ、以下の stackoverflow に出会いました。
mocking - Mock a typescript interface with jest - Stack Overflow

そこでは jest-mock-extended が紹介されていました。
https://github.com/marchaos/jest-mock-extended

READMEを読んだところ、 interface をモックしている例が記載されていため、試してみることにしました。

 
まずは jest-mock-extended をインストールします。

% yarn add -D jest-mock-extended

 
続いてテストコードを書いていきます。

まずは GridCellParams のモックを作成します。value が値を返すように定義し、他は特に定義しません。

const cellParams = mock<GridCellParams>()
cellParams.value = [new Date(2021, 10, 27), new Date(2021, 11, 28), new Date(2021, 11, 29)]

 
次に GridFilterItem のモックも作成します。 getApplyFilterFn の中で値をチェックしているプロパティについては、適当な値をセットします。

なお、フィルター用の値 value には '2021-11-27' を指定しておきます。

const filterItem = mock<GridFilterItem>()
filterItem.columnField = 'a'
filterItem.value = '2021-11-27'
filterItem.operatorValue = 'is'

 
あとは検証です。

まずは getApplyFilterFn() を実行します。

const fn = isOperator.getApplyFilterFn(filterItem)

getApplyFilterFn() の戻り値は null の可能性もありますが、このテストケースでは null にはならないはずです。

そのため、

expect(fn).not.toBeNull()

という検証を追加します。

 
次に、戻り値の関数 fn を実行しますが、

fn(cellParams)

とすると

TS2721: Cannot invoke an object which is possibly 'null'.

になってしまいます。

そこで、オプショナルチェーンを使うことでエラーとならないようにします。
オプショナルチェーン (?.) - JavaScript | MDN

const actual = fn?.(cellParams)

 
あとは検証して終わりです。

expect(actual).toBe(true)

 
テストコードの全体像は以下のとおりです。

念のため、 false となる条件も検証しています。

import {mock} from 'jest-mock-extended'
import {GridCellParams, GridFilterItem} from '@mui/x-data-grid'
import {isOperator} from '@/components/functions/datagrid/operators'

describe('is operator test', () => {
  const cellParams = mock<GridCellParams>()
  cellParams.value = [new Date(2021, 10, 27), new Date(2021, 11, 28), new Date(2021, 11, 29)]

  describe('フィルタの値がセルの値と一致する場合', () => {
    const filterItem = mock<GridFilterItem>()
    filterItem.columnField = 'a'
    filterItem.value = '2021-11-27'
    filterItem.operatorValue = 'is'

    it('trueを返す', () => {
      const fn = isOperator.getApplyFilterFn(filterItem)

      // nullの可能性があるため、
      //   ・nullではないことを確認
      //   ・オプショナルチェーン (?.) で関数を実行
      // とする
      expect(fn).not.toBeNull()

      const actual = fn?.(cellParams)
      expect(actual).toBe(true)
    })
  })

  describe('フィルタの値がセルの値と一致しない場合', () => {
    const filterItem = mock<GridFilterItem>()
    filterItem.columnField = 'a'
    filterItem.value = '2021-11-30'
    filterItem.operatorValue = 'is'

    it('falseを返す', () => {
      const fn = isOperator.getApplyFilterFn(filterItem)
      expect(fn).not.toBeNull()

      const actual = fn?.(cellParams)
      expect(actual).toBe(false)
    })
  })
})

 
テストを実行するとパスしました。

% yarn test
yarn run v1.22.11
$ jest
 PASS  src/tests/components/functions/datagrid/operators.test.ts (5.997 s)
  is operator test
    フィルタの値がセルの値と一致する場合
      ✓ trueを返す (4 ms)
    フィルタの値がセルの値と一致しない場合
      ✓ falseを返す
...
Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total

 

ソースコード

Github に上げました。
https://github.com/thinkAmi-sandbox/react_mui_with_vite-sample

今回のPRはこちらです。
https://github.com/thinkAmi-sandbox/react_mui_with_vite-sample/pull/2