关于 syncable-entity-testing
用于 Twenty 可同步实体的集成测试技能,指导构建查询工具,并覆盖验证失败、CRUD 操作和边缘情况,适用于实现完成后的最终测试步骤。
主要功能
- 01可复用的查询与封装工具
- 02验证失败及错误场景测试
- 03成功的 CRUD 场景
- 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 操作和边界情况
- 测试工具 - 可复用的查询工厂和辅助函数
测试模式:每种操作采用双文件模式(查询工厂 + 封装器)。
第 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 规则。