Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save bengry/2e76d76a29da12ab8f5907c4dfc933c8 to your computer and use it in GitHub Desktop.
Save bengry/2e76d76a29da12ab8f5907c4dfc933c8 to your computer and use it in GitHub Desktop.
faker.js & `graphql-mocks` based consistant graphql mocking
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();
}
import { base as baseLocale, en as enLocale, Faker } from '@faker-js/faker';
export function createFakerInstance() {
return new Faker({ locale: [baseLocale, enLocale] });
}
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;
}
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);
}
};
}
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;
};
}
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,
})
);
}
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;
};
}
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 };
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