はじめに
この記事は kintone Advent Calender 2019 part1 の12日目です。
kintoneにおけるJavaScriptカスタマイズの自動テストについて、環境構築から紹介したいと思います。
自動テストの種類
今回ユニットテスト環境とE2Eテスト環境両方の構築を行います。この二つの環境は共存できますが、一応順番にやっていきたいと思います。
ちなみに、テストフレームワークはJestを使用します。
ユニットテスト環境の構築
パッケージの追加
まず必要なパッケージを追加します。jest と babel-jest は必須ですね。
@babel/preset-envのインストールがまだの人はこちらも追加しましょう
npm install --save-dev jest babel-jest @babel/preset-env
# or
yarn add jest babel-jest
ESLintを利用しているあなたはとても素晴らしい。eslint-plugin-jestを追加しましょう。
TypeScript派のあなたはts-jestを追加してもよいです。
npm install --save-dev jest babel-jest eslint-plugin-jest ts-jest
Jest設定ファイルの追加
jest.config.jsファイルはjestが起動して最初に読まれる設定ファイルです。
以下の内容でプロジェクトのルートに作成して下さい。
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で動作可能な形にトランスパイルします。
const babelJest = require('babel-jest')
const babelOptions = {
presets: [
[
'@babel/preset-env',
{
targets: {
node: 'current',
},
},
],
],
}
module.exports = babelJest.createTransformer(babelOptions)
ここまでのディレクトリ構成は以下のような感じです。
package.jsonやnode_modulesなどは省略しています。テストに関係あるファイルだけ記載してます。
.
├── jest.config.js
└── test
└── config
└── babel.config.js
テストを実行してみる
テスト対象のファイルを作る
まずテストされるファイルを準備します。
// exportしているのでテスト可能
export const fuga = () => {
return 'fuga'
}
// ローカルでのみ参照可能な関数なのでテスト不可
const piyo = () => {
return 'piyo'
}
ここでは単純にfuga文字列を返すだけの関数を定義してexportしています。 基本的にはexportした関数/クラスのみがテスト対象になります。
なので上記のファイルではpiyo関数はテストができません。
.
├── 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ファイルを作成します。
import { fuga } from '../hoge'
describe('テストを実行', () => {
it('fugaが返る', () => {
const result = fuga()
expect(result).toEqual('fuga')
})
})
テストコードの詳しい書き方はJest公式を見て貰いたいですが、サラッと説明します。
基本的な作りとして、itのcallback内にテストを記述します。このitがテストの最小単位です。(itではなくtestと書いても良いです。)
そしてexpect(result).toEqual(actual) 行の結果を以って、テストの合否を判定します。resultに検証したい関数/メソッドの結果を、actualに期待する値を書きます。
ちなみにtoEaualはマッチャと呼ばれ、他にも色々種類があります。適したのを使いましょう。
describeはitをグルーピングする為に使用します。describeの中にdescribeをネストしても良いです。もちろん無くても問題なく動きます。
実行してみる
コンソールで以下のコマンドを実行してみます。
npx jest
# or
yarn jest
Jestがずらずらと起動して、テストが成功しPASS文字が表示されたらOKです!
ここまでのディレクトリ構成は以下の形です。
.
├── hoge.js
├── jest.config.js
└── test
├── hoge.test.js
└── config
└── babel.config.js
kintone特有の問題に対処する
ここまでは特にkintone固有の設定などは無く、ある意味普通にJestの環境構築をしただけです。
しかし実際テストを書こうとすると、kintone特有のテストし辛さというものがあります。
ここではその対処方法をいくつか紹介したいと思います。
kintoneオブジェクトがない
先の例では関数をexportした例でしたが、実際多くのkintoneカスタマイズはkintone.eventsを起点として書かれていると思います。
kintone.events.on('app.record.edit.show', event => {
event.record.piyo.value = 'piyo'
return event
})
まずこのようなファイルをテストコードでimportすると、kintone not definedエラーを吐いてしまいます。
node.js環境ではグローバル変数としてkintoneが存在しないので、これはしょうがないですね。
無いので作る
グローバル変数にkintoneが無いので、作ってしまいましょう。
global.kintone = { events: { on: function() {} }}
何も実行されないkintone.events.on関数を作ってグローバルに登録してしまいます。
そしてこのコードは、テスト対象ファイルをimportするよりも前に実行されている必要があるので、テストファイルの一番最初にimportされるようにします。
import './config/jest.ignore.js'
import { setPiyo } from '../sample1'
describe('テストを実行', () => {
イベントのロジックをテストしたい
global.kintoneを作ってしまうことでJestはエラーで落ちなくなりましたが、kintone.events.onが無視されるようになっただけで、中身のテストができていません。
ではどうするか。ロジックを関数に切り出しましょう。
kintone.events.on('app.record.edit.show', event => {
setPiyo(event.record)
return event
})
export const setPiyo = record => {
record.piyo.value = 'piyo'
return record
}
テストコードではexportしたロジックのテストのみを実行します。
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があっても上記のパターンでは検知できません。
// タイポしてるので発火しない
kintone.events.on('app.record.edit.sohw', event => {
個人的にはこの辺がユニットテストの限界で、上記をカバーするには後述のE2Eテストが適切かなと考えています。
kintoneAPIを呼べない
もう一つ、よくある詰まるパターンとしてはカスタマイズの中でkintone APIを呼び出しているパターンです。
まず前提ですが、ユニットテストからはkintone APIを呼び出せませんし、仮にできても呼び出さない方がよいです。
原則として自動テストは冪等性を持つべきです。同じテスト対象のファイルとテストコードで実行したら、毎回同じ結果が返ってくるのが良い状態です。ユニットテストの中でkintone APIを呼んでしまうと、どこかのkintoneアプリの状態によってはテストが通ったり通らなかったりすることになってしまいますので、自動テストとしては良くない状態になってしまいます。
kintone APIを呼び出す処理があったとしても、テスト対象の関数にはその処理を含めない内容にしましょう。
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ログインユーザーのセッション権限で実行する場合は以下のようなコードを書くと思います。
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が利用できます。 これは既存のオブジェクトを、テスト実行時のみ全く異なるオブジェクトに置き換えてしまう機能になります。
例えば以下のように記述します。
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ではオブジェクトを置き換えることができるので、テスト用に特定の値を返却するようにすることも可能です。
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に書いてありますが、少しだけなので書いていきます。
npm add --save-dev jinzo-ningen jest jest-puppeteer
# or
yarn add --dev jinzo-ningen jest jest-puppeteer
jest.config.jsへ追記
これだけで準備はOKです。
module.exports = {
transform: {
'^.+\\.js$': './test/config/babel.config.js',
},
moduleFileExtensions: ['js', 'ts', 'vue'],
verbose: true,
preset: 'jest-puppeteer',
}
テストファイルを作る
テストファイルの構成はユニットテストと同様ですが、いくつかポイントがあるのでかいつまんで説明します。
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を使ってドメイン名・ユーザー名・パスワードを読み込んでいます。
const { TEST_DOMAIN: domain, TEST_USERNAME: username, TEST_PASSWORD: password } = process.env
テストコードはGithubなどで管理することが多いと思います。その時にkintoneのドメイン名・ユーザー名・パスワードがベタ書きされているとマズいので、テストコードには含めないようにしましょう。
timeoutの設定
jest.setTimeout(60 * 1000)
Jestのtimeoutはデフォルトで5000msですが、E2Eテストは非常に遅いので当然(?)5秒では終わりません。そのためtimeoutを延ばしています。何秒程度が適切かはテストの量にもよりますので、実行しながら調整していって下さい。
テストデータの追加と削除
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に限らず自動テストは環境構築を含めて「最初の一歩」がなかなか億劫な分野だと思っています。
そんな人たちの後押しに、少しでもなれば幸いです!