kintoneは現場の担当者が業務システムを構築でき、業務の変更にも迅速に対応しやすいという特徴があります。
プラグインやJavaScriptカスタマイズもそれに合わせて迅速に対応できれば、継続的な改善を進めやすくなります。
本記事では迅速に対応するために重要な自動テストについて紹介します。
自動テストは単体テスト、結合テスト、E2Eテストなど目的に応じたさまざまなテストが存在します。
本記事では単体テストとE2Eテストを取り上げます。単体テストは
Vitest
、E2Eテストでは
Playwright
を利用します。
- TypeScriptの知識がある方
- VitestやPlaywrightの公式ドキュメントを確認しながら作業を進められる方
単体テストはモジュール単体が提供する機能に着目したテストです。
Vitest
のインストールや設定は公式サイトのドキュメントを確認してください。
テスト対象となる関数の説明
固定リンクがコピーされました
ログインユーザーが特定の組織に所属しているか否かを判定する関数をテストします。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
// 組織情報に関する型定義
export interface OrganizationTitle {
organization: {
id: string;
code: string;
name: string;
localName?: string;
localNameLocale?: string;
};
title?: {
name: string;
};
}
|
1
2
3
4
5
6
7
|
export const isBelongsToSpecificOrg = async (orgs: string[]) => {
const user = kintone.getLoginUser();
const {organizationTitles} = await kintone.api('/v1/user/organizations.json', 'GET', {code: user.code});
return organizationTitles.some((organizationTitle: OrganizationTitle) => {
return orgs.includes(organizationTitle.organization.code);
});
};
|
ユーザーの所属組織コードが許可組織リストに含まれるケースを作成します。
1
2
3
4
5
6
7
|
describe('isBelongsToSpecificOrg (外部API依存版)', () => {
it('ユーザーの所属組織コードが許可組織リストに含まれる場合はtrueを返す', async () => {
const allowedOrgs = ['org1', 'org2', 'org3'];
const result = await isBelongsToSpecificOrg(allowedOrgs);
expect(result).toBe(true);
});
});
|
次のコマンドでテストを実行します。
kintone
という未定義の変数によりエラーが発生しています。
これはテストランナーがシミュレートしたブラウザー環境と実際のkintone環境の差異があることに起因します。
Vitestなどのテストランナーは基本的にNode.js環境で実行され、仮想DOMを使用してブラウザー環境をシミュレートしますが、kintone環境特有のグローバルオブジェクトkintone
はこの環境には含まれていません。
このように、kintoneカスタマイズにおいてはkintone
オブジェクトを考慮せず実装した場合、テスタビリティ(テストのしやすさ)が低下します。
テスト対象となる関数の修正
固定リンクがコピーされました
isBelongsToSpecificOrg
関数をテスタビリティの観点から改善します。
現在の関数は関数内で外部依存のkintone
オブジェクトを直接参照しているという問題があります。
kintone
に依存したコードが混在している関数を実装するとテストは困難になります。
テストしやすいコード設計の基本は、外部サービスやグローバル変数などの外部依存を最小化し、関数の入力と出力だけで挙動を把握できる純粋なロジックを設計することです。
そこで、isBelongsToSpecicOrg
関数は外部依存と純粋なロジックを分離し、テスタビリティの高いコードに再設計します。
まず、kintone JS APIを呼び出してログインユーザーの組織情報を取得するロジックを分離します。
1
2
3
4
5
6
7
8
9
|
export const getOrganizationTitles = async (userCode: string) => {
try {
const {organizationTitles} = await kintone.api('/v1/user/organizations.json', 'GET', {code: userCode});
return organizationTitles as OrganizationTitle[];
} catch (error) {
console.error(error);
return [];
}
};
|
次に、ログインユーザーが指定した組織に所属しているかを判定するロジックを分離します。
1
2
3
4
5
|
export const isBelongsToSpecificOrg = (orgs: string[], organizationTitles: OrganizationTitle[]) => {
return organizationTitles.some((organizationTitle: any) => {
return orgs.includes(organizationTitle.organization.code);
});
};
|
外部依存している箇所は内部で呼び出さず引数として与える設計にすることで、isBelongsToSpecicOrg
関数を純粋関数にしました。
この関数は引数と戻り値のみで完結しており参照透過です。
次に、テストケースについて考えます。
getOrganizationTitles
関数はkintone.api
を呼び出し、結果を返すというシンプルなラッパー関数です。
外部API自体の動作を検証することが中心となるため今回は取り扱いません。
純粋関数isBelongsToSpecificOrg
をテストします。
基本的なケースを中心に単体テストコードを作成すると次のようになります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
|
/*
* isBelongsToSpecificOrg sample program
* Copyright (c) 2025 Cybozu
*
* Licensed under the MIT License
* <https://opensource.org/license/mit/>
*/
describe('isBelongsToSpecificOrg', () => {
it('ユーザーの所属組織コードが許可組織リストに含まれる場合はtrueを返す', async () => {
const allowedOrgs = ['org1', 'org2', 'org3'];
const userOrganizationTitles: OrganizationTitle[] = [
{
organization: {
id: '1',
code: 'org1',
name: '営業部',
},
},
{
organization: {
id: '2',
code: 'org4',
name: '総務部',
},
title: {
name: 'マネージャー',
},
},
];
const result = isBelongsToSpecificOrg(allowedOrgs, userOrganizationTitles);
expect(result).toBe(true);
});
it('ユーザーの所属組織コードが許可組織リストに含まれない場合はfalseを返す', async () => {
const allowedOrgs = ['org1', 'org2', 'org3'];
const userOrganizationTitles: OrganizationTitle[] = [
{
organization: {
id: '2',
code: 'org4',
name: '総務部',
},
},
{
organization: {
id: '3',
code: 'org5',
name: '人事部',
},
},
];
const result = isBelongsToSpecificOrg(allowedOrgs, userOrganizationTitles);
expect(result).toBe(false);
});
});
|
再度テストを実行します。
テストが成功しました。
今回は外部依存部分とビジネスロジック部分を分離することで、ビジネスロジックの単体テストが容易になり、テスタビリティが向上したこと確認しました。
なお、結合テスト(システム内の複数のコンポーネントやモジュールの統合が正しく動作しているかを検証するテスト)の場合も、単体テストと同様に外部依存を切り離すことを推奨します。
外部依存を分離できない場合
固定リンクがコピーされました
本来、外部システムへの依存(今回のkintoneグローバルオブジェクトなど)はビジネスロジックから分離し、コンストラクタや関数の引数を通じて依存を注入することがテスタビリティの観点では理想です。
しかし、既存実装の都合などで依存をコード外へ抽出しづらい場合は、テストランナー側でグローバル変数をスタブ化するメソッドを使って外部依存を差し替え、テストダブル(スタブ/モック)として振る舞わせることが現実的な妥協策になります。
次はリファクタリングを前のisBelongsToSpecificOrg
を例としたサンプルコードです。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
/*
* getOrganizationTitles sample program
* Copyright (c) 2025 Cybozu
*
* Licensed under the MIT License
* <https://opensource.org/license/mit/>
*/
describe('getOrganizationTitles', () => {
afterEach(() => {
// テスト後にグローバルスタブをクリーンアップ
vi.unstubAllGlobals();
});
it('ユーザーの所属組織コードが許可組織リストに含まれる場合はtrueを返す', async () => {
const mockUser = {
code: 'user001',
};
const targetOrgs = ['Sales', 'Marketing'];
vi.stubGlobal('kintone', {
api: vi.fn().mockResolvedValue({
organizationTitles: [
{
organization: {
id: '1',
code: 'Sales',
name: '営業部',
},
},
],
}),
getLoginUser: vi.fn().mockReturnValue(mockUser),
});
const result = await isBelongsToSpecificOrg(targetOrgs);
expect(result).toBe(true);
});
// 他のテストケース
// test()
});
|
グローバル変数をスタブ化するvi.stabGrobal
を利用し、kintone.getLoginUser
とkintone.api
のスタブを用意しています。
E2Eテストは、システムすべてのレイヤー(関数/UIコンポーネント/DB操作等)を通して実施するテストを指します。
このセクションでは特定アプリの全レコードに対してレコードコメントを一括で投稿する機能を検証していきます。
この機能は単体テストのセクションで実装したisBelongsToSpecificOrg
を利用し、特定組織(例:情報システム部)に所属するユーザーのみが利用できるような仕様と仮定します。
Playwright
のインストール手順は公式サイトのドキュメントを確認してください。
テストシナリオの作成
固定リンクがコピーされました
テストシナリオとは特定用途でユーザーがアプリケーションを操作するシナリオを記述したものです。
レコードコメントの一括投稿機能のテストシナリオを作成し、システム全体が期待どおりに動作するかを確認します。
テストシナリオ作成はユーザーがサービスを操作していることを仮定して設計し、ユーザー視点で「何をしたいか」を軸にシナリオを組み立てると、要素選択や画面遷移などが変更されてもテストを大きく壊さずに対応しやすくなります。
今回は例外やエラーの状態のないデフォルトのシナリオを検証します。
テストシナリオを作成するために、まずはユーザー視点の機能動作を確認します。
-
ログインユーザーが特定組織に所属する場合はボタンが表示されます。
-
ボタンを押下するとダイアログが表示され、任意のメッセージを入力できます。
-
ダイアログの送信ボタンを押すと、アプリ内のレコードに対してコメントを投稿します。
ユーザー視点での機能動作が確認できたので、検証する機能の通常フローをもとにテストシナリオを作成します。
- 特定組織に所属するユーザーとしてログインする。
- テスト対象となるアプリに移動する。
- 「レコードコメントを投稿する」ボタンを押下する。
- 表示されたダイアログ内のテキストボックスに文字列を入力する。
- ダイアログ内の「投稿する」ボタンを押下し、レコードコメントを投稿する。
テスト実行に関する設定
固定リンクがコピーされました
次にplaywright.config.ts
を作成し、playwrightのテスト実行に関する設定を定義します。
1
2
3
4
5
6
7
8
9
10
|
import {defineConfig} from '@playwright/test';
export default defineConfig({
testDir: './tests/playwright',
use: {
baseURL: 'https://sample.cybozu.com',
},
workers: 1,
fullyParallel: false,
});
|
testDir
ではテストに含めるファイルのパスパターンを定義します。
baseURL
を設定するとpage.goto('/some-path')
のような相対パスによるナビゲーションがbaseURL + '/some-path'
に自動的に解決されます。
worker
ではテスト全体を通してワーカー数(並行実行プロセス数)を1つに制限し、すべてのテストファイルを1つのワーカーで順番に実行させます。
また、fullyParallel
をfalse
に設定し、同じテストファイル内のテストも順番に実行するように設定します。
worker
とfullyParallel
で並列実行を避けるように設定しています。
これは、kintoneでアプリ設定の変更やレコードの登録/更新/削除操作等を並列で実行すると、データベースのデッドロックを引き起こす可能性があり推奨されていないためです。
詳細は次のページを参考にしてください。
kintoneコーディングガイドライン
次に、kintoneの認証情報とテスト環境情報を環境変数に設定します。
1
2
3
4
5
|
export USERNAME=ooooo
export PASSWORD=ooooo
# 検証する際に利用するアプリのID
export TARGET_APP_ID=ooo
|
これでセットアップは完了です。
では、先ほど作成したテストシナリオにしたがってテストコードを書きます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
|
/*
* e2e sample program
* Copyright (c) 2025 Cybozu
*
* Licensed under the MIT License
* <https://opensource.org/license/mit/>
*/
import test, {expect} from '@playwright/test';
import {login} from './utils/auth';
import {generateRandomComment} from './utils/main';
import {createToDoRecord, deleteAllRecords} from './utils/test-data';
test.describe('レコードコメントの一括投稿機能', () => {
test.beforeEach(async ({page}) => {
// kintoneにログイン
await login(page);
// 対象のアプリのレコードを全削除するクリーンアップ処理
// deleteAllRecordsの実装については割愛
await deleteAllRecords(process.env.TARGET_APP_ID);
});
test('正常値を入力し投稿ボタンを押下すると、レコードコメントが投稿される', async ({page}) => {
const targetAppId = process.env.TARGET_APP_ID;
// コメントを投稿する対象のレコードを作成
// createToDoRecordの実装については割愛
const testRecordId = await createToDoRecord(targetAppId, 'テスト');
// 検証するアプリに移動する
await page.goto(`k/${targetAppId}`);
const PostCommentButton = page.getByRole('button', {name: 'レコードコメントを一括投稿する'});
// ボタンが表示されていることを検証する
await expect(PostCommentButton).toBeVisible();
// ボタンを押下する
await PostCommentButton.click();
// generateRandomCommentの実装については割愛
const randomComment = generateRandomComment();
// レコードコメントのメッセージを入力する
const textFieldForReminderMessage = page.getByRole('textbox', {name: 'レコードコメント'});
await textFieldForReminderMessage.fill(randomComment);
// ダイアログ内の送信ボタンを押下する
const sendButton = page.getByRole('button', {name: /^投稿する$/});
await sendButton.click();
// テストレコードに遷移する
const recordUrl = `k/${targetAppId}/show#record=${testRecordId}`;
await page.goto(recordUrl);
await page.waitForURL(recordUrl);
// 対象のレコードコメントが投稿されているか検証する
await expect(page.getByText(randomComment)).toBeVisible();
});
// レコードコメントの一括投稿機能に関する他のケースのテスト
// test()
});
|
beforeEachの解説
固定リンクがコピーされました
beforeEach
は各テストを実行する前に毎回行う処理を実装します。
今回紹介しているのは1つのテストケースのみですが、レコードコメント一括機能に関する他のシナリオを検証する場合はbeforeEach
などのセットアップ関数を利用すると重複コードの削減やテスト間の依存防止につながります。
今回のケースではbeforeEach
でログイン処理とクリーンアップ処理(レコードの全削除処理)を実装しています。毎回のテスト実行で頻出するログイン処理(login
関数)のは次のとおりです。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
/*
* login sample program
* Copyright (c) 2025 Cybozu
*
* Licensed under the MIT License
* <https://opensource.org/license/mit/>
*/
export const login = async (page: Page) => {
const username = process.env.USERNAME;
const password = process.env.PASSWORD;
if (!username || !password) {
throw new Error('USERNAME or PASSWORD is not defined');
}
// ログインページに移動
await page.goto(`/k/`);
// 認証情報を入力
await page.getByPlaceholder(/Login Name|ログイン名/).fill(username);
await page.getByPlaceholder(/Password|パスワード/).fill(password);
await page.getByRole('button', {name: /Login|ログイン/}).click();
// ポータルに移動できるまで待機
await page.waitForURL('/k/#/portal');
};
|
テスト全体を通して、HTML要素を取得する際には、要素がもつ役割に基づいて要素を見つけるgetByRole
メソッドを使っています。
これはE2Eテストが本来目指すべき「ユーザーの操作を模倣する」という観点から理にかなっているためです。
また、getByRole
などの要素の役割に基づくクエリを利用するとDOMの変更に強くなります。
具体的には、ボタンは常にボタンとしての役割を持ち続け、そのUIの機能的な意味合いが変わらない限り、比較的変更されにくいと考えられます。
一方で、クラス名やDOMの階層構造など、実装の詳細に依存して要素を取得する場合はDOMの変更に対して弱いテストになります。
kintoneカスタマイズにおいてもHTML要素のアクセシビリティ特性に基づいたクエリでテストを記述することを推奨します。
kintoneのDOMツリーはサービス側が生成しており、開発者側ではコントロール不能なDOMです。
開発者がコントロールできないDOMだからこそ、実装の詳細に依存しない、より抽象的で安定した「役割」に基づいて要素を取得する手法がテストのメンテナンス性を高める上で特に重要になります。
次のコマンドでテストを実行します。
テストが成功しました。
E2Eテストのセクションではレコードコメントの一括投稿機能のハッピーパスを検証しました。
kintoneカスタマイズのテスト戦略
固定リンクがコピーされました
単体テストでビジネスロジックを検証し、E2Eテストではテストシナリオに基づいて検証することで、対象機能のテストケースを網羅してきました。
テスト戦略という観点では、単体テストや結合テストは比較的工数がかかりにくく、変更にも強いため、可能な限り単体テストや結合テストで大部分のテストケースを担保します。
一方でE2Eテストは実装や保守に工数がかかりやすいため、ハッピーパスを中心にすることが基本路線です。
ただし、kintoneカスタマイズにおいては、kintoneオブジェクトの取り扱いに時間を要するケースがあります。
そのようなケースではE2Eテストの利用を検討してみてもよいかもしれません。
自動テストを導入することで、プラグインやJavaScriptカスタマイズを継続的に改善しやすくなるだけでなく、品質も担保しやすくなります。
導入されていない方はこれを機会にぜひ導入を検討してください!