Nuxt.js + TypeScript +Jest でテストをする時に詰まったエラーと対応方法

# はじめに

Nuxt.jsとTypeScriptで構築したプロジェクトに、Jestでテストを書く事になりました。
その際に引っかかった部分についてまとめています。
まだしばらくJestでテストを書く予定なので、順次追記していきます。

# 初期設定

## i18nの文字列が取得できない
`this.$t`はfunctionではないというエラーが表示され失敗するパターン

```
console.error
  [Vue warn]: Error in render: "TypeError: this.$t is not a function"
```

この場合は$tにMockを設定することで回避が可能です。
また、i18nはプロジェクト全体で使用していることが多いと思うので、グローバルなMockに設定すると良いです。

### jest起動時に実行する処理の設定

ファイル名は何でも良いのですが、ここでは`jest.setup.ts`というファイル名で`jest.config.ts`と同階層に作成します。
`jest.setup.ts`を`jest.config.ts`の`setupFiles`というプロパティに設定することで、jest起動時に呼び出すように指定します。

```typescript:jest.config.ts
import type { Config } from '@jest/types'

const config: Config.InitialOptions = {
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/$1',
    '^~/(.*)$': '<rootDir>/$1',
    '^vue$': 'vue/dist/vue.common.js',
  },
  moduleFileExtensions: ['ts', 'js', 'vue', 'json'],
  transform: {
    '^.+\\.ts$': 'ts-jest',
    '^.+\\.js$': 'babel-jest',
    '.*\\.(vue)$': 'vue-jest',
  },
  setupFiles: ['./jest.setup.ts'], // 追加
}

export default config
```

`jest.setup.ts`では、i18nのMockを作成して`config.mocks`に設定します。
これで全てのjestファイルのmockにi18nが適用されるようになります。
`i18nMock`の処理は、単純に渡されたkeyに対してjsonから文字列を返しているだけのメソッドです。

```typescript:jest.setup.ts
import { config } from '@vue/test-utils'
import locales from '@/assets/json/locales/ja.json'

// i18nのモックを追加
const i18nMock = (key: string) => locales[key as keyof typeof locales]

const mocks = {
  $t: i18nMock,
}
config.mocks = mocks
```

## nuxt-linkが解決できない

nuxt-linkも同様にstubに置き換える必要があります。
こちらも先程のjest.setup.tsに設定を追加すると良いです。
nuxt-linkには、@vue/test-utilsからRouterLinkStubというものが提供されているため、それをimportしてきて使用します。

```typescript:jest.setup.ts
import { config, RouterLinkStub } from '@vue/test-utils'
:
省略
:
// nuxt-linkのstubを設定
config.stubs['nuxt-link'] = RouterLinkStub

```

# 基本的な使用方法

## dataが呼び出せない

dataを触る場合は、`wrapper.vm.$data`を参照します。

```typescript
expect(wrapper.vm.$data.hoge).toBe('abc')
```

## methodやcomputedが呼び出せない

methodやcomputedはdataと異なりvmから提供されていないため、vmをanyにキャストするか、
変数定義の際に型情報を追加してあげる必要があります。

キャストする方法は単純にメソッドを呼び出す時にanyにキャストします。

```typescript:anyにキャストする方法
expect((wrapper.vm as any).increment(3)).toBe(3)
```

型情報を追加する方法は、変数宣言時に `& { [key: string]: any }`とすることで、
vmにVueクラスのメソッド以外にも存在することを定義します。
こちらの方が、Vueクラスのプロパティが消えないことや、1箇所の定義ですむことからより良いかと思います。

```typescript:型情報を追加する方法
let wrapper: Wrapper<Component & { [key: string]: any }>
wrapper = shallowMount(Component, { propsData: { ...defaultProps } })
expect(wrapper.vm.increment(3)).toBe(3)
```

個人的には、factoryメソッドを作成して、その中でwrapperを作成するのがおすすめです。

```typescript:factoryを作成する方法
// wrapperを生成するfactory
const factory = (propsData?: PropsDataInterface) =>
  shallowMount<Component & { [key: string]: any }>(Component, {
    propsData: { ...defaultProps, ...propsData },
  })

// wrapperにデフォルト値から変更するプロパティのみ渡す
const wrapper = factory({ ...defaultProps, items })
```

## slotに値を入れる

slotに値を入れるには、shallowMountの第２引数にslotsというプロパティを渡すと設定ができます。
slot名は指定しない場合は`default`となるため、`slots: { default: 'slot contents' },`という値を設定すると反映されます。

```typescript
describe('BasicButton', () => {
  const factory = (propsData?: PropsDataInterface) =>
    shallowMount<Component & { [key: string]: any }>(Component, {
      propsData: { ...defaultProps, ...propsData },
      slots: { default: 'slot contents' },
    })

  test('slotにコンテンツが挿入できること', () => {
    const wrapper = factory()
    expect(wrapper.html()).toBe('<button>slot contents</button>')
  })
})
```

## Emitを検知する

Emitがされたかを検知するには、`wrapper.emitted('emit名')`を使用すると検知が出来ます。
emittedの戻りは２次元配列で表現されており、`emit発火 * emitの内容`となります。

```typescript

await wrapper.vm.click()
await wrapper.vm.click()
wrapper.emitted('success')
/*
[
  [
    "1回目のEmitの引数",
    "1回目のEmitの戻り値",
  ],
  [
    "2回目のEmitの引数",
    "2回目のEmitの戻り値",
  ],
]
*/
expect(wrapper.emitted('success')).toStrictEqual([[true, true], [true, true]])
// emitされなかった場合はundefinedが設定される
expect(wrapper.emitted('failed')).toBeUndefined()
```

# 応用/エラー対応

## [Vue warn]: Unknown custom element

テスト対象の.vueファイルにコンポーネントが存在する場合に発生しました。
対応としては、shallowMountの際に`stubs`を設定することで回避ができます。

```
 console.error
    [Vue warn]: Unknown custom element: <FooComponent> - did you register the component correctly? For recursive components, make sure to provide the "name" option.
```

```typescript
const factory = (propsData?: PropsDataInterface) =>
  shallowMount<Component & { [key: string]: any }>(Component, {
    propsData: { ...defaultProps, ...propsData },
    // stubを追加、コンポーネント名の文字列を渡す
    stubs: ['FooComponent'],
  })
```

## this.$router.pushの確認

$router.pushにMockを設定するが、Mockに`jest.fn()`を使用します。
`jest.fn()`を使用することで、実行された回数や引数などが取得できるようになります。

```typescript
// $router.pushのモックを定義
const pushMock = jest.fn()
const $router = { push: pushMock }


const factory = (propsData?: PropsDataInterface) =>
  shallowMount<Component & { [key: string]: any }>(Component, {
    propsData: { ...defaultProps, ...propsData },
    // mockを追加
    mocks: { $router },
  })

test('クリック時の動作', async () => {
  // toというPropにpathを渡すとそのpathに$router.pushするコンポーネント
  const wrapper = factory({ to: '/hoge' })
  // buttonタグを探してクリック
  const button = wrapper.find('button')
  await button.trigger('click')
  // $router.pushが1回呼ばれたことの確認
  expect(pushMock.mock.calls.length).toBe(1)
  // 引数が '/hoge' であることの確認
  expect(pushMock).toHaveBeenCalledWith('/hoge')
})
```

---
参考記事
- [How to use typescript correctly?](https://github.com/vuejs/vue-test-utils/issues/255)
- [【Vue】router.pushメソッドのテストの書き方](https://www.shotoro.com/entry/2020/04/10/182410)