Improve test tooling (#18259)

## Summary

Unifies test mocking tooling across Jest and Storybook, replaces
handcrafted mock data with auto-generated server-fetched data, and
restructures the mock data generation script for maintainability.

### Mock data generation

- Split `generate-mock-data.ts` into three focused modules under
`scripts/mock-data/`:
  - `utils.ts` — shared authentication, GraphQL client, and file writer
  - `generate-metadata.ts` — fetches object metadata from `/metadata`
- `generate-record-data.ts` — fetches record data from `/graphql` using
metadata-driven dynamic queries
- The orchestrator (`generate-mock-data.ts`) authenticates once and
passes the token to both generators
- Company records are now fetched from the actual server (limited to 10
records) instead of being handcrafted
- Generated files are organized under `generated/metadata/objects/` and
`generated/data/companies/`

### Unified test utilities

- Consolidated Jest and MSW mocking into shared utilities that compose
production code (`prefillRecord`, `getRecordNodeFromRecord`,
`getRecordConnectionFromRecords`) with mock metadata
- Renamed `generateEmptyJestRecordNode` → `generateMockRecordNode` and
moved to `testing/utils/`
- Extracted `generateMockRecordConnection` into its own file
- Removed `sanitizeInputForPrefill` workaround (no longer needed with
correctly shaped generated data)
This commit is contained in:
Charles Bochet
2026-02-26 14:31:54 +01:00
committed by GitHub
parent 2f0103faa5
commit 8a7a19f312
33 changed files with 23068 additions and 21543 deletions
@@ -11,7 +11,7 @@ import {
QUERY_MAX_RECORDS,
} from 'twenty-shared/constants';
import { MessageParticipantRole } from 'twenty-shared/types';
import { generateEmptyJestRecordNode } from '~/testing/jest/generateEmptyJestRecordNode';
import { generateMockRecordNode } from '~/testing/utils/generateMockRecordNode';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow';
@@ -84,7 +84,7 @@ const mocks = [
messages: {
edges: [
{
node: generateEmptyJestRecordNode({
node: generateMockRecordNode({
objectNameSingular: 'message',
input: {
id: '1',
@@ -95,7 +95,7 @@ const mocks = [
cursor: '1',
},
{
node: generateEmptyJestRecordNode({
node: generateMockRecordNode({
objectNameSingular: 'message',
input: {
id: '2',
@@ -135,7 +135,7 @@ const mocks = [
messageParticipants: {
edges: [
{
node: generateEmptyJestRecordNode({
node: generateMockRecordNode({
objectNameSingular: 'messageParticipant',
input: {
id: 'messageParticipant-1',
@@ -146,7 +146,7 @@ const mocks = [
cursor: '1',
},
{
node: generateEmptyJestRecordNode({
node: generateMockRecordNode({
objectNameSingular: 'messageParticipant',
input: {
id: 'messageParticipant-2',
@@ -1,5 +1,5 @@
import { FIND_MANY_OBJECT_METADATA_ITEMS } from '@/object-metadata/graphql/queries';
import { mockedStandardObjectMetadataQueryResult } from '~/testing/mock-data/generated/mock-metadata-query-result';
import { mockedStandardObjectMetadataQueryResult } from '~/testing/mock-data/generated/metadata/objects/mock-objects-metadata';
export const query = FIND_MANY_OBJECT_METADATA_ITEMS;
@@ -1,13 +1,16 @@
import { isAppEffectRedirectEnabledState } from '@/app/states/isAppEffectRedirectEnabledState';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useCallback } from 'react';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { useStore } from 'jotai';
export const useLoadMockedObjectMetadataItems = () => {
const store = useStore();
const loadMockedObjectMetadataItems = useCallback(async () => {
const { generatedMockObjectMetadataItems } = await import(
'~/testing/utils/generatedMockObjectMetadataItems'
);
if (
!isDeeplyEqual(
store.get(objectMetadataItemsState.atom),
@@ -1,12 +1,15 @@
import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { type RecordGqlFields } from '@/object-record/graphql/record-gql-fields/types/RecordGqlFields';
import { isNonCompositeField } from '@/object-record/object-filter-dropdown/utils/isNonCompositeField';
import { type ObjectPermissions } from 'twenty-shared/types';
import {
FieldMetadataType,
type ObjectPermissions,
RelationType,
} from 'twenty-shared/types';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
type MapFieldMetadataToGraphQLQueryArgs = {
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`generateActivityTargetGqlFields snapshot tests should match snapshot for Note with loadRelations="activity" 1`] = `
{
@@ -5,7 +5,7 @@ import {
variables,
} from '@/object-record/hooks/__mocks__/useFindOneRecord';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { generateEmptyJestRecordNode } from '~/testing/jest/generateEmptyJestRecordNode';
import { generateMockRecordNode } from '~/testing/utils/generateMockRecordNode';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
const mocks = [
@@ -16,7 +16,7 @@ const mocks = [
},
result: jest.fn(() => ({
data: {
person: generateEmptyJestRecordNode({
person: generateMockRecordNode({
objectNameSingular: 'person',
input: { id: '6205681e-7c11-40b4-9e32-f523dbe54590' },
withDepthOneRelation: true,
@@ -12,7 +12,7 @@ import {
type RecordUpdateHookParams,
} from '@/object-record/record-field/ui/contexts/FieldContext';
import { useToggleEditOnlyInput } from '@/object-record/record-field/ui/hooks/useToggleEditOnlyInput';
import { generateEmptyJestRecordNode } from '~/testing/jest/generateEmptyJestRecordNode';
import { generateMockRecordNode } from '~/testing/utils/generateMockRecordNode';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow';
@@ -39,7 +39,7 @@ const mocks: MockedResponse[] = [
result: jest.fn(() => ({
data: {
updateCompany: {
...generateEmptyJestRecordNode({
...generateMockRecordNode({
objectNameSingular: CoreObjectNameSingular.Company,
input: { id: recordId },
withDepthOneRelation: true,
@@ -27,8 +27,23 @@ import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSe
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationManyToOneFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/RelationManyToOneFieldInput';
import { getCompaniesMock } from '~/testing/mock-data/companies';
import { getMockFieldMetadataItemOrThrow } from '~/testing/utils/getMockFieldMetadataItemOrThrow';
import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow';
import { getFieldInputEventContextProviderWithJestMocks } from './utils/getFieldInputEventContextProviderWithJestMocks';
const personMetadata = getMockObjectMetadataItemOrThrow('person');
const companyMetadata = getMockObjectMetadataItemOrThrow('company');
const companyFieldOnPerson = getMockFieldMetadataItemOrThrow({
objectMetadataItem: personMetadata,
fieldName: 'company',
});
const peopleFieldOnCompany = getMockFieldMetadataItemOrThrow({
objectMetadataItem: companyMetadata,
fieldName: 'people',
});
const companiesMock = getCompaniesMock();
const RelationWorkspaceSetterEffect = () => {
const setRecordFieldInputLayoutDirectionLoading = useSetAtomComponentState(
recordFieldInputLayoutDirectionLoadingComponentState,
@@ -75,7 +90,7 @@ const RelationManyToOneFieldInputWithContext = ({
<FieldContext.Provider
value={{
fieldDefinition: {
fieldMetadataId: 'f6be42ac-ccb8-4df0-8c22-a9627d655c76',
fieldMetadataId: companyFieldOnPerson.id,
label: 'Company',
type: FieldMetadataType.RELATION,
iconName: 'IconLink',
@@ -84,9 +99,9 @@ const RelationManyToOneFieldInputWithContext = ({
relationObjectMetadataNamePlural: 'companies',
relationObjectMetadataNameSingular:
CoreObjectNameSingular.Company,
relationObjectMetadataId: '69e0aa5d-a9c5-486b-a99d-fe191195a19d',
relationObjectMetadataId: companyMetadata.id,
objectMetadataNameSingular: 'person',
relationFieldMetadataId: '94902a74-b8ca-4a56-8573-7469f0b664f6',
relationFieldMetadataId: peopleFieldOnCompany.id,
},
},
recordId: recordId,
@@ -154,7 +169,7 @@ export const Submit: Story = {
expect(handleSubmitMocked).toHaveBeenCalledTimes(0);
const item = await canvas.findByText('Linkedin', undefined, {
const item = await canvas.findByText(companiesMock[0].name, undefined, {
timeout: 3000,
});
@@ -171,7 +186,9 @@ export const Cancel: Story = {
const canvas = within(canvasElement);
expect(handleCancelMocked).toHaveBeenCalledTimes(0);
await canvas.findByText('Linkedin', undefined, { timeout: 3000 });
await canvas.findByText(companiesMock[0].name, undefined, {
timeout: 3000,
});
const emptyDiv = canvas.getByTestId('data-field-input-click-outside-div');
@@ -24,6 +24,19 @@ import { recordStoreFamilySelector } from '@/object-record/record-store/states/s
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationType } from '~/generated-metadata/graphql';
import { getMockFieldMetadataItemOrThrow } from '~/testing/utils/getMockFieldMetadataItemOrThrow';
import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow';
const personMetadata = getMockObjectMetadataItemOrThrow('person');
const companyMetadata = getMockObjectMetadataItemOrThrow('company');
const companyFieldOnPerson = getMockFieldMetadataItemOrThrow({
objectMetadataItem: personMetadata,
fieldName: 'company',
});
const peopleFieldOnCompany = getMockFieldMetadataItemOrThrow({
objectMetadataItem: companyMetadata,
fieldName: 'people',
});
const RelationWorkspaceSetterEffect = () => {
useEffect(() => {
@@ -39,7 +52,7 @@ const RelationOneToManyFieldInputWithContext = () => {
const fieldDefinition = useMemo(
() => ({
fieldMetadataId: '94902a74-b8ca-4a56-8573-7469f0b664f6',
fieldMetadataId: peopleFieldOnCompany.id,
label: 'People',
type: FieldMetadataType.RELATION,
iconName: 'IconLink',
@@ -49,7 +62,7 @@ const RelationOneToManyFieldInputWithContext = () => {
relationObjectMetadataNamePlural: 'people',
relationObjectMetadataNameSingular: CoreObjectNameSingular.Person,
objectMetadataNameSingular: 'company',
relationFieldMetadataId: 'f6be42ac-ccb8-4df0-8c22-a9627d655c76',
relationFieldMetadataId: companyFieldOnPerson.id,
},
}),
[],
@@ -61,7 +61,7 @@ describe('computeViewRecordGqlOperationFilter', () => {
expect(result).toEqual({
name: {
ilike: '%Linkedin%',
ilike: `%${companiesMock[0].name}%`,
},
});
});
@@ -116,7 +116,7 @@ describe('computeViewRecordGqlOperationFilter', () => {
and: [
{
name: {
ilike: '%Linkedin%',
ilike: `%${companiesMock[0].name}%`,
},
},
{
@@ -16,8 +16,8 @@ import { useRecordIndexContextOrThrow } from '@/object-record/record-index/conte
import { hasUserSelectedAllRowsComponentState } from '@/object-record/record-table/record-table-row/states/hasUserSelectedAllRowsFamilyState';
import { selectedRowIdsComponentSelector } from '@/object-record/record-table/states/selectors/selectedRowIdsComponentSelector';
import { unselectedRowIdsComponentSelector } from '@/object-record/record-table/states/selectors/unselectedRowIdsComponentSelector';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useAtomComponentSelectorCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorCallbackState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { atom, useStore } from 'jotai';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
@@ -42,17 +42,17 @@ export const RecordIndexFiltersToContextStoreEffect = () => {
recordIndexId,
);
const hasUserSelectedAllRowsAtom = useAtomComponentStateCallbackState(
const hasUserSelectedAllRows = useAtomComponentStateValue(
hasUserSelectedAllRowsComponentState,
recordIndexId,
);
const selectedRowIdsAtom = useAtomComponentSelectorCallbackState(
const selectedRowIds = useAtomComponentSelectorValue(
selectedRowIdsComponentSelector,
recordIndexId,
);
const unselectedRowIdsAtom = useAtomComponentSelectorCallbackState(
const unselectedRowIds = useAtomComponentSelectorValue(
unselectedRowIdsComponentSelector,
recordIndexId,
);
@@ -88,17 +88,14 @@ export const RecordIndexFiltersToContextStoreEffect = () => {
anyFieldFilterValue: string;
},
) => {
const hasUserSelectedAllRows = get(hasUserSelectedAllRowsAtom);
let newRule: ContextStoreTargetedRecordsRule;
if (hasUserSelectedAllRows) {
const unselectedRowIds = get(unselectedRowIdsAtom);
newRule = {
mode: 'exclusion',
excludedRecordIds: unselectedRowIds,
};
} else {
const selectedRowIds = get(selectedRowIdsAtom);
newRule = {
mode: 'selection',
selectedRecordIds: selectedRowIds,
@@ -132,9 +129,9 @@ export const RecordIndexFiltersToContextStoreEffect = () => {
},
),
[
hasUserSelectedAllRowsAtom,
selectedRowIdsAtom,
unselectedRowIdsAtom,
hasUserSelectedAllRows,
selectedRowIds,
unselectedRowIds,
contextStoreTargetedRecordsRuleAtom,
contextStoreFiltersAtom,
contextStoreFilterGroupsAtom,
@@ -321,6 +321,10 @@ export const useMultipleRecordPickerPerformSearch = () => {
},
}));
if (operationSignatures.length === 0) {
return;
}
performCombinedFindManyRecords({ operationSignatures }).then(
({ result }) => {
Object.values(result)
@@ -10,10 +10,13 @@ import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorato
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { RecordTableDecorator } from '~/testing/decorators/RecordTableDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { getCompaniesMock } from '~/testing/mock-data/companies';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { mockedViewsData } from '~/testing/mock-data/views';
import { sleep } from '~/utils/sleep';
const companiesMock = getCompaniesMock();
const meta: Meta = {
title: 'Modules/ObjectRecord/RecordTable/RecordTable',
component: RecordTableWithWrappers,
@@ -70,7 +73,7 @@ export const ScrolledLeft: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findAllByText('Linkedin', {}, { timeout: 3000 });
await canvas.findAllByText(companiesMock[0].name, {}, { timeout: 3000 });
const scrollWrapper = canvasElement.ownerDocument.body.querySelector(
'.scroll-wrapper-x-enabled',
@@ -88,7 +91,7 @@ export const ScrolledLeft: Story = {
},
});
await canvas.findByText('Facebook');
await canvas.findByText(companiesMock[1].name);
},
};
@@ -100,7 +103,7 @@ export const ScrolledBottom: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findAllByText('Linkedin', {}, { timeout: 3000 });
await canvas.findAllByText(companiesMock[0].name, {}, { timeout: 3000 });
const scrollWrapper = canvasElement.ownerDocument.body.querySelector(
'.scroll-wrapper-y-enabled',
@@ -118,6 +121,6 @@ export const ScrolledBottom: Story = {
},
});
await canvas.findByText('Facebook');
await canvas.findByText(companiesMock[1].name);
},
};
@@ -4,9 +4,9 @@ import { type ColumnDefinition } from '@/object-record/record-table/types/Column
import { filterAvailableTableColumns } from '@/object-record/utils/filterAvailableTableColumns';
import { findByProperty } from 'twenty-shared/utils';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
import { getMockCompanyObjectMetadataItem } from '~/testing/mock-data/companies';
import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow';
const COMPANY_MOCK_OBJECT = getMockCompanyObjectMetadataItem();
const COMPANY_MOCK_OBJECT = getMockObjectMetadataItemOrThrow('company');
export const SIGN_IN_BACKGROUND_MOCK_COLUMN_DEFINITIONS = (
[
@@ -12,7 +12,13 @@ import { PageDragDropProvider } from '@/navigation/components/PageDragDropProvid
import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage';
import { OBJECT_SETTINGS_WIDTH } from '@/settings/data-model/constants/ObjectSettings';
import { SignInAppNavigationDrawerMock } from '@/sign-in-background-mock/components/SignInAppNavigationDrawerMock';
import { SignInBackgroundMockPage } from '@/sign-in-background-mock/components/SignInBackgroundMockPage';
import { lazy, Suspense } from 'react';
const SignInBackgroundMockPage = lazy(() =>
import('@/sign-in-background-mock/components/SignInBackgroundMockPage').then(
(module) => ({ default: module.SignInBackgroundMockPage }),
),
);
import { useShowFullscreen } from '@/ui/layout/fullscreen/hooks/useShowFullscreen';
import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal';
import { NAVIGATION_DRAWER_CONSTRAINTS } from '@/ui/layout/resizable-panel/constants/NavigationDrawerConstraints';
@@ -106,7 +112,9 @@ export const DefaultLayout = () => {
{showAuthModal ? (
<>
<StyledMainContainer>
<SignInBackgroundMockPage />
<Suspense fallback={null}>
<SignInBackgroundMockPage />
</Suspense>
</StyledMainContainer>
<AnimatePresence mode="wait">
<LayoutGroup>