Last active
August 6, 2024 08:27
-
-
Save bengry/2e76d76a29da12ab8f5907c4dfc933c8 to your computer and use it in GitHub Desktop.
faker.js & `graphql-mocks` based consistant graphql mocking
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { GraphQLResolveInfo } from 'graphql'; | |
export function buildGqlResolvedFieldPath( | |
path: GraphQLResolveInfo['path'] | |
): string { | |
const fieldPath = path.key; | |
if (path.prev) { | |
return `${buildGqlResolvedFieldPath(path.prev)}.${fieldPath}`; | |
} | |
return fieldPath.toString(); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { base as baseLocale, en as enLocale, Faker } from '@faker-js/faker'; | |
export function createFakerInstance() { | |
return new Faker({ locale: [baseLocale, enLocale] }); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { GraphQLResolveInfo } from 'graphql'; | |
import { graphqlQueryToSeed } from './utils/graphqlQueryToSeed'; | |
import { createFakerInstance } from './createFakerInstance'; | |
export function createFakerInstanceForQuery(queryInfo: GraphQLResolveInfo) { | |
const faker = createFakerInstance(); | |
faker.seed(graphqlQueryToSeed(queryInfo)); | |
return faker; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
isAbstractType, | |
isEnumType, | |
isNonNullType, | |
isObjectType, | |
} from 'graphql'; | |
import type { types as gqlMocksResolverTypes } from 'graphql-mocks'; | |
import { typeUtils as gqlMocksTypeUtils } from 'graphql-mocks/graphql'; | |
import { match } from 'ts-pattern'; | |
import { PageInfo } from '../../../../../src/@generated/graphqlTypes'; | |
import { createFakerInstanceForQuery } from './createFakerInstanceForQuery'; | |
export async function fakerFieldResolver(): Promise<gqlMocksResolverTypes.FieldResolver> { | |
return function internalFakerResolver(parent, _args, _context, info) { | |
const { fieldName, returnType } = info; | |
const faker = createFakerInstanceForQuery(info); | |
function arrayOfRandomLength<T>(createValue: () => T): T[] { | |
return Array.from({ length: faker.number.int({ max: 5 }) }, createValue); | |
} | |
function getValue(allowNull: boolean) { | |
const value = match(gqlMocksTypeUtils.unwrap(returnType)) | |
.with({ name: 'String' }, () => faker.word.sample()) | |
.with({ name: 'Int' }, () => faker.number.int({ min: 0, max: 10_000 })) | |
.with({ name: 'Float' }, () => faker.number.float()) | |
.with({ name: 'Boolean' }, () => faker.datatype.boolean()) | |
.with({ name: 'ID' }, () => faker.string.uuid()) | |
.with({ name: 'DateTime' }, () => faker.date.recent().toISOString()) | |
.when(isEnumType, enumType => { | |
const possibleValues = enumType | |
.getValues() | |
.map(enumValue => enumValue.value); | |
return faker.helpers.arrayElement(possibleValues); | |
}) | |
.with({ name: 'JSON' }, () => ({ | |
// NOTE: simple basic JSON object | |
aProperty: 'aValue', | |
})) | |
.otherwise(() => { | |
throw new Error( | |
`Unsupported type: ${gqlMocksTypeUtils.unwrap(returnType).name}` | |
); | |
}); | |
if (allowNull) { | |
return faker.helpers.maybe(() => value) ?? null; | |
} | |
return value; | |
} | |
if (parent && fieldName in parent) { | |
return parent[fieldName]; | |
} | |
const unwrappedReturnType = gqlMocksTypeUtils.unwrap(returnType); | |
const isList = gqlMocksTypeUtils.hasListType(returnType); | |
const isNonNull = isNonNullType(returnType); | |
if ( | |
isObjectType(unwrappedReturnType) || | |
isAbstractType(unwrappedReturnType) | |
) { | |
// handles list case where the *number* to resolve is determined here | |
// but the actual data of each field is handled in follow up recursive | |
// resolving for each individual field. | |
if (isList) { | |
const array = arrayOfRandomLength(() => ({})); | |
return array; | |
} | |
if (unwrappedReturnType.name === 'PageInfo') { | |
return { | |
hasNextPage: false, | |
endCursor: null, | |
} satisfies PageInfo; | |
} | |
// otherwise, return and let future resolvers figure | |
// out the scalar field data | |
return {}; | |
} | |
if (isList) { | |
const allowNullListItems = !isNonNullType( | |
gqlMocksTypeUtils.listItemType(returnType) | |
); | |
const values = arrayOfRandomLength(() => getValue(allowNullListItems)); | |
if (!isNonNull) { | |
return faker.helpers.maybe(() => values) ?? null; | |
} | |
return values; | |
} else { | |
return getValue(!isNonNull); | |
} | |
}; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { GraphQLObjectType, GraphQLSchema } from 'graphql'; | |
import type { types as GqlMocksTypes } from 'graphql-mocks'; | |
import { extractDependencies } from 'graphql-mocks/resolver'; | |
import { createFakerInstanceForQuery } from './createFakerInstanceForQuery'; | |
export async function fakerTypeResolver(): Promise<GqlMocksTypes.TypeResolver> { | |
return (value, context, info, abstractType) => { | |
const { graphqlSchema } = extractDependencies<{ | |
graphqlSchema: GraphQLSchema; | |
}>(context, ['graphqlSchema']); | |
if (value?.__typename) { | |
return value.__typename; | |
} | |
const faker = createFakerInstanceForQuery(info); | |
const possibleTypes = graphqlSchema.getPossibleTypes( | |
abstractType | |
) as GraphQLObjectType[]; | |
const chosenType = faker.helpers.arrayElement(possibleTypes); | |
return chosenType.name; | |
}; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { GraphQLResolveInfo } from 'graphql'; | |
import { stringToNumberHash } from '../../../utils/stringToNumberHash'; | |
import { buildGqlResolvedFieldPath } from '../buildGqlResolvedFieldPath'; | |
export function graphqlQueryToSeed(info: GraphQLResolveInfo): number { | |
return stringToNumberHash( | |
JSON.stringify({ | |
path: buildGqlResolvedFieldPath(info.path), | |
variableValues: info.variableValues, | |
}) | |
); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { GraphQLSchema } from 'graphql'; | |
import type { types as gqlMocksTypes } from 'graphql-mocks'; | |
import * as gqlMocksHighlight from 'graphql-mocks/highlight'; | |
import * as gqlMocksResolverMap from 'graphql-mocks/resolver-map'; | |
import { fakerFieldResolver } from './_internal/fakerFieldResolver'; | |
import { fakerTypeResolver } from './_internal/fakerTypeResolver'; | |
export async function fakerMiddleware(config: { | |
graphqlSchema: GraphQLSchema; | |
}): Promise<gqlMocksTypes.ResolverMapMiddleware> { | |
// note that we pass in the schema here as well in order to start doing work ASAP, and not only when the first test runs, which slows it down | |
const { graphqlSchema } = config; | |
const [fieldResolver, typeResolver] = await Promise.all([ | |
fakerFieldResolver(), | |
fakerTypeResolver(), | |
]); | |
const highlighter = gqlMocksHighlight.utils.coerceHighlight( | |
graphqlSchema, | |
gqlMocksResolverMap.utils.highlightAllCallback | |
); | |
const fieldResolvableHighlight = highlighter | |
.filter(gqlMocksHighlight.field()) | |
.exclude(gqlMocksHighlight.interfaceField()); | |
const typeResolvableHighlight = highlighter.filter( | |
gqlMocksHighlight.combine( | |
gqlMocksHighlight.union(), | |
gqlMocksHighlight.interfaces() | |
) | |
); | |
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- just know that it's there | |
return async (resolverMap, _packOptions) => { | |
const fieldResolverPromise = gqlMocksHighlight.utils.walk( | |
graphqlSchema, | |
fieldResolvableHighlight.references, | |
({ reference }) => { | |
gqlMocksResolverMap.setResolver(resolverMap, reference, fieldResolver, { | |
graphqlSchema, | |
}); | |
} | |
); | |
const typeResolverPromise = gqlMocksHighlight.utils.walk( | |
graphqlSchema, | |
typeResolvableHighlight.references, | |
({ reference }) => { | |
gqlMocksResolverMap.setResolver(resolverMap, reference, typeResolver, { | |
graphqlSchema, | |
}); | |
} | |
); | |
await Promise.all([fieldResolverPromise, typeResolverPromise]); | |
return resolverMap; | |
}; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { GraphQLFileLoader } from '@graphql-tools/graphql-file-loader'; | |
import { loadSchema } from '@graphql-tools/load/schema'; | |
import { expect, test as base } from '@playwright/test'; | |
import type { ExecutionResult, GraphQLSchema } from 'graphql'; | |
import { GraphQLHandler } from 'graphql-mocks'; | |
import { | |
delay, | |
graphql, | |
GraphQLResponseResolver, | |
http, | |
HttpResponse, | |
} from 'msw'; | |
import path from 'node:path'; | |
import { | |
type Config, | |
createWorkerFixture, | |
type MockServiceWorker, | |
} from 'playwright-msw'; | |
/** | |
* Makes `K` properties in an object `O` non-nil. | |
*/ | |
type NonNullableKeys< | |
O extends object, | |
K extends keyof O = keyof O, | |
> = Omit<O, K> & { | |
[P in K]-?: NonNullable<O[P]>; | |
}; | |
import { Query } from '../../../src/@generated/graphqlTypes'; | |
import { fakerMiddleware } from './fakerMiddleware/fakerMiddleware'; | |
type GraphQLResponseResolverInfo = Parameters<GraphQLResponseResolver>[0]; | |
const schemaFilePath = path.resolve( | |
import.meta.dirname, | |
'../../../schema.generated.graphql' | |
); | |
const graphqlSchema = await loadSchema(schemaFilePath, { | |
loaders: [new GraphQLFileLoader()], | |
}); | |
const handler = new GraphQLHandler({ | |
dependencies: { graphqlSchema }, | |
middlewares: [await fakerMiddleware({ graphqlSchema })], | |
}); | |
export async function autoMockData<TData extends Pick<Query, '__typename'>>({ | |
query, | |
variables, | |
}: Pick<GraphQLResponseResolverInfo, 'query' | 'variables'>): Promise< | |
NonNullableKeys<ExecutionResult<TData>, 'data'> | |
> { | |
const responseData = await handler.query(query, variables).catch(error => { | |
throw new AggregateError([error], 'Failed to mock the query'); | |
}); | |
return responseData as NonNullableKeys<ExecutionResult<TData>, 'data'>; | |
} | |
export async function autoMock({ | |
query, | |
variables, | |
}: Pick<GraphQLResponseResolverInfo, 'query' | 'variables'>) { | |
const [mockData] = await Promise.all([ | |
autoMockData({ query, variables }), | |
// We add artificial delay in case the mock server is too fast | |
delay(), | |
]); | |
return HttpResponse.json(mockData); | |
} | |
function testFactory(config?: Config) { | |
return base.extend<{ | |
worker: MockServiceWorker; | |
http: typeof http; | |
graphql: typeof graphql; | |
graphqlSchema: GraphQLSchema; | |
}>({ | |
worker: createWorkerFixture([], config), | |
http, | |
graphql, | |
graphqlSchema, | |
}); | |
} | |
const test = testFactory(); | |
export { expect, test }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
Page, | |
PlaywrightTestArgs, | |
PlaywrightTestOptions, | |
TestType, | |
} from '@playwright/test'; | |
import { delay, GraphQLQuery, GraphQLVariables, HttpResponse } from 'msw'; | |
import { GraphQLOperationName } from '../../../src/@generated/graphqlTypes'; | |
import { AllowStringEnumDeep } from './utils/AllowStringEnumDeep'; | |
import { autoMock, autoMockData, test as base } from './mswFixture'; | |
type Context = Omit< | |
typeof base extends TestType<infer Args, {}> ? Args : never, | |
keyof (PlaywrightTestArgs & PlaywrightTestOptions) | |
>; | |
class MockServer { | |
constructor( | |
private readonly page: Page, | |
private readonly context: Context | |
) {} | |
async autoMockAllQueries() { | |
const { graphql, worker } = this.context; | |
await worker.use(graphql.operation(autoMock)); | |
} | |
async mockGraphQLQuery< | |
TQuery extends GraphQLQuery, | |
TVariables extends GraphQLVariables = never, | |
>( | |
operationName: GraphQLOperationName, | |
response: | |
| AllowStringEnumDeep<TQuery> | |
| (( | |
initialResponse: AllowStringEnumDeep<TQuery> | |
) => AllowStringEnumDeep<TQuery>) | |
) { | |
const { graphql, worker } = this.context; | |
await worker.use( | |
graphql.query<AllowStringEnumDeep<TQuery>, TVariables>( | |
operationName, | |
async ({ query, variables }) => { | |
if (typeof response === 'object') { | |
const [delayedResponse] = await Promise.all([ | |
HttpResponse.json({ data: response }), | |
delay(), | |
]); | |
return delayedResponse; | |
} | |
const autoMockedResponse = await autoMockData({ query, variables }); | |
const finalResponse = HttpResponse.json({ | |
...autoMockedResponse, | |
data: response( | |
autoMockedResponse.data as AllowStringEnumDeep<TQuery> | |
), | |
}); | |
const [delayedResponse] = await Promise.all([finalResponse, delay()]); | |
return delayedResponse; | |
} | |
) | |
); | |
} | |
async mockGraphQLMutation< | |
TMutation extends GraphQLQuery, | |
TVariables extends GraphQLVariables = never, | |
>( | |
operationName: GraphQLOperationName, | |
handler: (variables: TVariables) => TMutation | |
) { | |
const { graphql, worker } = this.context; | |
const mutation = graphql.mutation<TMutation, TVariables>( | |
operationName, | |
async ({ variables }) => { | |
const [delayedResponse] = await Promise.all([ | |
HttpResponse.json({ data: handler(variables) }), | |
delay(), | |
]); | |
return delayedResponse; | |
} | |
); | |
await worker.use(mutation); | |
return { | |
get called() { | |
return mutation.isUsed; | |
}, | |
}; | |
} | |
} | |
export const test = base.extend<{ | |
server: MockServer; | |
}>({ | |
server: [ | |
async ({ page, worker, graphql, http, graphqlSchema }, use) => { | |
const authPage = new MockServer(page, { | |
worker, | |
graphql, | |
http, | |
graphqlSchema, | |
}); | |
await authPage.autoMockAllQueries(); | |
await use(authPage); | |
}, | |
{ auto: true }, | |
], | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment