@khanacademy/wonder-blocks-testing 0.0.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,267 @@
1
+ // @flow
2
+ import * as React from "react";
3
+ import {render, screen, waitFor} from "@testing-library/react";
4
+
5
+ import {GqlRouter, useGql} from "@khanacademy/wonder-blocks-data";
6
+ import {RespondWith} from "../make-gql-mock-response.js";
7
+ import {mockGqlFetch} from "../mock-gql-fetch.js";
8
+
9
+ describe("integrating mockGqlFetch, RespondWith, GqlRouter and useGql", () => {
10
+ it("should reject with error indicating there are no mocks", async () => {
11
+ // Arrange
12
+ const mockFetch = mockGqlFetch();
13
+ const RenderError = () => {
14
+ const [result, setResult] = React.useState(null);
15
+ const gqlFetch = useGql();
16
+ React.useEffect(() => {
17
+ gqlFetch({
18
+ type: "query",
19
+ id: "getMyStuff",
20
+ }).catch((e) => {
21
+ setResult(e.message);
22
+ });
23
+ }, [gqlFetch]);
24
+
25
+ return <div data-test-id="result">{result}</div>;
26
+ };
27
+
28
+ // Act
29
+ render(
30
+ <GqlRouter defaultContext={{}} fetch={mockFetch}>
31
+ <RenderError />
32
+ </GqlRouter>,
33
+ );
34
+ const result = screen.getByTestId("result");
35
+
36
+ // Assert
37
+ await waitFor(() =>
38
+ expect(result).toHaveTextContent(
39
+ "No matching GraphQL mock response found for request",
40
+ ),
41
+ );
42
+ });
43
+
44
+ it("should resolve with data for RespondWith.data", async () => {
45
+ // Arrange
46
+ const mockFetch = mockGqlFetch();
47
+ const query = {
48
+ type: "query",
49
+ id: "getMyStuff",
50
+ };
51
+ const data = {myStuff: "stuff"};
52
+ const RenderError = () => {
53
+ const [result, setResult] = React.useState(null);
54
+ const gqlFetch = useGql();
55
+ React.useEffect(() => {
56
+ // eslint-disable-next-line promise/catch-or-return
57
+ gqlFetch(query).then((r) => {
58
+ setResult(JSON.stringify(r ?? "(null)"));
59
+ return;
60
+ });
61
+ }, [gqlFetch]);
62
+
63
+ return <div data-test-id="result">{result}</div>;
64
+ };
65
+
66
+ // Act
67
+ mockFetch.mockOperation({operation: query}, RespondWith.data(data));
68
+ render(
69
+ <GqlRouter defaultContext={{}} fetch={mockFetch}>
70
+ <RenderError />
71
+ </GqlRouter>,
72
+ );
73
+ const result = screen.getByTestId("result");
74
+
75
+ // Assert
76
+ await waitFor(() =>
77
+ expect(result).toHaveTextContent(JSON.stringify(data)),
78
+ );
79
+ });
80
+
81
+ it("should respond with null data for RespondWith.abortedRequest", async () => {
82
+ // Arrange
83
+ const mockFetch = mockGqlFetch();
84
+ const query = {
85
+ type: "query",
86
+ id: "getMyStuff",
87
+ };
88
+ const RenderError = () => {
89
+ const [result, setResult] = React.useState(null);
90
+ const gqlFetch = useGql();
91
+ React.useEffect(() => {
92
+ // eslint-disable-next-line promise/catch-or-return
93
+ gqlFetch(query).then((r) => {
94
+ setResult(JSON.stringify(r ?? "(null)"));
95
+ return;
96
+ });
97
+ }, [gqlFetch]);
98
+
99
+ return <div data-test-id="result">{result}</div>;
100
+ };
101
+
102
+ // Act
103
+ mockFetch.mockOperation(
104
+ {operation: query},
105
+ RespondWith.abortedRequest(),
106
+ );
107
+ render(
108
+ <GqlRouter defaultContext={{}} fetch={mockFetch}>
109
+ <RenderError />
110
+ </GqlRouter>,
111
+ );
112
+ const result = screen.getByTestId("result");
113
+
114
+ // Assert
115
+ await waitFor(() => expect(result).toHaveTextContent('"(null)"'));
116
+ });
117
+
118
+ it("should reject with unsuccessful response error for RespondWith.errorStatusCode", async () => {
119
+ // Arrange
120
+ const mockFetch = mockGqlFetch();
121
+ const query = {
122
+ type: "query",
123
+ id: "getMyStuff",
124
+ };
125
+ const RenderError = () => {
126
+ const [result, setResult] = React.useState(null);
127
+ const gqlFetch = useGql();
128
+ React.useEffect(() => {
129
+ // eslint-disable-next-line promise/catch-or-return
130
+ gqlFetch(query).catch((e) => {
131
+ setResult(e.message);
132
+ });
133
+ }, [gqlFetch]);
134
+
135
+ return <div data-test-id="result">{result}</div>;
136
+ };
137
+
138
+ // Act
139
+ mockFetch.mockOperation(
140
+ {operation: query},
141
+ RespondWith.errorStatusCode(404),
142
+ );
143
+ render(
144
+ <GqlRouter defaultContext={{}} fetch={mockFetch}>
145
+ <RenderError />
146
+ </GqlRouter>,
147
+ );
148
+ const result = screen.getByTestId("result");
149
+
150
+ // Assert
151
+ await waitFor(() =>
152
+ expect(result).toHaveTextContent("Response unsuccessful"),
153
+ );
154
+ });
155
+
156
+ it("should reject with parse error for RespondWith.unparseableBody", async () => {
157
+ // Arrange
158
+ const mockFetch = mockGqlFetch();
159
+ const query = {
160
+ type: "query",
161
+ id: "getMyStuff",
162
+ };
163
+ const RenderError = () => {
164
+ const [result, setResult] = React.useState(null);
165
+ const gqlFetch = useGql();
166
+ React.useEffect(() => {
167
+ // eslint-disable-next-line promise/catch-or-return
168
+ gqlFetch(query).catch((e) => {
169
+ setResult(e.message);
170
+ });
171
+ }, [gqlFetch]);
172
+
173
+ return <div data-test-id="result">{result}</div>;
174
+ };
175
+
176
+ // Act
177
+ mockFetch.mockOperation(
178
+ {operation: query},
179
+ RespondWith.unparseableBody(),
180
+ );
181
+ render(
182
+ <GqlRouter defaultContext={{}} fetch={mockFetch}>
183
+ <RenderError />
184
+ </GqlRouter>,
185
+ );
186
+ const result = screen.getByTestId("result");
187
+
188
+ // Assert
189
+ await waitFor(() =>
190
+ expect(result).toHaveTextContent("Failed to parse response"),
191
+ );
192
+ });
193
+
194
+ it("should reject with missing response error for RespondWith.nonGraphQLBody", async () => {
195
+ // Arrange
196
+ const mockFetch = mockGqlFetch();
197
+ const query = {
198
+ type: "query",
199
+ id: "getMyStuff",
200
+ };
201
+ const RenderError = () => {
202
+ const [result, setResult] = React.useState(null);
203
+ const gqlFetch = useGql();
204
+ React.useEffect(() => {
205
+ // eslint-disable-next-line promise/catch-or-return
206
+ gqlFetch(query).catch((e) => {
207
+ setResult(e.message);
208
+ });
209
+ }, [gqlFetch]);
210
+
211
+ return <div data-test-id="result">{result}</div>;
212
+ };
213
+
214
+ // Act
215
+ mockFetch.mockOperation(
216
+ {operation: query},
217
+ RespondWith.nonGraphQLBody(),
218
+ );
219
+ render(
220
+ <GqlRouter defaultContext={{}} fetch={mockFetch}>
221
+ <RenderError />
222
+ </GqlRouter>,
223
+ );
224
+ const result = screen.getByTestId("result");
225
+
226
+ // Assert
227
+ await waitFor(() =>
228
+ expect(result).toHaveTextContent("Server response missing"),
229
+ );
230
+ });
231
+
232
+ it("should reject with GraphQL error for RespondWith.graphQLErrors", async () => {
233
+ // Arrange
234
+ const mockFetch = mockGqlFetch();
235
+ const query = {
236
+ type: "query",
237
+ id: "getMyStuff",
238
+ };
239
+ const RenderError = () => {
240
+ const [result, setResult] = React.useState(null);
241
+ const gqlFetch = useGql();
242
+ React.useEffect(() => {
243
+ // eslint-disable-next-line promise/catch-or-return
244
+ gqlFetch(query).catch((e) => {
245
+ setResult(e.message);
246
+ });
247
+ }, [gqlFetch]);
248
+
249
+ return <div data-test-id="result">{result}</div>;
250
+ };
251
+
252
+ // Act
253
+ mockFetch.mockOperation(
254
+ {operation: query},
255
+ RespondWith.graphQLErrors(["error 1", "error 2"]),
256
+ );
257
+ render(
258
+ <GqlRouter defaultContext={{}} fetch={mockFetch}>
259
+ <RenderError />
260
+ </GqlRouter>,
261
+ );
262
+ const result = screen.getByTestId("result");
263
+
264
+ // Assert
265
+ await waitFor(() => expect(result).toHaveTextContent("GraphQL errors"));
266
+ });
267
+ });
@@ -0,0 +1,74 @@
1
+ // @flow
2
+ import type {GqlOperation, GqlContext} from "@khanacademy/wonder-blocks-data";
3
+ import type {GqlMockOperation} from "./types.js";
4
+
5
+ const safeHasOwnProperty = (obj: any, prop: string): boolean =>
6
+ // Flow really shouldn't be raising this error here.
7
+ // $FlowFixMe[method-unbinding]
8
+ Object.prototype.hasOwnProperty.call(obj, prop);
9
+
10
+ // TODO(somewhatabstract, FEI-4268): use a third-party library to do this and
11
+ // possibly make it also support the jest `jest.objectContaining` type matching
12
+ // to simplify mock declaration (note that it would need to work in regular
13
+ // tests and stories/fixtures).
14
+ const areObjectsEqual = (a: any, b: any): boolean => {
15
+ if (a === b) {
16
+ return true;
17
+ }
18
+ if (a == null || b == null) {
19
+ return false;
20
+ }
21
+ if (typeof a !== "object" || typeof b !== "object") {
22
+ return false;
23
+ }
24
+ const aKeys = Object.keys(a);
25
+ const bKeys = Object.keys(b);
26
+ if (aKeys.length !== bKeys.length) {
27
+ return false;
28
+ }
29
+ for (let i = 0; i < aKeys.length; i++) {
30
+ const key = aKeys[i];
31
+ if (!safeHasOwnProperty(b, key) || !areObjectsEqual(a[key], b[key])) {
32
+ return false;
33
+ }
34
+ }
35
+ return true;
36
+ };
37
+
38
+ export const gqlRequestMatchesMock = (
39
+ mock: GqlMockOperation<any, any, any, any>,
40
+ operation: GqlOperation<any, any, any>,
41
+ variables: ?{...},
42
+ context: GqlContext,
43
+ ): boolean => {
44
+ // If they don't represent the same operation, then they can't match.
45
+ // NOTE: Operations can include more fields than id and type, but we only
46
+ // care about id and type. The rest is ignored.
47
+ if (
48
+ mock.operation.id !== operation.id ||
49
+ mock.operation.type !== operation.type
50
+ ) {
51
+ return false;
52
+ }
53
+
54
+ // We do a loose match, so if the lhs doesn't define variables,
55
+ // we just assume it matches everything.
56
+ if (mock.variables != null) {
57
+ // Variables have to match.
58
+ if (!areObjectsEqual(mock.variables, variables)) {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ // We do a loose match, so if the lhs doesn't define context,
64
+ // we just assume it matches everything.
65
+ if (mock.context != null) {
66
+ // Context has to match.
67
+ if (!areObjectsEqual(mock.context, context)) {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ // If we get here, we have a match.
73
+ return true;
74
+ };
@@ -0,0 +1,124 @@
1
+ // @flow
2
+ export opaque type GqlMockResponse<TData> =
3
+ | {|
4
+ type: "data",
5
+ data: TData,
6
+ |}
7
+ | {|
8
+ type: "parse",
9
+ |}
10
+ | {|
11
+ type: "abort",
12
+ |}
13
+ | {|
14
+ type: "status",
15
+ statusCode: number,
16
+ |}
17
+ | {|
18
+ type: "invalid",
19
+ |}
20
+ | {|type: "graphql", errors: $ReadOnlyArray<string>|};
21
+
22
+ /**
23
+ * Helpers to define rejection states for mocking GQL requests.
24
+ */
25
+ export const RespondWith = Object.freeze({
26
+ data: <TData>(data: TData): GqlMockResponse<TData> => ({
27
+ type: "data",
28
+ data,
29
+ }),
30
+ unparseableBody: (): GqlMockResponse<any> => ({type: "parse"}),
31
+ abortedRequest: (): GqlMockResponse<any> => ({type: "abort"}),
32
+ errorStatusCode: (statusCode: number): GqlMockResponse<any> => {
33
+ if (statusCode < 300) {
34
+ throw new Error(`${statusCode} is not a valid error status code`);
35
+ }
36
+ return {
37
+ type: "status",
38
+ statusCode,
39
+ };
40
+ },
41
+ nonGraphQLBody: (): GqlMockResponse<any> => ({type: "invalid"}),
42
+ graphQLErrors: (
43
+ errorMessages: $ReadOnlyArray<string>,
44
+ ): GqlMockResponse<any> => ({
45
+ type: "graphql",
46
+ errors: errorMessages,
47
+ }),
48
+ });
49
+
50
+ /**
51
+ * Turns an ErrorResponse value in an actual Response that will invoke
52
+ * that error.
53
+ */
54
+ export const makeGqlMockResponse = <TData>(
55
+ response: GqlMockResponse<TData>,
56
+ ): Promise<Response> => {
57
+ switch (response.type) {
58
+ case "data":
59
+ return Promise.resolve(
60
+ ({
61
+ status: 200,
62
+ text: () =>
63
+ Promise.resolve(
64
+ JSON.stringify({
65
+ data: response.data,
66
+ }),
67
+ ),
68
+ }: any),
69
+ );
70
+
71
+ case "parse":
72
+ return Promise.resolve(
73
+ ({
74
+ status: 200,
75
+ text: () => Promise.resolve("INVALID JSON"),
76
+ }: any),
77
+ );
78
+
79
+ case "abort":
80
+ const abortError = new Error("Mock request aborted");
81
+ abortError.name = "AbortError";
82
+ return Promise.reject(abortError);
83
+
84
+ case "status":
85
+ return Promise.resolve(
86
+ ({
87
+ status: response.statusCode,
88
+ text: () => Promise.resolve(JSON.stringify({})),
89
+ }: any),
90
+ );
91
+
92
+ case "invalid":
93
+ return Promise.resolve(
94
+ ({
95
+ status: 200,
96
+ text: () =>
97
+ Promise.resolve(
98
+ JSON.stringify({
99
+ valid: "json",
100
+ that: "is not a valid graphql response",
101
+ }),
102
+ ),
103
+ }: any),
104
+ );
105
+
106
+ case "graphql":
107
+ return Promise.resolve(
108
+ ({
109
+ status: 200,
110
+ text: () =>
111
+ Promise.resolve(
112
+ JSON.stringify({
113
+ errors: response.errors.map((e) => ({
114
+ message: e,
115
+ })),
116
+ }),
117
+ ),
118
+ }: any),
119
+ );
120
+
121
+ default:
122
+ throw new Error(`Unknown response type: ${response.type}`);
123
+ }
124
+ };
@@ -0,0 +1,96 @@
1
+ // @flow
2
+ import type {GqlContext} from "@khanacademy/wonder-blocks-data";
3
+ import {gqlRequestMatchesMock} from "./gql-request-matches-mock.js";
4
+ import {makeGqlMockResponse} from "./make-gql-mock-response.js";
5
+ import type {GqlMockResponse} from "./make-gql-mock-response.js";
6
+ import type {GqlMock, GqlMockOperation, GqlFetchMockFn} from "./types.js";
7
+
8
+ /**
9
+ * A mock for the fetch function passed to GqlRouter.
10
+ */
11
+ export const mockGqlFetch = (): GqlFetchMockFn => {
12
+ // We want this to work in jest and in fixtures to make life easy for folks.
13
+ // This is the array of mocked operations that we will traverse and
14
+ // manipulate.
15
+ const mocks: Array<GqlMock> = [];
16
+
17
+ // What we return has to be a drop in for the fetch function that is
18
+ // provided to `GqlRouter` which is how folks will then use this mock.
19
+ const gqlFetchMock: GqlFetchMockFn = (
20
+ operation,
21
+ variables,
22
+ context,
23
+ ): Promise<Response> => {
24
+ // Iterate our mocked operations and find the first one that matches.
25
+ for (const mock of mocks) {
26
+ if (mock.onceOnly && mock.used) {
27
+ // This is a once-only mock and it has been used, so skip it.
28
+ continue;
29
+ }
30
+ if (
31
+ gqlRequestMatchesMock(
32
+ mock.operation,
33
+ operation,
34
+ variables,
35
+ context,
36
+ )
37
+ ) {
38
+ mock.used = true;
39
+ return mock.response();
40
+ }
41
+ }
42
+
43
+ // Default is to reject with some helpful info on what request
44
+ // we rejected.
45
+ return Promise.reject(
46
+ new Error(`No matching GraphQL mock response found for request:
47
+ Operation: ${operation.type} ${operation.id}
48
+ Variables: ${
49
+ variables == null ? "None" : JSON.stringify(variables, null, 2)
50
+ }
51
+ Context: ${JSON.stringify(context, null, 2)}`),
52
+ );
53
+ };
54
+
55
+ const addMockedOperation = <
56
+ TType,
57
+ TData,
58
+ TVariables: {...},
59
+ TContext: GqlContext,
60
+ >(
61
+ operation: GqlMockOperation<TType, TData, TVariables, TContext>,
62
+ response: GqlMockResponse<TData>,
63
+ onceOnly: boolean,
64
+ ): GqlFetchMockFn => {
65
+ const mockResponse = () => makeGqlMockResponse(response);
66
+ mocks.push({
67
+ operation,
68
+ response: mockResponse,
69
+ onceOnly,
70
+ used: false,
71
+ });
72
+ return gqlFetchMock;
73
+ };
74
+
75
+ gqlFetchMock.mockOperation = <
76
+ TType,
77
+ TData,
78
+ TVariables: {...},
79
+ TContext: GqlContext,
80
+ >(
81
+ operation: GqlMockOperation<TType, TData, TVariables, TContext>,
82
+ response: GqlMockResponse<TData>,
83
+ ): GqlFetchMockFn => addMockedOperation(operation, response, false);
84
+
85
+ gqlFetchMock.mockOperationOnce = <
86
+ TType,
87
+ TData,
88
+ TVariables: {...},
89
+ TContext: GqlContext,
90
+ >(
91
+ operation: GqlMockOperation<TType, TData, TVariables, TContext>,
92
+ response: GqlMockResponse<TData>,
93
+ ): GqlFetchMockFn => addMockedOperation(operation, response, true);
94
+
95
+ return gqlFetchMock;
96
+ };
@@ -0,0 +1,41 @@
1
+ //@flow
2
+ import type {GqlOperation, GqlContext} from "@khanacademy/wonder-blocks-data";
3
+ import type {GqlMockResponse} from "./make-gql-mock-response.js";
4
+
5
+ export type GqlMockOperation<
6
+ TType,
7
+ TData,
8
+ TVariables: {...},
9
+ TContext: GqlContext,
10
+ > = {|
11
+ operation: GqlOperation<TType, TData, TVariables>,
12
+ variables?: TVariables,
13
+ context?: TContext,
14
+ |};
15
+
16
+ type GqlMockOperationFn = <
17
+ TType,
18
+ TData,
19
+ TVariables: {...},
20
+ TContext: GqlContext,
21
+ >(
22
+ operation: GqlMockOperation<TType, TData, TVariables, TContext>,
23
+ response: GqlMockResponse<TData>,
24
+ ) => GqlFetchMockFn;
25
+
26
+ export type GqlFetchMockFn = {|
27
+ (
28
+ operation: GqlOperation<any, any, any>,
29
+ variables: ?{...},
30
+ context: GqlContext,
31
+ ): Promise<Response>,
32
+ mockOperation: GqlMockOperationFn,
33
+ mockOperationOnce: GqlMockOperationFn,
34
+ |};
35
+
36
+ export type GqlMock = {|
37
+ operation: GqlMockOperation<any, any, any, any>,
38
+ onceOnly: boolean,
39
+ used: boolean,
40
+ response: () => Promise<Response>,
41
+ |};