kintoneカスタマイズの自動テスト(Vitest, Playwright)

著者名:山田 夏暉( サイボウズ株式会社 (External link)

目次

はじめに

kintoneは現場の担当者が業務システムを構築でき、業務の変更にも迅速に対応しやすいという特徴があります。
プラグインやJavaScriptカスタマイズもそれに合わせて迅速に対応できれば、継続的な改善を進めやすくなります。
本記事では迅速に対応するために重要な自動テストについて紹介します。

自動テストは単体テスト、結合テスト、E2Eテストなど目的に応じたさまざまなテストが存在します。
本記事では単体テストとE2Eテストを取り上げます。単体テストは Vitest (External link) 、E2Eテストでは Playwright (External link) を利用します。

想定読者

  • TypeScriptの知識がある方
  • VitestやPlaywrightの公式ドキュメントを確認しながら作業を進められる方

単体テスト

単体テストはモジュール単体が提供する機能に着目したテストです。
Vitest (External link) のインストールや設定は公式サイトのドキュメントを確認してください。

テスト対象となる関数の説明

ログインユーザーが特定の組織に所属しているか否かを判定する関数をテストします。

 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.user.getOrganizations(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);
  });
});

テストの実行

次のコマンドでテストを実行します。

1
npx vitest

kintoneという未定義の変数によりエラーが発生しています。
これはテストランナーがシミュレートしたブラウザー環境と実際のkintone環境の差異があることに起因します。
Vitestなどのテストランナーは基本的にNode.js環境で実行され、仮想DOMを使用してブラウザー環境をシミュレートしますが、kintone環境特有のグローバルオブジェクトkintoneはこの環境には含まれていません。
このように、kintoneカスタマイズにおいてはkintoneオブジェクトを考慮せず実装した場合、テスタビリティ(テストのしやすさ)が低下します。

テスト対象となる関数の修正

isBelongsToSpecificOrg関数をテスタビリティの観点から改善します。
現在の関数は関数内で外部依存のkintoneオブジェクトを直接参照しているという問題があります。

kintoneに依存したコードが混在している関数を実装するとテストは困難になります。
テストしやすいコード設計の基本は、外部サービスやグローバル変数などの外部依存を最小化し、関数の入力と出力だけで挙動を把握できる純粋なロジックを設計することです。

そこで、isBelongsToSpecificOrg関数は外部依存と純粋なロジックを分離し、テスタビリティの高いコードに再設計します。

まず、kintone JavaScript APIを呼び出してログインユーザーの組織情報を取得するロジックを分離します。

1
2
3
4
5
6
7
8
export const getOrganizationTitles = async (userCode: string) => {
  try {
    return await kintone.user.getOrganizations(userCode);
  } 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);
  });
};

外部依存している箇所は内部で呼び出さず引数として与える設計にすることで、isBelongsToSpecificOrg関数を純粋関数にしました。
この関数は引数と戻り値のみで完結しており参照透過です。

次に、テストケースについて考えます。
getOrganizationTitles関数はkintone.user.getOrganizations()を呼び出し、結果を返すというシンプルなラッパー関数です。
外部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) 2026 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) 2026 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', {
      user: {
        getOrganizations: vi.fn().mockResolvedValue([
          {
            organization: {
              id: '1',
              code: 'Sales',
              name: '営業部',
            },
          },
        ]),
      },
      getLoginUser: vi.fn().mockReturnValue(mockUser),
    });

    const result = await isBelongsToSpecificOrg(targetOrgs);

    expect(result).toBe(true);
  });

  // 他のテストケース
  // test()
});

グローバル変数をスタブ化するvi.stubGlobalを利用し、kintone.getLoginUserkintone.user.getOrganizationsのスタブを用意しています。

E2Eテスト

E2Eテストは、システムすべてのレイヤー(関数/UIコンポーネント/DB操作等)を通して実施するテストを指します。
このセクションでは特定アプリの全レコードに対してレコードコメントを一括で投稿する機能を検証していきます。
この機能では、単体テストのセクションで扱った所属判定と同様に、特定組織(例:情報システム部)に所属するユーザーのみが利用できる仕様とします。

Playwright (External link) のインストール手順は公式サイトのドキュメントを確認してください。

検証用アプリとカスタマイズの準備

E2Eで検証する機能を動かすため、検証用アプリを作成します。

  1. kintoneアプリストアにある「To Do」アプリを追加します。
  2. アプリの設定画面から「JavaScript / CSSによるカスタマイズ」を開き、「PC用のJavaScriptファイル」に以下を指定します。
    1. URL指定で kintone UI Component を追加します。
      https://unpkg.com/kintone-ui-component@1.25.0/umd/kuc.min.js
    2. 後述のサンプルプログラムをコピーし、任意のファイル名で保存してアップロードします。

設定を保存してアプリを更新したら、一覧画面に「レコードコメントを一括投稿する」ボタンが表示されることを確認してください。

サンプルプログラム

 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
62
63
64
65
66
67
68
69
70
71
72
73
/*
 * record comment bulk post sample program
 * Copyright (c) 2026 Cybozu
 *
 * Licensed under the MIT License
 * https://opensource.org/license/mit/
 */

(() => {
  'use strict';

  const ALLOWED_ORG = '情報システム部';
  const Kuc = Kucs['1.25.0'];

  kintone.events.on('app.record.index.show', async (event) => {
    const header = kintone.app.getHeaderMenuSpaceElement();
    if (!header || header.dataset.bulkCommentReady) return event;
    header.dataset.bulkCommentReady = '1';

    const orgs = await kintone.user.getOrganizations();
    if (!orgs.some((o) => o.organization.name === ALLOWED_ORG)) return event;

    const button = new Kuc.Button({
      text: 'レコードコメントを一括投稿する',
      type: 'submit',
    });

    button.addEventListener('click', async () => {
      const input = document.createElement('textarea');
      input.id = 'bulk-comment-input';
      input.rows = 4;
      input.style.width = '100%';

      const label = document.createElement('label');
      label.textContent = 'レコードコメント';
      label.htmlFor = input.id;

      const body = document.createElement('div');
      body.style.padding = '16px';
      body.append(label, input);

      const dialog = await kintone.createDialog({
        title: 'レコードコメントを一括投稿する',
        body,
        okButtonText: '投稿する',
        showCancelButton: true,
      });

      if ((await dialog.show()) !== 'OK') return;
      const text = input.value.trim();
      if (!text) return;

      const app = kintone.app.getId();
      const {records} = await kintone.api(kintone.api.url('/k/v1/records.json'), 'GET', {
        app,
        query: 'order by $id asc limit 100',
      });

      for (const record of records) {
        await kintone.api(kintone.api.url('/k/v1/record/comment.json'), 'POST', {
          app,
          record: record.$id.value,
          comment: {text},
        });
      }

      location.reload();
    });

    header.appendChild(button);
    return event;
  });
})();

テストシナリオの作成

テストシナリオとは特定用途でユーザーがアプリケーションを操作するシナリオを記述したものです。
レコードコメントの一括投稿機能のテストシナリオを作成し、システム全体が期待どおりに動作するかを確認します。

テストシナリオ作成はユーザーがサービスを操作していることを仮定して設計し、ユーザー視点で「何をしたいか」を軸にシナリオを組み立てると、要素選択や画面遷移などが変更されてもテストを大きく壊さずに対応しやすくなります。

今回は例外やエラーの状態のないデフォルトのシナリオを検証します。
テストシナリオを作成するために、まずはユーザー視点の機能動作を確認します。

  1. ログインユーザーが特定組織に所属する場合はボタンが表示されます。

  2. ボタンを押下するとダイアログが表示され、任意のメッセージを入力できます。

  3. ダイアログの送信ボタンを押すと、アプリ内のレコードに対してコメントを投稿します。

ユーザー視点での機能動作が確認できたので、検証する機能の通常フローをもとにテストシナリオを作成します。

  1. 特定組織に所属するユーザーとしてログインする。
  2. テスト対象となるアプリに移動する。
  3. 「レコードコメントを一括投稿する」ボタンを押下する。
  4. 表示されたダイアログ内のテキストボックスに文字列を入力する。
  5. ダイアログ内の「投稿する」ボタンを押下し、レコードコメントを投稿する。

テスト実行に関する設定

次にplaywright.config.tsを作成し、playwrightのテスト実行に関する設定を定義します。

 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
import {readFileSync} from 'node:fs';
import {defineConfig} from '@playwright/test';

// .env を読み込む(USERNAME / PASSWORD / TARGET_APP_ID)
try {
  for (const line of readFileSync('.env', 'utf8').split('\n')) {
    const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
    if (!match) continue;
    const [, key, rawValue] = match;
    if (process.env[key] === undefined) {
      process.env[key] = rawValue.replace(/^['"]|['"]$/g, '');
    }
  }
} catch {
  // .env が無い場合は環境変数をそのまま使う
}

export default defineConfig({
  testDir: './tests/playwright',
  timeout: 60_000,
  use: {
    baseURL: process.env.KINTONE_BASE_URL ?? 'https://sample.cybozu.com',
  },
  workers: 1,
  fullyParallel: false,
});

testDirではテストに含めるファイルのパスパターンを定義します。
baseURLを設定するとpage.goto('/some-path')のような相対パスによるナビゲーションがbaseURL + '/some-path'に自動的に解決されます。
KINTONE_BASE_URLを指定すると、自分の環境のドメインに切り替えられます。

workerではテスト全体を通してワーカー数(並行実行プロセス数)を1つに制限し、すべてのテストファイルを1つのワーカーで順番に実行させます。

また、fullyParallelfalseに設定し、同じテストファイル内のテストも順番に実行するように設定します。

workerfullyParallelで並列実行を避けるように設定しています。
これは、kintoneでアプリ設定の変更やレコードの登録/更新/削除操作等を並列で実行すると、データベースのデッドロックを引き起こす可能性があり推奨されていないためです。
詳細は次のページを参考にしてください。
kintoneコーディングガイドライン

環境変数の設定

次に、kintoneの認証情報とテスト環境情報を.envに設定します。
Playwrightは標準では.envを読まないため、前述のplaywright.config.tsで読み込んでいます。
TARGET_APP_IDには、前節で作成したTo DoアプリのアプリIDを指定してください。

1
2
3
4
5
6
USERNAME=ログイン名
PASSWORD=パスワード
TARGET_APP_ID=アプリID
# 任意
# TITLE_FIELD_CODE=タイトル
# KINTONE_BASE_URL=https://sample.cybozu.com

検証用アプリ以外でdeleteAllRecordsを実行しないでください。

これでセットアップは完了です。

テストコードの記述

では、先ほど作成したテストシナリオにしたがってテストコードを書きます。

 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
62
63
64
65
66
67
/*
 * comment bulk post e2e sample program
 * Copyright (c) 2026 Cybozu
 *
 * Licensed under the MIT License
 * https://opensource.org/license/mit/
 */

import test, {expect} from '@playwright/test';
import {login} from './utils/auth.js';
import {generateRandomComment} from './utils/main.js';
import {createToDoRecord, deleteAllRecords} from './utils/test-data.js';

test.describe('レコードコメントの一括投稿機能', () => {
  test.beforeEach(async ({page}) => {
    // kintoneにログイン
    await login(page);

    // 対象のアプリのレコードを全削除するクリーンアップ処理
    await deleteAllRecords(process.env.TARGET_APP_ID);
  });

  test('正常値を入力し投稿ボタンを押下すると、レコードコメントが投稿される', async ({page}) => {
    const targetAppId = process.env.TARGET_APP_ID;

    // コメントを投稿する対象のレコードを作成
    const testRecordId = await createToDoRecord(targetAppId, 'テスト');

    // 検証するアプリに移動する
    await page.goto(`k/${targetAppId}`);
    const postCommentButton = page.getByRole('button', {name: 'レコードコメントを一括投稿する'});

    // ボタンが表示されていることを検証する
    await expect(postCommentButton).toBeVisible();
    // ボタンを押下する
    await postCommentButton.click();

    const randomComment = generateRandomComment();
    // レコードコメントのメッセージを入力する
    const textFieldForReminderMessage = page.getByRole('textbox', {name: 'レコードコメント'});
    await textFieldForReminderMessage.fill(randomComment);

    // ダイアログ内の送信ボタンを押下する
    const sendButton = page.getByRole('button', {name: /^投稿する$/});

    // カスタマイズJSのコメント投稿API完了を待ってから詳細画面へ遷移する
    const commentPosted = page.waitForResponse(
      (response) =>
        response.url().includes('/k/v1/record/comment.json') &&
        response.request().method() === 'POST' &&
        response.ok(),
    );
    await sendButton.click();
    await commentPosted;

    // テストレコードに遷移する
    const recordUrl = `k/${targetAppId}/show#record=${testRecordId}`;
    await page.goto(recordUrl);
    await page.waitForURL(recordUrl);

    // 対象のレコードコメントが投稿されているか検証する
    await expect(page.getByText(randomComment)).toBeVisible({timeout: 15_000});
  });

  // レコードコメントの一括投稿機能に関する他のケースのテスト
  // 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
29
/*
 * login sample program
 * Copyright (c) 2026 Cybozu
 *
 * Licensed under the MIT License
 * https://opensource.org/license/mit/
 */

import type {Page} from '@playwright/test';

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/');

  // 認証情報を入力する(ログイン画面は accessible name で取得する)
  await page.getByRole('textbox', {name: /Login name|ログイン名/i}).fill(username);
  await page.getByRole('textbox', {name: /Password|パスワード/i}).fill(password);
  await page.getByRole('button', {name: /Login|ログイン/i}).click();

  // ポータルに移動できるまで待機
  await page.waitForURL('/k/#/portal');
};

レコード操作用ユーティリティ

tests/playwright/utils/test-data.tsの実装例です。
Node側からkintone REST APIを呼び、認証にはX-Cybozu-AuthorizationUSERNAME:PASSWORDのBase64)を使います。

  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
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
/*
 * test-data sample program
 * Copyright (c) 2026 Cybozu
 *
 * Licensed under the MIT License
 * https://opensource.org/license/mit/
 */

const getBaseUrl = (): string => {
  return (process.env.KINTONE_BASE_URL ?? 'https://sample.cybozu.com').replace(/\/$/, '');
};

const getAuthHeader = (): string => {
  const username = process.env.USERNAME;
  const password = process.env.PASSWORD;
  if (!username || !password) {
    throw new Error('USERNAME or PASSWORD is not defined');
  }
  return Buffer.from(`${username}:${password}`, 'utf8').toString('base64');
};

const kintoneApi = async <T>(
  method: 'GET' | 'POST' | 'DELETE',
  path: string,
  body?: Record<string, unknown>,
): Promise<T> => {
  const headers: Record<string, string> = {
    'X-Cybozu-Authorization': getAuthHeader(),
  };

  let url = `${getBaseUrl()}${path}`;
  let requestBody: string | undefined;

  if (method === 'GET' && body) {
    const params = new URLSearchParams();
    for (const [key, value] of Object.entries(body)) {
      if (Array.isArray(value)) {
        value.forEach((item, index) => {
          params.set(`${key}[${index}]`, String(item));
        });
      } else if (value !== undefined) {
        params.set(key, String(value));
      }
    }
    url += `?${params.toString()}`;
  } else if (body) {
    headers['Content-Type'] = 'application/json';
    requestBody = JSON.stringify(body);
  }

  const response = await fetch(url, {method, headers, body: requestBody});
  const data = (await response.json()) as T & {message?: string; code?: string};
  if (!response.ok) {
    throw new Error(
      `kintone API failed: ${method} ${path} (${response.status}) ${JSON.stringify(data)}`,
    );
  }
  return data;
};

const resolveTitleFieldCode = async (appId: string): Promise<string> => {
  if (process.env.TITLE_FIELD_CODE) {
    return process.env.TITLE_FIELD_CODE;
  }

  const {properties} = await kintoneApi<{
    properties: Record<string, {type: string; code: string}>;
  }>('GET', '/k/v1/app/form/fields.json', {app: appId});

  const textField = Object.values(properties).find((field) => field.type === 'SINGLE_LINE_TEXT');
  if (!textField) {
    throw new Error(
      'SINGLE_LINE_TEXT field not found. Set TITLE_FIELD_CODE in .env (e.g. TITLE_FIELD_CODE=タイトル)',
    );
  }
  return textField.code;
};

// アプリ内のレコードを全削除する
export const deleteAllRecords = async (appId: string | undefined): Promise<void> => {
  if (!appId) {
    throw new Error('TARGET_APP_ID is not defined');
  }

  // 100件ずつ取得して削除を繰り返す
  while (true) {
    const {records} = await kintoneApi<{records: Array<{$id: {value: string}}>}>('GET', '/k/v1/records.json', {
      app: appId,
      query: 'limit 100',
      fields: ['$id'],
    });

    if (records.length === 0) {
      return;
    }

    await kintoneApi('DELETE', '/k/v1/records.json', {
      app: appId,
      ids: records.map((record) => record.$id.value),
    });
  }
};

// テスト用レコードを1件作成し、レコードIDを返す
export const createToDoRecord = async (
  appId: string | undefined,
  title: string,
): Promise<string> => {
  if (!appId) {
    throw new Error('TARGET_APP_ID is not defined');
  }

  const fieldCode = await resolveTitleFieldCode(appId);
  const {id} = await kintoneApi<{id: string}>('POST', '/k/v1/record.json', {
    app: appId,
    record: {
      [fieldCode]: {value: title},
    },
  });
  return id;
};

TITLE_FIELD_CODE未設定時は、フォーム定義から最初の文字列(1行)フィールドを自動検出します。

コメント文字列の生成例です。tests/playwright/utils/main.tsに置きます。

1
2
3
export const generateRandomComment = (): string => {
  return `test-comment-${Date.now()}`;
};

testの解説

テスト全体を通して、HTML要素を取得する際には、要素がもつ役割に基づいて要素を見つけるgetByRoleメソッドを使っています。
これはE2Eテストが本来目指すべき「ユーザーの操作を模倣する」という観点から理にかなっているためです。

また、getByRoleなどの要素の役割に基づくクエリを利用するとDOMの変更に強くなります。
具体的には、ボタンは常にボタンとしての役割を持ち続け、そのUIの機能的な意味合いが変わらない限り、比較的変更されにくいと考えられます。
一方で、クラス名やDOMの階層構造など、実装の詳細に依存して要素を取得する場合はDOMの変更に対して弱いテストになります。

kintoneカスタマイズにおいてもHTML要素のアクセシビリティ特性に基づいたクエリでテストを記述することを推奨します。
kintoneのDOMツリーはサービス側が生成しており、開発者側ではコントロール不能なDOMです。
開発者がコントロールできないDOMだからこそ、実装の詳細に依存しない、より抽象的で安定した「役割」に基づいて要素を取得する手法がテストのメンテナンス性を高める上で特に重要になります。

テストの実行

次のコマンドでテストを実行します。

1
npx playwright test

テストが成功しました。
E2Eテストのセクションではレコードコメントの一括投稿機能のハッピーパスを検証しました。

kintoneカスタマイズのテスト戦略

単体テストでビジネスロジックを検証し、E2Eテストではテストシナリオに基づいて検証することで、対象機能のテストケースを網羅してきました。

テスト戦略という観点では、単体テストや結合テストは比較的工数がかかりにくく、変更にも強いため、可能な限り単体テストや結合テストで大部分のテストケースを担保します。
一方でE2Eテストは実装や保守に工数がかかりやすいため、ハッピーパスを中心にすることが基本路線です。
ただし、kintoneカスタマイズにおいては、kintoneオブジェクトの取り扱いに時間を要するケースがあります。
そのようなケースではE2Eテストの利用を検討してみてもよいかもしれません。

おわりに

自動テストを導入することで、プラグインやJavaScriptカスタマイズを継続的に改善しやすくなるだけでなく、品質も担保しやすくなります。
導入されていない方はこれを機会にぜひ導入を検討してください。

information

このTipsは、2026年7月版kintoneで動作を確認しています。

公式コミュニティ

kintone開発者同士で質問や知見を共有し、学び合うことができます。