From eaafe328838ad93938a79f0c3b3a6ef02b07ddce Mon Sep 17 00:00:00 2001 From: Victor Guo Date: Mon, 12 Apr 2021 11:27:48 -0400 Subject: [PATCH] feat: allow users to provider default mock resolvers (#113) --- README.md | 51 ++++++ src/__tests__/lib.test.ts | 161 ++++++++++++++++++ src/__tests__/schema.ts | 2 + src/apollo/ErgonoMockedProvider.tsx | 9 +- src/apollo/MockLink.ts | 4 +- .../__tests__/ErgonoMockedProvider.test.tsx | 40 ++++- src/mock.ts | 36 ++-- 7 files changed, 285 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index fee8fcb..f12ee0a 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,56 @@ const mocks = {

+#### Providing default mock resolver functions + +If you have custom scalar types or would like to provide default mock resolver functions for certain types, you can pass your mock functions via the `resolvers` option. + +
+See example

+ +```js +const schema = gql` + type Shape = { + id: ID! + returnInt: Int + date: Date + nestedShape: Shape + } + + type Query { + getShape: Shape + } +`; + +const testQuery = gql` +{ + getShape { + id + returnInt + date + nestedShape { + date + } + } +} +`; + +const resp = ergonomock(schema, testQuery, { + resolvers: { + Date: () => "2021-04-09" + } +}); +expect(resp.data).toMatchObject({ + id: expect.toBeString(), + returnInt: expect.toBeNumber(), + date: '2021-04-09' + nestedShape { + date: '2021-04-09' + } +}); +``` +

+ #### Mocking Errors You can return or throw errors within the mock shape. @@ -431,6 +481,7 @@ This component's props are very similar to Apollo-Client's [MockedProvider](http - `mocks` is an object where keys are the operation names and the values are the `mocks` input that `ergonomock()` would accept. (i.e. could be empty, or any shape that matches the expected response.) - `onCall` is a handler that gets called by any executed query. The call signature is `({operation: GraphQLOperation, response: any}) => void` where response is the full response being returned to that single query. The purpose of `onCall` is to provide some sort of spy (or `jest.fn()`) to make assertions on which calls went through, with which variables, and get a handle on the generated values from `ergonomock()`. +- `resolvers` is an object where the keys are the `gql` type and the values are the mock resolver functions. It's designed to allow users to override or provide a default mock function for certain types. (e.g. custom scalar types) The call signature is `(root, args, context, info) => any`. ## Roadmap diff --git a/src/__tests__/lib.test.ts b/src/__tests__/lib.test.ts index ff035be..fb0d525 100644 --- a/src/__tests__/lib.test.ts +++ b/src/__tests__/lib.test.ts @@ -1126,4 +1126,165 @@ describe("Automocking", () => { expect(resp.errors).toStrictEqual([new GraphQLError("foo shape")]); }); }); + + describe("default mock resolvers", () => { + test("can mock partiallly resolved objects", () => { + const testQuery = /* GraphQL */ ` + { + returnShape { + id + returnInt + returnIntList + returnString + returnFloat + returnBoolean + } + } + `; + const resp: any = ergonomock(schema, testQuery, { + resolvers: { + Shape: () => ({ + // only returnInt and returnIntList are mocked, + // the rest of the results should be auto-mocked + returnInt: 1234, + returnIntList: [1234], + }), + }, + }); + expect(resp.data.returnShape).toMatchObject({ + id: expect.toBeString(), + returnInt: 1234, + returnIntList: [1234], + returnString: expect.toBeString(), + returnFloat: expect.toBeNumber(), + returnBoolean: expect.toBeBoolean(), + }); + }); + + test("it can mock partiallly resolved nested objects", () => { + const testQuery = /* GraphQL */ ` + { + returnShape { + id + returnInt + returnString + nestedShape { + id + returnString + } + } + } + `; + const resp: any = ergonomock(schema, testQuery, { + resolvers: { + Shape: () => ({ + // all returnString under Shape should return "Hello world!" + returnString: "Hello world!", + }), + }, + }); + expect(resp.data.returnShape).toMatchObject({ + id: expect.toBeString(), + returnInt: expect.toBeNumber(), + returnString: "Hello world!", + nestedShape: { + id: expect.toBeString(), + returnString: "Hello world!", + }, + }); + }); + + test("can override basic scalar type", () => { + const testQuery = /* GraphQL */ ` + { + returnShape { + returnInt + } + returnInt + returnListOfInt + } + `; + const resp: any = ergonomock(schema, testQuery, { + resolvers: { + Int: () => 1234, + }, + }); + expect(resp.data).toMatchObject({ + returnShape: { + returnInt: 1234, + }, + returnInt: 1234, + }); + expect(resp.data.returnListOfInt.every((number) => number === 1234)).toBe(true); + }); + + test("can provide a default resolver for custom scalar type", () => { + const testQuery = /* GraphQL */ ` + { + returnShape { + id + returnCustomScalar + } + } + `; + const resp: any = ergonomock(schema, testQuery, { + resolvers: { + CustomScalarType: () => "Custom scalar value", + }, + }); + expect(resp.data.returnShape.returnCustomScalar).toEqual("Custom scalar value"); + }); + + test("can't override union type", () => { + const testQuery = /* GraphQL */ ` + { + returnBirdsAndBees { + __typename + ... on Bird { + id + } + ... on Bee { + id + } + } + } + `; + const resp: any = ergonomock(schema, testQuery, { + resolvers: { + BirdsAndBees: () => ({ + id: 1234, + }), + }, + }); + expect(resp.data.returnBirdsAndBees.find(({ id }) => id === 1234)).toBeUndefined(); + }); + + test("mocks takes precedent over default mock resolvers", () => { + const testQuery = /* GraphQL */ ` + query { + returnShape { + id + returnString + } + } + `; + const mocks = { + returnShape: { + returnString: "return string from mock", + }, + }; + const resp: any = ergonomock(schema, testQuery, { + mocks, + resolvers: { + Shape: () => ({ + returnString: "return string from resolver", + }), + }, + }); + expect(resp.data.returnShape).toMatchObject({ + id: expect.toBeString(), + returnString: "return string from mock", + }); + }); + }); }); diff --git a/src/__tests__/schema.ts b/src/__tests__/schema.ts index 23a888f..fdabab5 100644 --- a/src/__tests__/schema.ts +++ b/src/__tests__/schema.ts @@ -2,6 +2,7 @@ import { buildSchemaFromTypeDefinitions } from "graphql-tools"; const schemaSDL = /* GraphQL */ ` scalar MissingMockType + scalar CustomScalarType interface Flying { id: ID! returnInt: Int @@ -41,6 +42,7 @@ const schemaSDL = /* GraphQL */ ` returnIDList: [ID] nestedShape: Shape nestedShapeList: [Shape] + returnCustomScalar: CustomScalarType } type RootQuery { returnInt: Int diff --git a/src/apollo/ErgonoMockedProvider.tsx b/src/apollo/ErgonoMockedProvider.tsx index f5d0dd3..076d8fb 100644 --- a/src/apollo/ErgonoMockedProvider.tsx +++ b/src/apollo/ErgonoMockedProvider.tsx @@ -1,16 +1,16 @@ import React from "react"; +import { GraphQLSchema, DocumentNode } from "graphql"; import { ApolloClient, DefaultOptions, ApolloCache, - Resolvers, ApolloLink, InMemoryCache, ApolloProvider, NormalizedCacheObject } from "@apollo/client"; import MockLink, { ApolloErgonoMockMap, MockLinkCallHandler } from "./MockLink"; -import { GraphQLSchema, DocumentNode } from "graphql"; +import { DefaultMockResolvers } from '../mock'; export interface ErgonoMockedProviderProps { schema: GraphQLSchema | DocumentNode; @@ -19,7 +19,7 @@ export interface ErgonoMockedProviderProps { addTypename?: boolean; defaultOptions?: DefaultOptions; cache?: ApolloCache; - resolvers?: Resolvers; + resolvers?: DefaultMockResolvers; children?: React.ReactElement; link?: ApolloLink; } @@ -40,8 +40,7 @@ export default function ErgonoMockedProvider(props: ErgonoMockedProviderProps) { const c = new ApolloClient({ cache: cache || new InMemoryCache({ addTypename }), defaultOptions, - link: link || new MockLink(schema, mocks || {}, { addTypename, onCall }), - resolvers + link: link || new MockLink(schema, mocks || {}, { addTypename, onCall, resolvers }), }); setClient(c); return () => client && ((client as unknown) as ApolloClient).stop(); diff --git a/src/apollo/MockLink.ts b/src/apollo/MockLink.ts index ecb12dd..427aaf0 100644 --- a/src/apollo/MockLink.ts +++ b/src/apollo/MockLink.ts @@ -1,11 +1,12 @@ import { ApolloLink, Operation, Observable, FetchResult } from "@apollo/client"; -import { ErgonoMockShape, ergonomock } from "../mock"; +import { ErgonoMockShape, ergonomock, DefaultMockResolvers } from "../mock"; import { GraphQLSchema, ExecutionResult, DocumentNode } from "graphql"; import stringify from "fast-json-stable-stringify"; type MockLinkOptions = { addTypename: Boolean; onCall?: MockLinkCallHandler; + resolvers?: DefaultMockResolvers; }; export type ApolloErgonoMockMap = Record< @@ -53,6 +54,7 @@ export default class MockLink extends ApolloLink { mocks: mock || {}, seed, variables: operation.variables, + resolvers: this.options.resolvers, }); // Return Observer to be compatible with apollo diff --git a/src/apollo/__tests__/ErgonoMockedProvider.test.tsx b/src/apollo/__tests__/ErgonoMockedProvider.test.tsx index 7468c49..b64c429 100644 --- a/src/apollo/__tests__/ErgonoMockedProvider.test.tsx +++ b/src/apollo/__tests__/ErgonoMockedProvider.test.tsx @@ -18,6 +18,7 @@ const QUERY_A = gql` id returnInt returnString + returnCustomScalar } } `; @@ -29,8 +30,10 @@ const ChildA = ({ shapeId }: { shapeId: string }): ReactElement => { return (
- Component ChildA. returnString: {data.queryShape.returnString} returnInt:{" "} - {data.queryShape.returnInt}{" "} + Component ChildA. +

returnString: {data.queryShape.returnString}

+

returnInt: {data.queryShape.returnInt}

+

returnCustomScalar: {data.queryShape.returnCustomScalar}

); }; @@ -160,6 +163,39 @@ test("can mock the same operation multiple times with a function", async () => { }); }); +test("it allows the user to provide default mock resolvers", async () => { + const spy = jest.fn(); + render( + ({ + returnString: `John Doe ${args.id}`, + }), + }} + > + + + ); + + expect(await screen.findByText(/returnString: John Doe 123/)).toBeVisible(); + expect(await screen.findByText(/returnInt: -?[0-9]+/)).toBeVisible(); + const { operation, response } = spy.mock.calls[0][0]; + expect(spy).toHaveBeenCalledTimes(1); + expect(operation.operationName).toEqual("OperationA"); + expect(operation.variables).toEqual({ shapeId: "123" }); + expect(response).toMatchObject({ + data: { + queryShape: { + __typename: "Shape", + returnString: "John Doe 123", + returnInt: expect.toBeNumber(), + }, + }, + }); +}); + test("automocking is stable and deterministic per operation query, name and variable", async () => { const spy1 = jest.fn(); const spy2 = jest.fn(); diff --git a/src/mock.ts b/src/mock.ts index 51aca24..2b572b8 100644 --- a/src/mock.ts +++ b/src/mock.ts @@ -35,10 +35,15 @@ export type ErgonoMockShape = { [k: string]: ErgonoMockShape | ErgonoMockLeaf | Array; }; +export type DefaultMockResolvers = { + [k: string]: GraphQLFieldResolver +}; + export type ErgonomockOptions = { mocks?: ErgonoMockShape; seed?: string; variables?: Record; + resolvers?: DefaultMockResolvers; }; export function ergonomock( @@ -73,6 +78,13 @@ export function ergonomock( random.seed(seed); + const resolverOverrides: Map> = new Map(); + if (options.resolvers) { + Object.entries(options.resolvers).forEach(([type, resolver]) => + resolverOverrides.set(type, resolver) + ); + } + const mockResolverFunction = function ( type: GraphQLType, fieldName?: string @@ -95,16 +107,6 @@ export function ergonomock( return root[fieldName]; } - // Default mock for enums - if (fieldType instanceof GraphQLEnumType) { - return getRandomElement(fieldType.getValues()).value; - } - - // Automock object types - if (isObjectType(fieldType)) { - return { __typename: fieldType.name }; - } - // Lists if (fieldType instanceof GraphQLList) { return random @@ -123,6 +125,20 @@ export function ergonomock( ); } + if (resolverOverrides.has(fieldType.name)) { + return resolverOverrides.get(fieldType.name)!(root, args, context, info); + } + + // Default mock for enums + if (fieldType instanceof GraphQLEnumType) { + return getRandomElement(fieldType.getValues()).value; + } + + // Automock object types + if (isObjectType(fieldType)) { + return { __typename: fieldType.name }; + } + // Mock default scalars if (defaultMockMap.has(fieldType.name)) { return defaultMockMap.get(fieldType.name)!(root, args, context, info);