syncable-entity-testing について
Twenty の同期可能なエンティティ向け統合テストスキルです。クエリユーティリティの作成と、検証エラー、CRUD 操作、境界ケースのテストを案内します。実装後の最終テスト工程で使います。
主な機能
- 01再利用可能なクエリ・ラッパーユーティリティ
- 02検証エラーと失敗ケースのテスト
- 03CRUD 成功シナリオ
- 04スナップショットによるエラーチェック
- 05テスト後のクリーンアップ手順
活用シーン
- 01新しいエンティティの検証エラーをテスト
- 02作成・更新・削除の動作を確認
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ twentyhq/ twenty --skill syncable-entity-testingインストール後、Claude Code で/syncable-entity-testingを入力して読み込まれたか確認します。
詳細
同期可能エンティティ: 統合テスト (ステップ 6/6 - 必須)
目的: すべてのバリデーションシナリオ、入力トランスパイルの例外、および成功するユースケースを網羅する包括的なテストスイートを作成します。
使用するタイミング: ステップ 1~5 を完了した後。統合テストはすべての同期可能エンティティに対して必須です。
クイックスタート
テストは以下を網羅する必要があります:
- 失敗シナリオ - すべてのバリデータ例外と入力トランスパイルエラー
- 成功シナリオ - すべての CRUD 操作とエッジケース
- テストユーティリティ - 再利用可能なクエリファクトリーとヘルパー関数
テストパターン: 各操作に対して 2 ファイルパターン (クエリファクトリー + ラッパー)。
ステップ 1: テストユーティリティを作成する
パターン: クエリファクトリー
ファイル: test/integration/metadata/suites/my-entity/utils/create-my-entity-query-factory.util.ts
import gql from 'graphql-tag';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { type CreateMyEntityInput } from 'src/engine/metadata-modules/my-entity/dtos/create-my-entity.input';
export type CreateMyEntityFactoryInput = CreateMyEntityInput;
const DEFAULT_MY_ENTITY_GQL_FIELDS = `
id
name
label
description
isCustom
createdAt
updatedAt
`;
export const createMyEntityQueryFactory = ({
input,
gqlFields = DEFAULT_MY_ENTITY_GQL_FIELDS,
}: PerformMetadataQueryParams<CreateMyEntityFactoryInput>) => ({
query: gql`
mutation CreateMyEntity($input: CreateMyEntityInput!) {
createMyEntity(input: $input) {
${gqlFields}
}
}
`,
variables: {
input,
},
});
パターン: ラッパーユーティリティ
ファイル: test/integration/metadata/suites/my-entity/utils/create-my-entity.util.ts
import {
type CreateMyEntityFactoryInput,
createMyEntityQueryFactory,
} from 'test/integration/metadata/suites/my-entity/utils/create-my-entity-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { type MyEntityDto } from 'src/engine/metadata-modules/my-entity/dtos/my-entity.dto';
export const createMyEntity = async ({
input,
gqlFields,
expectToFail = false,
token,
}: PerformMetadataQueryParams<CreateMyEntityFactoryInput>): CommonResponseBody<{
createMyEntity: MyEntityDto;
}> => {
const graphqlOperation = createMyEntityQueryFactory({
input,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation, token);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'My entity creation should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'My entity creation has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};
必要なユーティリティ (同じパターンに従ってください):
update-my-entity-query-factory.util.ts+update-my-entity.util.tsdelete-my-entity-query-factory.util.ts+delete-my-entity.util.ts
ステップ 2: 作成失敗テスト
ファイル: test/integration/metadata/suites/my-entity/failing-my-entity-creation.integration-spec.ts
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { createMyEntity } from 'test/integration/metadata/suites/my-entity/utils/create-my-entity.util';
import { deleteMyEntity } from 'test/integration/metadata/suites/my-entity/utils/delete-my-entity.util';
import {
eachTestingContextFilter,
type EachTestingContext,
} from 'twenty-shared/testing';
import { isDefined } from 'twenty-shared/utils';
import { type CreateMyEntityInput } from 'src/engine/metadata-modules/my-entity/dtos/create-my-entity.input';
type TestContext = {
input: CreateMyEntityInput;
};
type GlobalTestContext = {
existingEntityLabel: string;
existingEntityName: string;
};
const globalTestContext: GlobalTestContext = {
existingEntityLabel: 'Existing Test Entity',
existingEntityName: 'existingTestEntity',
};
type CreateMyEntityTestingContext = EachTestingContext<TestContext>[];
describe('My entity creation should fail', () => {
let existingEntityId: string | undefined;
beforeAll(async () => {
// Setup: Create entity for uniqueness tests
const { data } = await createMyEntity({
expectToFail: false,
input: {
name: globalTestContext.existingEntityName,
label: globalTestContext.existingEntityLabel,
},
});
existingEntityId = data.createMyEntity.id;
});
afterAll(async () => {
// Cleanup
if (isDefined(existingEntityId)) {
await deleteMyEntity({
expectToFail: false,
input: { id: existingEntityId },
});
}
});
const failingMyEntityCreationTestCases: CreateMyEntityTestingContext = [
// Input transpilation validation
{
title: 'when name is missing',
context: {
input: {
label: 'Entity Missing Name',
} as CreateMyEntityInput,
},
},
{
title: 'when label is missing',
context: {
input: {
name: 'entityMissingLabel',
} as CreateMyEntityInput,
},
},
{
title: 'when name is empty string',
context: {
input: {
name: '',
label: 'Empty Name Entity',
},
},
},
// Validator business logic
{
title: 'when name already exists (uniqueness)',
context: {
input: {
name: globalTestContext.existingEntityName,
label: 'Duplicate Name Entity',
},
},
},
{
title: 'when trying to create standard entity',
context: {
input: {
name: 'myEntity',
label: 'Standard Entity',
isCustom: false,
} as CreateMyEntityInput,
},
},
// Foreign key validation
{
title: 'when parentEntityId does not exist',
context: {
input: {
name: 'invalidParentEntity',
label: 'Invalid Parent Entity',
parentEntityId: '00000000-0000-0000-0000-000000000000',
},
},
},
];
it.each(eachTestingContextFilter(failingMyEntityCreationTestCases))(
'$title',
async ({ context }) => {
const { errors } = await createMyEntity({
expectToFail: true,
input: context.input,
});
expectOneNotInternalServerErrorSnapshot({
errors,
});
},
);
});
テストカバレッジ要件:
- ✅ 必須フィールドの欠落
- ✅ 空文字列
- ✅ 無効なフォーマット
- ✅ 一意性制約違反
- ✅ 標準エンティティの保護
- ✅ 外部キーのバリデーション
ステップ 3: 作成成功テスト
ファイル: test/integration/metadata/suites/my-entity/successful-my-entity-creation.integration-spec.ts
import { createMyEntity } from 'test/integration/metadata/suites/my-entity/utils/create-my-entity.util';
import { deleteMyEntity } from 'test/integration/metadata/suites/my-entity/utils/delete-my-entity.util';
import { type CreateMyEntityInput } from 'src/engine/metadata-modules/my-entity/dtos/create-my-entity.input';
describe('My entity creation should succeed', () => {
let createdEntityId: string;
afterEach(async () => {
if (createdEntityId) {
await deleteMyEntity({
expectToFail: false,
input: { id: createdEntityId },
});
}
});
it('should create entity with minimal required input', async () => {
const { data } = await createMyEntity({
expectToFail: false,
input: {
name: 'minimalEntity',
label: 'Minimal Entity',
},
});
createdEntityId = data?.createMyEntity?.id;
expect(data.createMyEntity).toMatchObject({
id: expect.any(String),
name: 'minimalEntity',
label: 'Minimal Entity',
description: null,
isCustom: true,
createdAt: expect.any(String),
updatedAt: expect.any(String),
});
});
it('should create entity with all optional fields', async () => {
const input = {
name: 'fullEntity',
label: 'Full Entity',
description: 'Entity with all fields specified',
} as const satisfies CreateMyEntityInput;
const { data } = await createMyEntity({
expectToFail: false,
input,
});
createdEntityId = data?.createMyEntity?.id;
expect(data.createMyEntity).toMatchObject({
id: expect.any(String),
name: 'fullEntity',
label: 'Full Entity',
description: 'Entity with all fields specified',
isCustom: true,
});
});
it('should sanitize input by trimming whitespace', async () => {
const { data } = await createMyEntity({
expectToFail: false,
input: {
name: ' entityWithSpaces ',
label: ' Entity With Spaces ',
description: ' Description with spaces ',
},
});
createdEntityId = data?.createMyEntity?.id;
expect(data.createMyEntity).toMatchObject({
id: expect.any(String),
name: 'entityWithSpaces',
label: 'Entity With Spaces',
description: 'Description with spaces',
});
});
it('should handle long text content', async () => {
const longDescription = 'A'.repeat(1000);
const { data } = await createMyEntity({
expectToFail: false,
input: {
name: 'longDescEntity',
label: 'Long Description Entity',
description: longDescription,
},
});
createdEntityId = data?.createMyEntity?.id;
expect(data.createMyEntity).toMatchObject({
id: expect.any(String),
description: longDescription,
});
});
});
テストカバレッジ要件:
- ✅ 最小限の必須入力
- ✅ すべてのオプションフィールド
- ✅ 入力のサニタイズ
- ✅ 長いテキストコンテンツ
- ✅ 特殊文字
ステップ 4: 更新と削除のテスト
更新および削除操作に対して同様のテストファイルを作成します:
必要なファイル:
failing-my-entity-update.integration-spec.tssuccessful-my-entity-update.integration-spec.tsfailing-my-entity-deletion.integration-spec.tssuccessful-my-entity-deletion.integration-spec.ts
テストのベストプラクティス
パターン: クリーンアップ
afterEach(async () => {
if (createdEntityId) {
await deleteMyEntity({
expectToFail: false,
input: { id: createdEntityId },
});
}
});
パターン: 型安全な入力
const input = {
name: 'myEntity',
label: 'My Entity',
} as const satisfies CreateMyEntityInput;
パターン: スナップショットテスト
expectOneNotInternalServerErrorSnapshot({
errors,
});
テストの実行
# Run all entity tests
npx jest test/integration/metadata/suites/my-entity --config=packages/twenty-server/jest.config.mjs
# Run specific test file
npx jest test/integration/metadata/suites/my-entity/failing-my-entity-creation.integration-spec.ts --config=packages/twenty-server/jest.config.mjs
# Update snapshots
npx jest test/integration/metadata/suites/my-entity --updateSnapshot --config=packages/twenty-server/jest.config.mjs
完全なテストチェックリスト
テストユーティリティ
-
create-my-entity-query-factory.util.tsを作成済み -
create-my-entity.util.tsを作成済み -
update-my-entity-query-factory.util.tsを作成済み -
update-my-entity.util.tsを作成済み -
delete-my-entity-query-factory.util.tsを作成済み -
delete-my-entity.util.tsを作成済み
失敗テストのカバレッジ
- 必須フィールドの欠落
- 空文字列のバリデーション
- 一意性制約違反
- 標準エンティティの保護
- 外部キーのバリデーション
- JSONB プロパティのバリデーション (該当する場合)
成功テストのカバレッジ
- 最小限の入力での作成
- すべてのオプションフィールドでの作成
- 入力のサニタイズ (空白)
- 長いテキストコンテンツ
- 単一フィールドの更新
- 複数フィールドの更新
- 削除の成功
スナップショットテスト
- すべての失敗テストで
expectOneNotInternalServerErrorSnapshotを使用している - スナップショットが
__snapshots__/ディレクトリにコミットされている
成功基準
以下の条件を満たしたとき、統合テストは完了です:
✅ すべてのテストユーティリティを作成済み (最低 6 ファイル) ✅ 作成失敗テストがすべてのバリデータを網羅している ✅ 更新失敗テストがビジネスルールを網羅している ✅ 削除失敗テストが保護ルールを網羅している ✅ 成功テストがすべてのユースケースを網羅している ✅ すべてのスナップショットが生成されコミットされている ✅ すべてのテストが安定的に成功する ✅ テストカバレッジが要件を満たしている (>80%)
最終ステップ
✅ ステップ 6 完了! → あなたの同期可能エンティティは完全にテストされ、本番環境に対応しています!
おめでとうございます! Twenty のワークスペース移行システムで新しい同期可能エンティティを無事に作成できました。
完全なワークフローについては、@creating-syncable-entity ルールを参照してください。