0から始めるkintone自動テスト

# はじめに

この記事は kintone Advent Calender 2019 part1 の12日目です。

kintoneにおけるJavaScriptカスタマイズの自動テストについて、環境構築から紹介したいと思います。

## 自動テストの種類

今回ユニットテスト環境とE2Eテスト環境両方の構築を行います。この二つの環境は共存できますが、一応順番にやっていきたいと思います。
ちなみに、テストフレームワークは[Jest](https://jestjs.io/ja/)を使用します。

# ユニットテスト環境の構築

## パッケージの追加

まず必要なパッケージを追加します。`jest` と `babel-jest` は必須ですね。
`@babel/preset-env`のインストールがまだの人はこちらも追加しましょう

```shell
npm install --save-dev jest babel-jest @babel/preset-env
# or
yarn add jest babel-jest
```

ESLintを利用しているあなたはとても素晴らしい。`eslint-plugin-jest`を追加しましょう。
TypeScript派のあなたは`ts-jest`を追加してもよいです。

```shell
npm install --save-dev jest babel-jest eslint-plugin-jest ts-jest
```

## Jest設定ファイルの追加

`jest.config.js`ファイルはjestが起動して最初に読まれる設定ファイルです。
以下の内容でプロジェクトのルートに作成して下さい。

```js:jest.config.js
module.exports = {
  transform: {
    '^.+\\.js$': './test/config/babel.config.js',
  },
  moduleFileExtensions: ['js', 'ts', 'vue'],
  verbose: true,
  preset: 'ts-jest' // TypeScriptの場合はこの行が必用
}
```

## Babel設定ファイルの追加

`babel.config.js`ファイルはJest設定ファイルから呼び出され、テストコードのトランスパイルを行います。
以下の内容で `/test/config/babel.config.js` に作成して下さい。

テストコードやテスト対象コードでimport/export構文を利用している場合、Jestを実行使用する際にエラーになってしまいます。（素のnode.jsで動作するため）
その為、babelを利用してnode.jsで動作可能な形にトランスパイルします。

```js:babel.config.js
const babelJest = require('babel-jest')
const babelOptions = {
  presets: [
    [
      '@babel/preset-env',
      {
        targets: {
          node: 'current',
        },
      },
    ],
  ],
}
module.exports = babelJest.createTransformer(babelOptions)
```

ここまでのディレクトリ構成は以下のような感じです。
`package.json`や`node_modules`などは省略しています。テストに関係あるファイルだけ記載してます。

```shell
.
├── jest.config.js
└── test
    └── config
        └── babel.config.js
```

## テストを実行してみる

### テスト対象のファイルを作る

まずテスト**される**ファイルを準備します。

```js:hoge.js
// exportしているのでテスト可能
export const fuga = () => {
  return 'fuga'
}

// ローカルでのみ参照可能な関数なのでテスト不可
const piyo = () => {
  return 'piyo'
}
```

ここでは単純にfuga文字列を返すだけの関数を定義して`export`しています。 基本的には`export`した関数/クラスのみがテスト対象になります。
なので上記のファイルでは`piyo`関数はテストができません。

```shell
.
├── hoge.js
├── jest.config.js
└── test
    └── config
        └── babel.config.js
```

### テストファイルを作る

つぎにテストコードを書くファイルを用意します。

Jestでは以下の2つの方法でテストのファイルを探します。 どちらかに合致する場合、Jestの実行時に自動的に読み込まれます。

- 末尾が.test.jsのファイル `ex) hoge.test.js`
  - このファイルはプロジェクトルート配下であればどの場所にあってもよいです。
- `__tests__`フォルダの配下にあるファイル `ex) __tests__/hoge.js`
  - このフォルダはプロジェクトルートの直下に必要です。

今回は末尾が`.test.js`の形で進めていきます。（好みなのでどちらでも良いです）
`/test`ディレクトリ配下に`hoge.test.js`ファイルを作成します。

```js:hoge.test.js
import { fuga } from '../hoge'

describe('テストを実行', () => {
  it('fugaが返る', () => {
    const result = fuga()
    expect(result).toEqual('fuga')
  })
})
```

テストコードの詳しい書き方は[Jest公式](https://jestjs.io/docs/ja/getting-started)を見て貰いたいですが、サラッと説明します。

基本的な作りとして、`it`のcallback内にテストを記述します。この`it`がテストの最小単位です。（`it`ではなく`test`と書いても良いです。）
そして`expect(result).toEqual(actual)` 行の結果を以って、テストの合否を判定します。`result`に検証したい関数/メソッドの結果を、`actual`に期待する値を書きます。

ちなみに`toEaual`はマッチャと呼ばれ、他にも[色々種類があります。](https://jestjs.io/docs/ja/using-matchers)適したのを使いましょう。

`describe`は`it`をグルーピングする為に使用します。`describe`の中に`describe`をネストしても良いです。もちろん無くても問題なく動きます。

### 実行してみる

コンソールで以下のコマンドを実行してみます。

```shell
npx jest
# or
yarn jest
```

Jestがずらずらと起動して、テストが成功し`PASS`文字が表示されたらOKです！

<img width="338" alt="スクリーンショット 2019-12-11 22.45.39.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204683/b19a4549-1e19-9d81-c7bc-c37ef53ed5bc.png">

ここまでのディレクトリ構成は以下の形です。

```shell
.
├── hoge.js
├── jest.config.js
└── test
    ├── hoge.test.js
    └── config
        └── babel.config.js
```

# kintone特有の問題に対処する

ここまでは特にkintone固有の設定などは無く、ある意味普通にJestの環境構築をしただけです。
しかし実際テストを書こうとすると、kintone特有の**テストし辛さ**というものがあります。
ここではその対処方法をいくつか紹介したいと思います。

## kintoneオブジェクトがない

先の例では関数を`export`した例でしたが、実際多くのkintoneカスタマイズは`kintone.events`を起点として書かれていると思います。

```js:sample1.js
kintone.events.on('app.record.edit.show', event => {
  event.record.piyo.value = 'piyo'
  return event
})
```

まずこのようなファイルをテストコードで`import`すると、`kintone not defined`エラーを吐いてしまいます。
node.js環境ではグローバル変数として`kintone`が存在しないので、これはしょうがないですね。

### 無いので作る

グローバル変数に`kintone`が無いので、作ってしまいましょう。

```js:/test/config/jest.ignore.js
global.kintone = { events: { on: function() {} }}
```

何も実行されない`kintone.events.on`関数を作ってグローバルに登録してしまいます。

そしてこのコードは、テスト対象ファイルを`import`するよりも前に実行されている必要があるので、テストファイルの一番最初に`import`されるようにします。

```js:/test/sample1.test.js
import './config/jest.ignore.js'
import { setPiyo } from '../sample1'

describe('テストを実行', () => {
```

### イベントのロジックをテストしたい

`global.kintone`を作ってしまうことでJestはエラーで落ちなくなりましたが、`kintone.events.on`が無視されるようになっただけで、中身のテストができていません。

ではどうするか。ロジックを関数に切り出しましょう。

```js:sample1.js
kintone.events.on('app.record.edit.show', event => {
  setPiyo(event.record)
  return event
})

export const setPiyo = record => {
  record.piyo.value = 'piyo'
  return record
}
```

テストコードでは`export`したロジックのテストのみを実行します。

```js:/test/sample1.test.js
import './config/jest.ignore.js'
import { setPiyo } from '../sample1'

describe('テストを実行', () => {
  it('piyoが返ること', () => {
    const result = setPiyo({ piyo: { value: null } })
    expect(result.piyo.value).toEqual('piyo')
  }
}
```

このように`kintone.events.on`の中に処理を全て書くのではなく、関数に切り出すことでテストを実施することができます。

しかしそもそもイベントが正常に発火するかどうかなどはテストできないのでは？と思われるかもしれません。例えばイベントの記述にtypoがあっても上記のパターンでは検知できません。

```js
// タイポしてるので発火しない
kintone.events.on('app.record.edit.sohw', event => {
```

個人的にはこの辺がユニットテストの限界で、上記をカバーするには後述の**E2Eテスト**が適切かなと考えています。

## kintoneAPIを呼べない

もう一つ、よくある詰まるパターンとしてはカスタマイズの中で**kintone API**を呼び出しているパターンです。

まず前提ですが、**ユニットテストからはkintone APIを呼び出せません**し、仮にできても**呼び出さない方がよいです。**

原則として自動テストは[冪等性](https://qiita.com/suin/items/316cb8aaf8dfcf11abae)を持つべきです。同じテスト対象のファイルとテストコードで実行したら、毎回同じ結果が返ってくるのが良い状態です。ユニットテストの中でkintone APIを呼んでしまうと、どこかのkintoneアプリの状態によってはテストが通ったり通らなかったりすることになってしまいますので、自動テストとしては良くない状態になってしまいます。

kintone APIを呼び出す処理があったとしても、テスト対象の関数にはその処理を含めない内容にしましょう。

```js:sample2.js
kintone.events.on('app.record.edit.show', event => {
  setPiyo(event.record)
  // kintone APIの呼び出しはテスト対象の関数に含めない
  kintone.api(kintone.api.url('/k/v1/record', true), 'GET', { app: 1, id: 1 }, resp => {
    console.log(resp.record)
  }
  return event
})

export const setPiyo = record => {
  record.piyo.value = 'piyo'
  return record
}
```

### '@kintone/js-sdk'が呼べない

`kintone.api`を使ってAPIを呼び出している場合は上記のように関数を分ければ良いのですが、`@kintone/js-sdk`（`kintone-utility`）を利用している場合は別の詰まりポイントがあります。

例えば`@kintone/js-sdk`をkintoneログインユーザーのセッション権限で実行する場合は以下のようなコードを書くと思います。

```js:sample3.js
import { Record } from '@kintone/js-sdk'

const kintoneRecord = new Record()

kintone.events.on('app.record.edit.show', event => {
  kintoneRecord.getRecord({ app: 1, id: 1 }).then(resp => {
    console.log(resp.record)
  })
  return event
})
```

このJavaScriptはkintone上では動作しますが、Jestで読み込むとエラーになります。

`kintone-js-sdk`はnode.js環境でも動作しますが、そこにセッション権限というものは存在しない為、`Record`クラスをnewするために`Auth`と`Connection`が必須になるからです。

同様（？）に、`kintone-utility`の場合はライブラリ自体がES6で記述されているので、`import`の時点でJestがエラーになってしまいます。

#### jest.mockで回避

`kintone-js-sdk`や`kintone-utility`に限らず、外部モジュールが原因でテストが動かないということはおおいにあり得ます。

そこで、これらを回避するために`jest.mock`が利用できます。 これは既存のオブジェクトを、テスト実行時のみ全く異なるオブジェクトに置き換えてしまう機能になります。

例えば以下のように記述します。

```js
import './config/jest.ignore.js'
import { piyo } from '../fuga'

jest.mock('@kintone/kintone-js-sdk', () => ({
  Record: class Record {}
}))

describe('テストを実行', () => {
```

この場合、`@kintone/kintone-js-sdk`の`Record`クラスを`class Record{}`（中身が空のクラス）に置き換えています。 そうすると、本来`new Record()`されて内部で落ちるところがあったとしても、空のクラスの`new Record()`に置き換わるので 動きはしないけどエラーも起きない状態にすることができます。

#### 擬似的にAPIのレスポンスを作る

`jest.mock`ではオブジェクトを置き換えることができるので、テスト用に特定の値を返却するようにすることも可能です。

```js
jest.mock('@kintone/kintone-js-sdk', () => ({
  Record: class Record {
    async getAllRecordsByQuery() {
      return {
        records: [
          { $id: { type: '__ID__', value: '1' } },
          { $id: { type: '__ID__', value: '2' } },
          { $id: { type: '__ID__', value: '3' } },
        ],
      }
    }
  },
}))
```

このようにすると擬似的な値を返すようになります。 （`getAllRecordsByQuery`の引数は一切無視され、記述された値が返される）

このように`jest.mock`で擬似的なレスポンスを作成することができるなら、kintone APIをテスト対象から分離しなくてもよいのでは？と思われるかもしれません。
しかし`jest.mock`で擬似的なレスポンスをテスト毎に記述すること自体がコスト高めな行為になりますし、APIのテストができているわけでもありません。そのため、原則は分離する。どうしても分離できない場合は`jest.mock`で逃げる。という形が良いと思います。

# E2Eテスト環境の構築

## kintoneにおけるE2Eテストとは

モダンなフレームワークに付属するテストツールでE2Eテストを行う場合、ローカルにアプリケーションサーバーを立てて利用することが一般的かと思いますが、kintoneの場合はローカルにサーバーを立てるということができません。（`docker`とかで立てれたら最高だなーと常々思ってます。なんとかなりませんかね？）

ないものはしょうがないので、kintoneにテスト用の環境（スペースなど）を用意して、そこに対してE2Eテストを実施します。

## jinzo-ningen

kintoneでのE2Eテストを簡易に行うツールとして、`jinzo-ningen`を利用します。
https://github.com/goqoo-on-kintone/jinzo-ningen

jinzo-ningenは僕と @latica で作成した kintone用のpuppeteerラッパーです。
ちなみに命名は @the_red です。[PR]

## パッケージの追加

導入方法はREADMEに書いてありますが、少しだけなので書いていきます。

```shell
npm add --save-dev jinzo-ningen jest jest-puppeteer
# or
yarn add --dev jinzo-ningen jest jest-puppeteer
```

## jest.config.jsへ追記

これだけで準備はOKです。

```js:jest.config.js
module.exports = {
  transform: {
    '^.+\\.js$': './test/config/babel.config.js',
  },
  moduleFileExtensions: ['js', 'ts', 'vue'],
  verbose: true,
  preset: 'jest-puppeteer',
}
```

## テストファイルを作る

テストファイルの構成はユニットテストと同様ですが、いくつかポイントがあるのでかいつまんで説明します。

```js:e2e.test.js
import jz from 'jinzo-ningen'

const { TEST_DOMAIN: domain, TEST_USERNAME: username, TEST_PASSWORD: password } = process.env
const App = {
  sample: 1,
}

jest.setTimeout(60 * 1000)

beforeAll(async () => {
  await jz.login({ domain, username, password })
  await jz.upload({ domain, appId: App.sample }, 'test/csv/sample.csv')
})

afterAll(async () => {
  await jz.delete({ domain, appId: App.sample })
})

it('sampleアプリが開けること', async () => {
  await jz.gotoIndexPage(domain, App.sample)
  expect(page.url()).toEqual('https://xxxx.cybozu.com/k/1/')
})
```

### ログイン情報の読み込み

ここでは`dotenv`を使ってドメイン名・ユーザー名・パスワードを読み込んでいます。

```js
const { TEST_DOMAIN: domain, TEST_USERNAME: username, TEST_PASSWORD: password } = process.env
```
テストコードはGithubなどで管理することが多いと思います。その時にkintoneのドメイン名・ユーザー名・パスワードがベタ書きされているとマズいので、テストコードには含めないようにしましょう。

### timeoutの設定

```js
jest.setTimeout(60 * 1000)
```

Jestのtimeoutはデフォルトで`5000ms`ですが、E2Eテストは非常に遅いので当然（？）5秒では終わりません。そのためtimeoutを延ばしています。何秒程度が適切かはテストの量にもよりますので、実行しながら調整していって下さい。

### テストデータの追加と削除

```js
beforeAll(async () => {
  await jz.login({ domain, username, password })
  await jz.upload({ domain, appId: App.sample }, 'test/csv/sample.csv')
})

afterAll(async () => {
  await jz.delete({ domain, appId: App.sample })
})
```

`beforeAll`でテスト開始前にログインとテストデータのアップロードを、`afterAll`でテストデータの全削除を行なっています。

E2Eテストでは実際のkintone環境を操作するので、前述の**冪等性**を維持することが難しいです。ここではテストに利用するデータをテストコードの中でアップロードし、テストコードの中で削除することによって、Jest実施前と実施後でkintoneアプリの状態が変わらないようにしています。

例外として、アプリのレコード番号だけはテストを実施する度に変わってしまいます。そのため、レコード番号に依存したロジック・テストを避けるようにしましょう。
（kintoneのレコード番号の採番はユーザー側でコントロールできないので、テストの有無に関わらずレコード番号に依存したロジックは避けた方が良いと思います）

## E2Eテストでは何をテストすべきか

ユニットテストとE2Eテスト二つの環境構築を紹介しましたが、**じゃあどちらで書けばいいのか？**迷いますよね。

結論から言うと、**原則ユニットテスト**で書くべきだと思います。
一番の理由は、E2Eテストはテストを書くこと自体のコストが高めで、かつテストが非常に壊れやすい性質があるからです。

ただユニットテストでは空のグローバル変数を用意したり、`jset.mock`で逃げたりと割と多めの制約があるのに対し、E2Eテストでは一切の制約がないことが強みです。
そのため、kintoneイベントの発火やAPI連携を含む一連の処理のテストをE2Eテストで行い、細かい条件分岐などのテストはユニットテストで行うようにするとコスパが良いのかなと思います。

# さいごに

kintoneに限らず自動テストは環境構築を含めて「最初の一歩」がなかなか億劫な分野だと思っています。
そんな人たちの後押しに、少しでもなれば幸いです！