├── .prettierrc
├── docs
├── tsconfig.json
├── src
│ ├── env.d.ts
│ └── content
│ │ ├── docs
│ │ ├── creating-a-handler.mdx
│ │ ├── integrations
│ │ │ ├── browser.mdx
│ │ │ └── node.mdx
│ │ ├── index.mdx
│ │ └── installation.mdx
│ │ └── config.ts
├── .gitignore
├── package.json
├── public
│ └── favicon.svg
├── astro.config.mjs
└── README.md
├── .storybook
├── vite-env.d.ts
├── preview-head.html
├── stories
│ ├── components
│ │ ├── Container.tsx
│ │ ├── TeamLogo.tsx
│ │ ├── Product.tsx
│ │ ├── logos
│ │ │ ├── AtlantaDream.svg
│ │ │ ├── LosAngelesSparks.svg
│ │ │ ├── ChicagoSky.svg
│ │ │ ├── MinnesotaLynx.svg
│ │ │ ├── ConnecticutSun.svg
│ │ │ ├── NewYorkLiberty.svg
│ │ │ └── IndianaFever.svg
│ │ ├── relay
│ │ │ ├── __generated__
│ │ │ │ ├── RelayComponentReviewsFragment_product.graphql.ts
│ │ │ │ ├── RelayComponentAppQuery.graphql.ts
│ │ │ │ └── RelayComponentWithDeferAppQuery.graphql.ts
│ │ │ ├── RelayComponent.tsx
│ │ │ └── relay-environment.ts
│ │ └── apollo-client
│ │ │ ├── EcommerceExample.tsx
│ │ │ └── WNBAExample.tsx
│ ├── schemas
│ │ ├── wnba.graphql
│ │ └── ecommerce.graphql
│ ├── input.css
│ ├── Relay.stories.tsx
│ ├── ApolloClient.mdx
│ ├── Relay.mdx
│ ├── ApolloClient.stories.tsx
│ └── Welcome.mdx
├── preview.ts
├── main.ts
├── tailwind.config.js
└── public
│ └── mockServiceWorker.js
├── .prettierignore
├── src
├── __tests__
│ ├── mocks
│ │ ├── svg.ts
│ │ ├── server.ts
│ │ └── handlers.ts
│ └── handlers.test.tsx
├── index.ts
├── utilities.ts
├── requestHandler.ts
└── handlers.ts
├── netlify.toml
├── global.d.ts
├── graphql.d.ts
├── tsup.config.ts
├── setupTests.ts
├── demo
└── server
│ ├── tsconfig.json
│ ├── package.json
│ └── src
│ └── index.ts
├── .changeset
├── config.json
└── README.md
├── .gitignore
├── vitest.config.ts
├── codegen.ts
├── renovate.json
├── tsconfig.json
├── .github
└── workflows
│ ├── deploy-storybook.yml
│ ├── release.yml
│ ├── snapshot-release.yml
│ └── test.yml
├── LICENSE
├── .eslintrc.cjs
├── jest.polyfills.js
├── jest.config.cjs
├── CHANGELOG.md
├── scripts
└── prepareDist.cjs
├── README.md
└── package.json
/.prettierrc:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/docs/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "astro/tsconfigs/strict"
3 | }
4 |
--------------------------------------------------------------------------------
/.storybook/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | pnpm-lock.yaml
2 | .storybook/public/mockServiceWorker.js
3 | __generated__
--------------------------------------------------------------------------------
/src/__tests__/mocks/svg.ts:
--------------------------------------------------------------------------------
1 | export default "div";
2 | export const ReactComponent = "div";
3 |
--------------------------------------------------------------------------------
/netlify.toml:
--------------------------------------------------------------------------------
1 | [build]
2 | base = "docs"
3 | publish = "dist"
4 | command = "pnpm i && pnpm run build"
--------------------------------------------------------------------------------
/docs/src/env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/docs/src/content/docs/creating-a-handler.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Creating a handler
3 | # description: A guide in my new Starlight docs site.
4 | ---
5 |
--------------------------------------------------------------------------------
/global.d.ts:
--------------------------------------------------------------------------------
1 | declare module "@bundled-es-modules/statuses" {
2 | import * as statuses from "statuses";
3 | export default statuses;
4 | }
5 |
--------------------------------------------------------------------------------
/graphql.d.ts:
--------------------------------------------------------------------------------
1 | declare module "*.graphql" {
2 | import type { DocumentNode } from "graphql";
3 |
4 | const value: DocumentNode;
5 | export = value;
6 | }
7 |
--------------------------------------------------------------------------------
/docs/src/content/config.ts:
--------------------------------------------------------------------------------
1 | import { defineCollection } from "astro:content";
2 | import { docsSchema } from "@astrojs/starlight/schema";
3 |
4 | export const collections = {
5 | docs: defineCollection({ schema: docsSchema() }),
6 | };
7 |
--------------------------------------------------------------------------------
/src/__tests__/mocks/server.ts:
--------------------------------------------------------------------------------
1 | import { setupServer } from "msw/node";
2 | import { handlers } from "./handlers.js";
3 |
4 | // This configures a request mocking server with the given request handlers.
5 | export const server = setupServer(...handlers);
6 |
--------------------------------------------------------------------------------
/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "tsup";
2 |
3 | export default defineConfig({
4 | entry: ["src/index.ts"],
5 | format: ["cjs", "esm"],
6 | dts: true,
7 | splitting: false,
8 | sourcemap: true,
9 | clean: true,
10 | });
11 |
--------------------------------------------------------------------------------
/.storybook/preview-head.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
--------------------------------------------------------------------------------
/docs/.gitignore:
--------------------------------------------------------------------------------
1 | # build output
2 | dist/
3 | # generated types
4 | .astro/
5 |
6 | # dependencies
7 | node_modules/
8 |
9 | # logs
10 | npm-debug.log*
11 | yarn-debug.log*
12 | yarn-error.log*
13 | pnpm-debug.log*
14 |
15 |
16 | # environment variables
17 | .env
18 | .env.production
19 |
20 | # macOS-specific files
21 | .DS_Store
22 |
--------------------------------------------------------------------------------
/setupTests.ts:
--------------------------------------------------------------------------------
1 | import "@testing-library/jest-dom";
2 | import { gql } from "@apollo/client";
3 | import { server } from "./src/__tests__/mocks/server.js";
4 |
5 | gql.disableFragmentWarnings();
6 |
7 | beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
8 | afterAll(() => server.close());
9 | afterEach(() => server.resetHandlers());
10 |
--------------------------------------------------------------------------------
/demo/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "rootDirs": ["src"],
4 | "outDir": "dist",
5 | "lib": ["es2020"],
6 | "target": "es2020",
7 | "module": "esnext",
8 | "moduleResolution": "node",
9 | "esModuleInterop": true,
10 | "types": ["node"],
11 | "skipLibCheck": true
12 | },
13 | "include": ["../../graphql.d.ts"]
14 | }
15 |
--------------------------------------------------------------------------------
/.changeset/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/@changesets/config@2.2.0/schema.json",
3 | "changelog": [
4 | "@changesets/changelog-github",
5 | { "repo": "apollographql/graphql-testing-library" }
6 | ],
7 | "commit": false,
8 | "fixed": [],
9 | "linked": [],
10 | "access": "public",
11 | "baseBranch": "main",
12 | "updateInternalDependencies": "patch",
13 | "ignore": []
14 | }
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .yarn/*
2 | !.yarn/patches
3 | !.yarn/plugins
4 | !.yarn/releases
5 | !.yarn/sdks
6 | !.yarn/versions
7 | node_modules/
8 | tsconfig.tsbuildinfo
9 | dist/
10 | .yalc
11 | yalc.lock
12 | *.tgz
13 | .DS_Store
14 | .vscode/
15 | .vercel
16 | .next/
17 | test-results/
18 | temp/
19 | *storybook.log
20 |
21 | # output of storybook-build
22 | storybook-static
23 |
24 | # jest coverage
25 | coverage
26 | coverage.txt
27 |
28 | # generated types
29 | .astro
--------------------------------------------------------------------------------
/docs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "docs",
3 | "type": "module",
4 | "version": "0.0.1",
5 | "scripts": {
6 | "dev": "astro dev",
7 | "start": "astro dev",
8 | "build": "astro check && astro build",
9 | "preview": "astro preview",
10 | "astro": "astro"
11 | },
12 | "dependencies": {
13 | "@apollo/tailwind-preset": "0.2.0",
14 | "@astrojs/check": "0.9.3",
15 | "@astrojs/starlight": "0.28.2",
16 | "astro": "4.15.9",
17 | "sharp": "0.33.5",
18 | "typescript": "5.5.4"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createHandler,
3 | createHandlerFromSchema,
4 | createSchemaWithDefaultMocks,
5 | } from "./handlers.js";
6 | import {
7 | createDefaultResolvers,
8 | createPossibleTypesMap,
9 | generateEnumMocksFromSchema,
10 | mockCustomScalars,
11 | } from "./utilities.ts";
12 |
13 | export {
14 | createHandler,
15 | createHandlerFromSchema,
16 | generateEnumMocksFromSchema,
17 | mockCustomScalars,
18 | createDefaultResolvers,
19 | createPossibleTypesMap,
20 | createSchemaWithDefaultMocks,
21 | };
22 |
--------------------------------------------------------------------------------
/.changeset/README.md:
--------------------------------------------------------------------------------
1 | # Changesets
2 |
3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
4 | with multi-package repos, or single-package repos to help you version and publish your code. You can
5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets)
6 |
7 | We have a quick list of common questions to get you started engaging with this project in
8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
9 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 | import { defineConfig } from "vite";
3 | import { vitePluginGraphqlLoader } from "vite-plugin-graphql-loader";
4 | import svgr from "vite-plugin-svgr";
5 |
6 | export default defineConfig({
7 | plugins: [vitePluginGraphqlLoader(), svgr()],
8 | test: {
9 | include: ["**/*.test.tsx"],
10 | globals: true,
11 | environment: "jsdom",
12 | setupFiles: ["./setupTests.ts"],
13 | server: {
14 | deps: {
15 | fallbackCJS: true,
16 | },
17 | },
18 | },
19 | });
20 |
--------------------------------------------------------------------------------
/codegen.ts:
--------------------------------------------------------------------------------
1 | import type { CodegenConfig } from "@graphql-codegen/cli";
2 |
3 | const config: CodegenConfig = {
4 | hooks: {
5 | afterAllFileWrite: ["prettier --write"],
6 | },
7 | generates: {
8 | "./src/__generated__/resolvers-types-ecommerce.ts": {
9 | schema: "./.storybook/stories/schemas/ecommerce.graphql",
10 | plugins: ["typescript", "typescript-resolvers"],
11 | },
12 | "./src/__generated__/resolvers-types-github.ts": {
13 | schema: "./.storybook/stories/schemas/github.graphql",
14 | plugins: ["typescript", "typescript-resolvers"],
15 | },
16 | },
17 | };
18 |
19 | export default config;
20 |
--------------------------------------------------------------------------------
/.storybook/stories/components/Container.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from "react";
2 |
3 | function Container({ children }: { children: ReactNode }) {
4 | return (
5 |
6 |
7 |
8 | Customers also purchased
9 |
10 |
11 |
12 | {children}
13 |
14 |
15 |
16 | );
17 | }
18 |
19 | export { Container };
20 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["apollo-open-source"],
3 | "packageRules": [
4 | {
5 | "groupName": "all @types",
6 | "groupSlug": "all-types",
7 | "matchPackagePatterns": ["@types/*"]
8 | },
9 | {
10 | "groupName": "all devDependencies",
11 | "groupSlug": "all-dev",
12 | "matchPackagePatterns": ["*"],
13 | "matchDepTypes": ["devDependencies"]
14 | },
15 | {
16 | "groupName": "all dependencies - patch updates",
17 | "groupSlug": "all-patch",
18 | "matchPackagePatterns": ["*"],
19 | "matchUpdateTypes": ["patch"]
20 | }
21 | ],
22 | "ignoreDeps": ["eslint"]
23 | }
24 |
--------------------------------------------------------------------------------
/docs/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.storybook/stories/schemas/wnba.graphql:
--------------------------------------------------------------------------------
1 | type Query {
2 | teams: [Team!]!
3 | team(id: ID): Team!
4 | coaches: [Coach!]!
5 | favoriteTeam: ID!
6 | }
7 |
8 | type Mutation {
9 | setCurrentTeam(team: ID!): Team!
10 | }
11 |
12 | type Team {
13 | id: ID!
14 | name: String!
15 | wins: Int!
16 | losses: Int!
17 | coach: Coach!
18 | }
19 |
20 | type Coach {
21 | id: ID!
22 | team: ID!
23 | name: String!
24 | }
25 |
26 | type Player {
27 | id: ID!
28 | name: String!
29 | position: String!
30 | }
31 |
32 | type Game {
33 | home: Team!
34 | away: Team!
35 | id: ID!
36 | }
37 |
38 | type Subscription {
39 | numberIncremented: Int
40 | score(gameId: ID!): Score
41 | }
42 |
43 | type Score {
44 | home: Int!
45 | away: Int!
46 | }
47 |
--------------------------------------------------------------------------------
/.storybook/preview.ts:
--------------------------------------------------------------------------------
1 | import type { Preview } from "@storybook/react";
2 | import { initialize, mswLoader, getWorker } from "msw-storybook-addon";
3 | import "./stories/input.css";
4 |
5 | let options = {};
6 |
7 | if (location.hostname === "apollographql.github.io") {
8 | options = {
9 | serviceWorker: {
10 | url: "/graphql-testing-library/mockServiceWorker.js",
11 | },
12 | };
13 | }
14 |
15 | // Initialize MSW
16 | initialize(options);
17 |
18 | const preview: Preview = {
19 | // calling getWorker().start() is a workaround for an issue
20 | // where Storybook doesn't wait for MSW before running:
21 | // https://github.com/mswjs/msw-storybook-addon/issues/89
22 | loaders: [mswLoader, () => getWorker().start(options)],
23 | };
24 |
25 | export default preview;
26 |
--------------------------------------------------------------------------------
/.storybook/stories/input.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | .sb-show-main.sb-main-centered #storybook-root {
6 | padding: 0;
7 | }
8 |
9 | .wnba * {
10 | font-family: "VT323", monaco, Consolas, "Lucida Console", monospace;
11 | }
12 |
13 | .marquee {
14 | width: 100vw;
15 | left: 0;
16 | top: 0;
17 | position: absolute;
18 | line-height: 35px;
19 | background-color: red;
20 | color: white;
21 | white-space: nowrap;
22 | overflow: hidden;
23 | box-sizing: border-box;
24 | }
25 | .marquee p {
26 | display: inline-block;
27 | padding-left: 100%;
28 | font-size: 1.5em;
29 | animation: marquee 20s linear infinite;
30 | }
31 | @keyframes marquee {
32 | 0% {
33 | transform: translate(0, 0);
34 | }
35 | 100% {
36 | transform: translate(-100%, 0);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/demo/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "wnba-demo-server",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "dist/index.js",
6 | "type": "module",
7 | "scripts": {
8 | "postinstall": "npm run compile",
9 | "compile": "tsc",
10 | "start": "pnpm run compile && node ./dist/index.js"
11 | },
12 | "license": "MIT",
13 | "dependencies": {
14 | "@apollo/server": "^4.0.0",
15 | "@graphql-tools/schema": "^9.0.13",
16 | "body-parser": "^1.20.2",
17 | "cors": "^2.8.5",
18 | "express": "^4.17.1",
19 | "graphql": "^16.6.0",
20 | "graphql-subscriptions": "^1.2.1",
21 | "graphql-ws": "^5.5.5",
22 | "typescript": "^4.7.4",
23 | "ws": "^8.4.2"
24 | },
25 | "devDependencies": {
26 | "@types/cors": "2.8.17",
27 | "@types/node": "18.19.54",
28 | "@types/ws": "8.5.12"
29 | },
30 | "keywords": []
31 | }
32 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@tsconfig/recommended/tsconfig.json",
3 | "compilerOptions": {
4 | "esModuleInterop": true,
5 | "skipLibCheck": true,
6 | "target": "es2022",
7 | "allowJs": true,
8 | "resolveJsonModule": true,
9 | "moduleDetection": "force",
10 | "isolatedModules": true,
11 | "verbatimModuleSyntax": true,
12 | "strict": true,
13 | "noUncheckedIndexedAccess": true,
14 | "noImplicitOverride": true,
15 | "module": "NodeNext",
16 | "outDir": "dist",
17 | "sourceMap": true,
18 | "declaration": true,
19 | "declarationMap": true,
20 | "noEmit": true,
21 | "allowImportingTsExtensions": true,
22 | "jsx": "react-jsx",
23 | "lib": ["es2022", "dom", "dom.iterable"],
24 | "types": ["@testing-library/jest-dom/jest-globals"]
25 | },
26 | "include": [
27 | "src",
28 | "global.d.ts",
29 | "graphql.d.ts",
30 | "setupTests.ts",
31 | ".storybook/vite-env.d.ts"
32 | ]
33 | }
34 |
--------------------------------------------------------------------------------
/.storybook/main.ts:
--------------------------------------------------------------------------------
1 | import type { StorybookConfig } from "@storybook/react-vite";
2 | import relay from "vite-plugin-relay";
3 | import graphqlLoader from "vite-plugin-graphql-loader";
4 | import svgr from "vite-plugin-svgr";
5 |
6 | const config: StorybookConfig = {
7 | stories: ["./**/*.mdx", "./**/*.stories.@(js|jsx|mjs|ts|tsx)"],
8 | staticDirs: ["./public"],
9 | addons: [
10 | "@storybook/addon-links",
11 | "@storybook/addon-essentials",
12 | "@storybook/addon-interactions",
13 | "@storybook/addon-docs",
14 | ],
15 | framework: {
16 | name: "@storybook/react-vite",
17 | options: {},
18 | },
19 | async viteFinal(config, options) {
20 | config.plugins?.push(relay, graphqlLoader(), svgr());
21 | config.css = {
22 | postcss: {
23 | plugins: [
24 | require("tailwindcss")({
25 | config: ".storybook/tailwind.config.js",
26 | }),
27 | ],
28 | },
29 | };
30 | return config;
31 | },
32 | };
33 |
34 | export default config;
35 |
--------------------------------------------------------------------------------
/src/__tests__/mocks/handlers.ts:
--------------------------------------------------------------------------------
1 | import { createHandler } from "../../handlers.js";
2 | import ecommerceSchema from "../../../.storybook/stories/schemas/ecommerce.graphql";
3 | import type { Resolvers } from "../../__generated__/resolvers-types-ecommerce.ts";
4 |
5 | const products = ["beanie", "bottle", "cap", "onesie", "shirt", "socks"];
6 |
7 | const ecommerceHandler = createHandler({
8 | typeDefs: ecommerceSchema,
9 | resolvers: {
10 | Query: {
11 | products: () =>
12 | Array.from({ length: products.length }, (_element, id) => ({
13 | id: `${id}`,
14 | title: products[id],
15 | mediaUrl: `https://storage.googleapis.com/hack-the-supergraph/apollo-${products[id]}.jpg`,
16 | reviews: [
17 | {
18 | id: `review-${id}`,
19 | rating: id * 2,
20 | },
21 | ],
22 | })),
23 | },
24 | },
25 | });
26 |
27 | const handlers = [ecommerceHandler];
28 |
29 | export { handlers, products, ecommerceHandler };
30 |
--------------------------------------------------------------------------------
/.github/workflows/deploy-storybook.yml:
--------------------------------------------------------------------------------
1 | name: Deploy Storybook
2 |
3 | on:
4 | push:
5 | branches:
6 | - "main"
7 |
8 | permissions:
9 | contents: read
10 | pages: write
11 | id-token: write
12 |
13 | jobs:
14 | deploy:
15 | runs-on: ubuntu-latest
16 | steps:
17 | - name: Checkout
18 | uses: actions/checkout@v4
19 |
20 | - uses: pnpm/action-setup@v4
21 | name: Install pnpm
22 | with:
23 | version: 9
24 | run_install: false
25 |
26 | - name: Install Node.js
27 | uses: actions/setup-node@v4
28 | with:
29 | node-version: 20
30 | cache: "pnpm"
31 |
32 | - name: Install dependencies
33 | run: pnpm install
34 |
35 | - name: Build and publish
36 | id: build-publish
37 | uses: bitovi/github-actions-storybook-to-github-pages@v1.0.3
38 | with:
39 | path: storybook-static
40 | build_command: pnpm run build-storybook
41 | install_command: pnpm i
42 | checkout: false
43 |
--------------------------------------------------------------------------------
/docs/astro.config.mjs:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "astro/config";
2 | import starlight from "@astrojs/starlight";
3 |
4 | // https://astro.build/config
5 | export default defineConfig({
6 | integrations: [
7 | starlight({
8 | title: "GraphQL Testing Library",
9 | social: {
10 | github: "https://github.com/apollographql/graphql-testing-library"
11 | },
12 | sidebar: [
13 | {
14 | label: "Getting started",
15 | items: [
16 | { label: "Installation", slug: "installation" },
17 | { label: "In Node.js", slug: "integrations/node" },
18 | { label: "In the browser", slug: "integrations/browser" },
19 | ],
20 | },
21 | {
22 | label: "Guides",
23 | items: [{ label: "Creating a handler", slug: "creating-a-handler" }],
24 | },
25 | {
26 | label: "Storybook Examples",
27 | link: "https://apollographql.github.io/graphql-testing-library",
28 | },
29 | ],
30 | }),
31 | ],
32 | });
33 |
--------------------------------------------------------------------------------
/docs/src/content/docs/integrations/browser.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Usage in the browser
3 | # description: A guide in my new Starlight docs site.
4 | ---
5 |
6 | import { Tabs, TabItem } from "@astrojs/starlight/components";
7 |
8 | ## With Storybook
9 |
10 | [Mock Service Worker](https://mswjs.io/) and [GraphQL Testing Library](https://github.com/apollographql/graphql-testing-library) pair nicely with [Storybook](https://storybook.js.org/) when developing in the browser.
11 |
12 | To get started, install the [Mock Service Worker Storybook addon](https://storybook.js.org/addons/msw-storybook-addon).
13 |
14 |
15 |
16 |
17 | ```sh
18 | npm install --save-dev msw-storybook-addon
19 | ```
20 |
21 |
22 |
23 |
24 | ```sh
25 | pnpm add --save-dev msw-storybook-addon
26 | ```
27 |
28 |
29 |
30 |
31 | ```sh
32 | yarn add --dev msw-storybook-addon
33 | ```
34 |
35 |
36 |
37 |
38 | ```sh
39 | bun add --dev msw-storybook-addon
40 | ```
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Apollo GraphQL
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.storybook/stories/components/TeamLogo.tsx:
--------------------------------------------------------------------------------
1 | import Atlanta from "./logos/AtlantaDream.svg?react";
2 | import Chicago from "./logos/ChicagoSky.svg?react";
3 | import Connecticut from "./logos/ConnecticutSun.svg?react";
4 | import Dallas from "./logos/DallasWings.svg?react";
5 | import Indiana from "./logos/IndianaFever.svg?react";
6 | import LasVegas from "./logos/LasVegasAces.svg?react";
7 | import LosAngeles from "./logos/LosAngelesSparks.svg?react";
8 | import Minnesota from "./logos/MinnesotaLynx.svg?react";
9 | import NewYork from "./logos/NewYorkLiberty.svg?react";
10 | import Phoenix from "./logos/PhoenixMercury.svg?react";
11 | import Seattle from "./logos/SeattleStorm.svg?react";
12 | import Washington from "./logos/WashingtonMystics.svg?react";
13 |
14 | // indexed by team ID
15 | const logos = [
16 | ,
17 | NewYork,
18 | LasVegas,
19 | LosAngeles,
20 | Atlanta,
21 | Chicago,
22 | Connecticut,
23 | Indiana,
24 | Washington,
25 | Dallas,
26 | Minnesota,
27 | Phoenix,
28 | Seattle,
29 | ];
30 |
31 | export function TeamLogo({ team }: { team: string }) {
32 | const Logo = logos[parseInt(team)] || (() => "Error: no logo found");
33 | return ;
34 | }
35 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | /** @type {import("@types/eslint").Linter.Config} */
2 | // eslint-disable-next-line no-undef
3 | module.exports = {
4 | env: {
5 | browser: true,
6 | node: true,
7 | es2021: true,
8 | },
9 | extends: [
10 | "eslint:recommended",
11 | "plugin:@typescript-eslint/recommended",
12 | "plugin:storybook/recommended",
13 | ],
14 | overrides: [],
15 | parser: "@typescript-eslint/parser",
16 | parserOptions: {
17 | ecmaVersion: "latest",
18 | sourceType: "module",
19 | },
20 | plugins: ["@typescript-eslint"],
21 | ignorePatterns: ["**/__generated__/*.ts"],
22 | rules: {
23 | "@typescript-eslint/no-explicit-any": "off",
24 | "@typescript-eslint/no-unused-vars": [
25 | "warn",
26 | {
27 | args: "all",
28 | argsIgnorePattern: "^_",
29 | caughtErrors: "all",
30 | caughtErrorsIgnorePattern: "^_",
31 | destructuredArrayIgnorePattern: "^_",
32 | varsIgnorePattern: "^_",
33 | ignoreRestSiblings: true,
34 | },
35 | ],
36 | "@typescript-eslint/consistent-type-imports": [
37 | "error",
38 | {
39 | prefer: "type-imports",
40 | disallowTypeAnnotations: false,
41 | fixStyle: "separate-type-imports",
42 | },
43 | ],
44 | },
45 | };
46 |
--------------------------------------------------------------------------------
/.storybook/stories/Relay.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from "@storybook/react";
2 | import { within, expect, waitFor } from "@storybook/test";
3 | import {
4 | RelayApp,
5 | RelayAppWithDefer as AppWithDefer,
6 | } from "./components/relay/RelayComponent.js";
7 | import { ecommerceHandler } from "../../src/__tests__/mocks/handlers.js";
8 |
9 | export default {
10 | title: "Example/Relay",
11 | component: RelayApp,
12 | parameters: {
13 | layout: "centered",
14 | msw: {
15 | handlers: {
16 | graphql: ecommerceHandler,
17 | },
18 | },
19 | },
20 | } satisfies Meta;
21 |
22 | export const EcommerceApp: StoryObj = {
23 | play: async ({ canvasElement }) => {
24 | const canvas = within(canvasElement);
25 | await expect(
26 | canvas.getByRole("heading", { name: /loading/i }),
27 | ).toHaveTextContent("Loading...");
28 | await waitFor(
29 | () =>
30 | expect(
31 | canvas.getByRole("heading", { name: /customers/i }),
32 | ).toHaveTextContent("Customers also purchased"),
33 | { timeout: 2000 },
34 | );
35 | await waitFor(
36 | () => expect(canvas.getByText(/beanie/i)).toBeInTheDocument(),
37 | { timeout: 2000 },
38 | );
39 | },
40 | };
41 |
42 | export const EcommerceAppWithDefer = () => ;
43 |
--------------------------------------------------------------------------------
/docs/src/content/docs/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: GraphQL Testing Library
3 | description: Generate Mock Service Worker handlers for your GraphQL APIs.
4 | template: splash
5 | hero:
6 | tagline: Generate Mock Service Worker handlers for your GraphQL APIs.
7 | image:
8 | file: ../../assets/GraphQLxMSW.svg
9 | actions:
10 | - text: Read the docs
11 | link: /installation
12 | icon: right-arrow
13 | variant: primary
14 | - text: View repository
15 | link: https://github.com/apollographql/graphql-testing-library
16 | icon: external
17 | ---
18 |
19 | import { Card, CardGrid } from "@astrojs/starlight/components";
20 |
21 |
22 |
23 | GraphQL Testing Library can be used to test apps built with any GraphQL
24 | client or front-end stack.
25 |
26 |
27 | Supports experimental incremental delivery features `@defer` and `@stream`
28 | out of the box.
29 |
30 |
31 | Goodbye hand-written response mocks, hello mock resolvers.
32 |
33 |
34 | Learn more about Mock Service Worker by exploring [the MSW
35 | docs](https://mswjs.io/).
36 |
37 |
38 |
--------------------------------------------------------------------------------
/jest.polyfills.js:
--------------------------------------------------------------------------------
1 | // jest.polyfills.js
2 | /**
3 | * @note The block below contains polyfills for Node.js globals
4 | * required for Jest to function when running JSDOM tests.
5 | * These HAVE to be require's and HAVE to be in this exact
6 | * order, since "undici" depends on the "TextEncoder" global API.
7 | *
8 | * Consider migrating to a more modern test runner if
9 | * you don't want to deal with this.
10 | */
11 |
12 | const { TextDecoder, TextEncoder } = require("node:util");
13 | const { ReadableStream } = require("node:stream/web");
14 |
15 | Object.defineProperties(globalThis, {
16 | TextDecoder: { value: TextDecoder },
17 | TextEncoder: { value: TextEncoder },
18 | ReadableStream: { value: ReadableStream },
19 | });
20 |
21 | const { Blob, File } = require("node:buffer");
22 | const { fetch, Headers, FormData, Request, Response } = require("undici");
23 |
24 | Object.defineProperties(globalThis, {
25 | fetch: { value: fetch, writable: true },
26 | Blob: { value: Blob },
27 | File: { value: File },
28 | Headers: { value: Headers },
29 | FormData: { value: FormData },
30 | Request: { value: Request },
31 | Response: { value: Response },
32 | });
33 |
34 | // Symbol.dispose is not defined
35 | // jest bug: https://github.com/jestjs/jest/issues/14874
36 | // fix is available in https://github.com/jestjs/jest/releases/tag/v30.0.0-alpha.3
37 | if (!Symbol.dispose) {
38 | Object.defineProperty(Symbol, "dispose", {
39 | value: Symbol("dispose"),
40 | });
41 | }
42 | if (!Symbol.asyncDispose) {
43 | Object.defineProperty(Symbol, "asyncDispose", {
44 | value: Symbol("asyncDispose"),
45 | });
46 | }
47 |
--------------------------------------------------------------------------------
/.storybook/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const { join } = require("node:path");
2 | const { createThemes } = require("tw-colors");
3 | const defaultConfig = require("tailwindcss/defaultConfig");
4 |
5 | /** @type {import('tailwindcss').Config} */
6 | module.exports = {
7 | content: [join(__dirname, "./**/*.{js,ts,jsx,tsx}")],
8 | presets: [defaultConfig],
9 | plugins: [
10 | require("@tailwindcss/aspect-ratio"),
11 | createThemes({
12 | liberty: {
13 | primary: "#86CEBC",
14 | secondary: "#FFFFFF",
15 | },
16 | aces: {
17 | primary: "#A7A8AA",
18 | secondary: "#FFFFFF",
19 | },
20 | sparks: {
21 | primary: "#552583",
22 | secondary: "#FDB927",
23 | },
24 | dream: {
25 | primary: "#E31837",
26 | secondary: "#FFFFFF",
27 | },
28 | sky: {
29 | primary: "#5091CD",
30 | secondary: "#FFD520",
31 | },
32 | sun: {
33 | primary: "#0A2240",
34 | secondary: "#F05023",
35 | },
36 | fever: {
37 | primary: "#E03A3E",
38 | secondary: "#FFD520",
39 | },
40 | mystics: {
41 | primary: "#002B5C",
42 | secondary: "#E03A3E",
43 | },
44 | wings: {
45 | primary: "#2456A5",
46 | secondary: "#C4D600",
47 | },
48 | lynx: {
49 | primary: "#266092",
50 | secondary: "#79BC43",
51 | },
52 | mercury: {
53 | primary: "#1D1160",
54 | secondary: "#E56020",
55 | },
56 | storm: {
57 | primary: "#2C5235",
58 | secondary: "#FEE11A",
59 | },
60 | }),
61 | ],
62 | };
63 |
--------------------------------------------------------------------------------
/.storybook/stories/components/Product.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from "react";
2 |
3 | type Product = {
4 | id: string;
5 | mediaUrl: string | null | undefined;
6 | title: string | null | undefined;
7 | };
8 |
9 | function Product({
10 | children,
11 | product,
12 | }: {
13 | children: ReactNode;
14 | product: Product;
15 | }) {
16 | return (
17 |
18 |
19 |

23 |
24 |
25 |
33 |
34 | {children}
35 |
36 |
37 |
38 | );
39 | }
40 |
41 | type ReviewType = { rating: number; id: string };
42 |
43 | function Reviews({ reviews }: { reviews: Array }) {
44 | return reviews?.length > 0
45 | ? `${Math.round(
46 | reviews
47 | ?.map((i) => i.rating)
48 | .reduce((curr, acc) => {
49 | return curr + acc;
50 | }) / reviews.length,
51 | )}/5`
52 | : "-";
53 | }
54 |
55 | export { Product, Reviews };
56 |
--------------------------------------------------------------------------------
/.storybook/stories/components/logos/AtlantaDream.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/src/content/docs/installation.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Getting started
3 | # description: A guide in my new Starlight docs site.
4 | ---
5 |
6 | import { Tabs, TabItem, Aside } from "@astrojs/starlight/components";
7 |
8 | GraphQL Testing Library provides utilities for creating [Mock Service Worker](https://mswjs.io/) (MSW) [request handlers](https://mswjs.io/docs/concepts/request-handler/) for [GraphQL](https://graphql.org/) APIs.
9 |
10 | This guide will help you get started with MSW, create a request handler using a GraphQL schema and begin writing tests that can run in Node.js, the browser or React Native.
11 |
12 |
17 |
18 | ## Installation
19 |
20 | GraphQL Testing Library has `peerDependencies` listings for `msw` at `^2.0.0` and `graphql` at `^15.0.0 || ^16.0.0`.
21 |
22 | Install them along with this library using your preferred package manager:
23 |
24 |
25 |
26 |
27 | ```sh
28 | npm install --save-dev @apollo/graphql-testing-library msw graphql
29 | ```
30 |
31 |
32 |
33 |
34 | ```sh
35 | pnpm add --save-dev @apollo/graphql-testing-library msw graphql
36 | ```
37 |
38 |
39 |
40 |
41 | ```sh
42 | yarn add --dev @apollo/graphql-testing-library msw graphql
43 | ```
44 |
45 |
46 |
47 |
48 | ```sh
49 | bun add --dev @apollo/graphql-testing-library msw graphql
50 | ```
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/.storybook/stories/ApolloClient.mdx:
--------------------------------------------------------------------------------
1 | import { Canvas, Meta, Source } from "@storybook/blocks";
2 | import * as ApolloStories from "./ApolloClient.stories.tsx";
3 |
4 |
5 |
6 | # Apollo Client Demo
7 |
8 | The `Apollo/App` and `Apollo/AppWithDefer` stories provide two examples of a MSW handler generated by this library resolving a request originating from an Apollo Client app.
9 |
10 | ## `App` query
11 |
12 | In `App`, a single JSON response is generated using the mock resolver found in [`src/__tests__/mocks/handlers.ts`](https://github.com/apollographql/graphql-testing-library/blob/main/src/__tests__/mocks/handlers.ts).
13 |
14 |
30 |
31 | ## `AppWithDefer` query
32 |
33 | In `AppWithDefer`, the same mock resolver is used to generate the response, but the presence of `@defer` prompts the generated MSW handler to reply with a multipart response using the proposed [incremental delivery over HTTP](https://github.com/graphql/graphql-over-http/blob/main/rfcs/IncrementalDelivery.md) specification. While the inline fragment is pending, the `Reviews` component displays a `-` in place of the missing data.
34 |
35 | View the response in the browser devtools network tab to inspect the multipart response after the final chunk has arrived.
36 |
37 |
55 |
--------------------------------------------------------------------------------
/.storybook/stories/Relay.mdx:
--------------------------------------------------------------------------------
1 | import { Canvas, Meta, Source } from "@storybook/blocks";
2 | import * as RelayStories from "./Relay.stories.tsx";
3 |
4 |
5 |
6 | # Relay Demo
7 |
8 | The `Relay/App` and `Relay/AppWithDefer` stories provide two examples of a MSW handler generated by this library resolving a request originating from a Relay app.
9 |
10 | ## `App` query
11 |
12 | In `App`, a single JSON response is generated using the mock resolver found in [`src/__tests__/mocks/handlers.ts`](https://github.com/apollographql/graphql-testing-library/blob/main/src/__tests__/mocks/handlers.ts).
13 |
14 |
28 |
29 | ## `AppWithDefer` query
30 |
31 | In `AppWithDefer`, the same mock resolver is used to generate the response, but the presence of `@defer` prompts the generated MSW handler to reply with a multipart response using the proposed [incremental delivery over HTTP](https://github.com/graphql/graphql-over-http/blob/main/rfcs/IncrementalDelivery.md) specification. While the `RelayComponentReviewsFragment_product` fragment is suspending, the `Reviews` component displays a `-` fallback.
32 |
33 | View the response in the browser devtools network tab to inspect the multipart response after the final chunk has arrived.
34 |
35 |
49 |
--------------------------------------------------------------------------------
/jest.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | globals: {
3 | "globalThis.__DEV__": JSON.stringify(true),
4 | },
5 | moduleNameMapper: {
6 | "\\.svg": "/src/__tests__/mocks/svg.js",
7 | },
8 | extensionsToTreatAsEsm: [".ts", ".tsx"],
9 | testEnvironment: "jsdom",
10 | roots: ["/src/__tests__"],
11 | testPathIgnorePatterns: [
12 | "/src/__tests__/mocks",
13 | "/.storybook",
14 | "/dist",
15 | ],
16 | setupFiles: ["./jest.polyfills.js"],
17 | setupFilesAfterEnv: ["/setupTests.ts"],
18 | preset: "ts-jest/presets/default-esm",
19 | // Opt out of the browser export condition for MSW tests
20 | // for more information, see: https://github.com/mswjs/msw/issues/1786#issuecomment-1782559851
21 | testEnvironmentOptions: {
22 | customExportConditions: [""],
23 | },
24 | collectCoverageFrom: [
25 | "src/**/*.ts",
26 | "!src/__tests__/**/*.ts",
27 | "!src/requestHandler.ts",
28 | ],
29 | coverageReporters: ["html", "json-summary", "text", "text-summary"],
30 | reporters: ["default", ["jest-junit", { outputDirectory: "coverage" }]],
31 | transform: {
32 | "\\.(gql|graphql)$": "@graphql-tools/jest-transform",
33 | "^.+\\.tsx?$": [
34 | "ts-jest",
35 | {
36 | useESM: true,
37 | // Note: We shouldn't need to include `isolatedModules` here because
38 | // it's a deprecated config option in TS 5, but setting it to `true`
39 | // fixes the `ESM syntax is not allowed in a CommonJS module when
40 | // 'verbatimModuleSyntax' is enabled` error that we're seeing when
41 | // running our Jest tests.
42 | // See https://github.com/kulshekhar/ts-jest/issues/4081
43 | isolatedModules: true,
44 | diagnostics: {
45 | warnOnly: process.env.TEST_ENV !== "ci",
46 | },
47 | },
48 | ],
49 | },
50 | resolver: "ts-jest-resolver",
51 | };
52 |
--------------------------------------------------------------------------------
/.storybook/stories/components/relay/__generated__/RelayComponentReviewsFragment_product.graphql.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @generated SignedSource<<622b20ed5026c732388dc5f7e1487d6d>>
3 | * @lightSyntaxTransform
4 | * @nogrep
5 | */
6 |
7 | /* tslint:disable */
8 | /* eslint-disable */
9 | // @ts-nocheck
10 |
11 | import { Fragment, ReaderFragment } from 'relay-runtime';
12 | import { FragmentRefs } from "relay-runtime";
13 | export type RelayComponentReviewsFragment_product$data = {
14 | readonly reviews: ReadonlyArray<{
15 | readonly id: string;
16 | readonly rating: number | null | undefined;
17 | } | null | undefined> | null | undefined;
18 | readonly " $fragmentType": "RelayComponentReviewsFragment_product";
19 | };
20 | export type RelayComponentReviewsFragment_product$key = {
21 | readonly " $data"?: RelayComponentReviewsFragment_product$data;
22 | readonly " $fragmentSpreads": FragmentRefs<"RelayComponentReviewsFragment_product">;
23 | };
24 |
25 | const node: ReaderFragment = {
26 | "argumentDefinitions": [],
27 | "kind": "Fragment",
28 | "metadata": null,
29 | "name": "RelayComponentReviewsFragment_product",
30 | "selections": [
31 | {
32 | "alias": null,
33 | "args": null,
34 | "concreteType": "Review",
35 | "kind": "LinkedField",
36 | "name": "reviews",
37 | "plural": true,
38 | "selections": [
39 | {
40 | "alias": null,
41 | "args": null,
42 | "kind": "ScalarField",
43 | "name": "id",
44 | "storageKey": null
45 | },
46 | {
47 | "alias": null,
48 | "args": null,
49 | "kind": "ScalarField",
50 | "name": "rating",
51 | "storageKey": null
52 | }
53 | ],
54 | "storageKey": null
55 | }
56 | ],
57 | "type": "Product",
58 | "abstractKey": null
59 | };
60 |
61 | (node as any).hash = "9017708b7b8d3c62f47be61c11fa4625";
62 |
63 | export default node;
64 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # @apollo/graphql-testing-library
2 |
3 | ## 0.3.0
4 |
5 | ### Minor Changes
6 |
7 | - [#83](https://github.com/apollographql/graphql-testing-library/pull/83) [`2cf1dcd`](https://github.com/apollographql/graphql-testing-library/commit/2cf1dcda275a47fbb50d0f606cb271ab83450a37) Thanks [@alessbell](https://github.com/alessbell)! - Adds helper utilities `createDefaultResolvers`, `createPossibleTypesMap`, `generateEnumMocksFromSchema` and `mockCustomScalars` for those who want to manually create and configure their mock schema.
8 |
9 | Also renames `createHandler` to `createHandlerFromSchema`; `createHandler` now provides an API that will create the mock schema under the hood with default resolvers as well as enum and custom scalar mocks.
10 |
11 | ## 0.2.3
12 |
13 | ### Patch Changes
14 |
15 | - [#60](https://github.com/apollographql/graphql-testing-library/pull/60) [`8896595`](https://github.com/apollographql/graphql-testing-library/commit/889659514d175c110d112f76062a241e50d19670) Thanks [@alessbell](https://github.com/alessbell)! - Bump default "real" delay in Node processes to 20ms.
16 |
17 | ## 0.2.2
18 |
19 | ### Patch Changes
20 |
21 | - [#57](https://github.com/apollographql/graphql-testing-library/pull/57) [`47bf677`](https://github.com/apollographql/graphql-testing-library/commit/47bf6778dc2a89ebed5cc103006210d0da555522) Thanks [@alessbell](https://github.com/alessbell)! - Adds support for MSW's delay API and infinite loading states
22 |
23 | ## 0.2.1
24 |
25 | ### Patch Changes
26 |
27 | - [#48](https://github.com/apollographql/graphql-testing-library/pull/48) [`5bd7d96`](https://github.com/apollographql/graphql-testing-library/commit/5bd7d9693f3f15306eda4a8ed80503e8b1ed0b83) Thanks [@alessbell](https://github.com/alessbell)! - Bumps @graphql-tools/executor version to v1.2.7 for GraphQL 15 backward-compatibility.
28 |
29 | ## 0.2.0
30 |
31 | ### Minor Changes
32 |
33 | - [#41](https://github.com/apollographql/graphql-testing-library/pull/41) [`19212ce`](https://github.com/apollographql/graphql-testing-library/commit/19212ce1d72b612b26061d0e987a5f5ea38e24c1) Thanks [@alessbell](https://github.com/alessbell)! - Fix bundling issue caused by an unneeded files field in package.json, and adjust relative file paths.
34 |
--------------------------------------------------------------------------------
/.storybook/stories/ApolloClient.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from "@storybook/react";
2 | import { within, expect, waitFor } from "@storybook/test";
3 | import {
4 | ApolloApp as ApolloEcommerceApp,
5 | ApolloAppWithDefer as AppWithDefer,
6 | } from "./components/apollo-client/EcommerceExample.js";
7 | import { ApolloApp as ApolloWNBAApp } from "./components/apollo-client/WNBAExample.js";
8 | import { ecommerceHandler } from "../../src/__tests__/mocks/handlers.js";
9 | import { createHandler } from "../../src/handlers.js";
10 | import wnbaTypeDefs from "../stories/schemas/wnba.graphql";
11 |
12 | const meta = {
13 | title: "Example/Apollo Client",
14 | component: ApolloEcommerceApp,
15 | parameters: {
16 | layout: "centered",
17 | msw: {
18 | handlers: {
19 | graphql: ecommerceHandler,
20 | },
21 | },
22 | },
23 | } satisfies Meta;
24 |
25 | export default meta;
26 |
27 | export const EcommerceApp: StoryObj = {
28 | play: async ({ canvasElement }) => {
29 | const canvas = within(canvasElement);
30 | await expect(
31 | canvas.getByRole("heading", { name: /loading/i }),
32 | ).toHaveTextContent("Loading...");
33 | await waitFor(
34 | () =>
35 | expect(
36 | canvas.getByRole("heading", { name: /customers/i }),
37 | ).toHaveTextContent("Customers also purchased"),
38 | { timeout: 2000 },
39 | );
40 | await waitFor(
41 | () => expect(canvas.getByText(/beanie/i)).toBeInTheDocument(),
42 | { timeout: 2000 },
43 | );
44 | },
45 | };
46 |
47 | export const EcommerceAppWithDefer = () => ;
48 |
49 | export const WNBAApp = () => ;
50 |
51 | const teams = [
52 | {
53 | id: "1",
54 | name: "New York Liberty",
55 | },
56 | {
57 | id: "2",
58 | name: "Las Vegas Aces",
59 | },
60 | ];
61 |
62 | WNBAApp.parameters = {
63 | msw: {
64 | handlers: {
65 | graphql: createHandler({
66 | typeDefs: wnbaTypeDefs,
67 | resolvers: {
68 | Mutation: {
69 | setCurrentTeam: (_p, { team }) => teams.find((t) => t.id === team),
70 | },
71 | Query: {
72 | team: () => ({
73 | id: "1",
74 | name: "New York Liberty",
75 | }),
76 | teams: () => teams,
77 | },
78 | },
79 | }),
80 | },
81 | },
82 | };
83 |
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | # Starlight Starter Kit: Basics
2 |
3 | [](https://starlight.astro.build)
4 |
5 | ```
6 | npm create astro@latest -- --template starlight
7 | ```
8 |
9 | [](https://stackblitz.com/github/withastro/starlight/tree/main/examples/basics)
10 | [](https://codesandbox.io/p/sandbox/github/withastro/starlight/tree/main/examples/basics)
11 | [](https://app.netlify.com/start/deploy?repository=https://github.com/withastro/starlight&create_from_path=examples/basics)
12 | [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fwithastro%2Fstarlight%2Ftree%2Fmain%2Fexamples%2Fbasics&project-name=my-starlight-docs&repository-name=my-starlight-docs)
13 |
14 | > 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
15 |
16 | ## 🚀 Project Structure
17 |
18 | Inside of your Astro + Starlight project, you'll see the following folders and files:
19 |
20 | ```
21 | .
22 | ├── public/
23 | ├── src/
24 | │ ├── assets/
25 | │ ├── content/
26 | │ │ ├── docs/
27 | │ │ └── config.ts
28 | │ └── env.d.ts
29 | ├── astro.config.mjs
30 | ├── package.json
31 | └── tsconfig.json
32 | ```
33 |
34 | Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name.
35 |
36 | Images can be added to `src/assets/` and embedded in Markdown with a relative link.
37 |
38 | Static assets, like favicons, can be placed in the `public/` directory.
39 |
40 | ## 🧞 Commands
41 |
42 | All commands are run from the root of the project, from a terminal:
43 |
44 | | Command | Action |
45 | | :------------------------ | :----------------------------------------------- |
46 | | `npm install` | Installs dependencies |
47 | | `npm run dev` | Starts local dev server at `localhost:4321` |
48 | | `npm run build` | Build your production site to `./dist/` |
49 | | `npm run preview` | Preview your build locally, before deploying |
50 | | `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
51 | | `npm run astro -- --help` | Get help using the Astro CLI |
52 |
53 | ## 👀 Want to learn more?
54 |
55 | Check out [Starlight’s docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat).
56 |
--------------------------------------------------------------------------------
/scripts/prepareDist.cjs:
--------------------------------------------------------------------------------
1 | // The GraphQL Testing Library source that is published to npm is located in the
2 | // "dist" directory. This script is called when building the library, to prepare
3 | // the "dist" directory for publishing.
4 | //
5 | // This script will:
6 | //
7 | // - Copy the current root package.json into "dist" after adjusting it for
8 | // publishing.
9 | // - Copy the supporting files from the root into "dist" (e.g. `README.MD`,
10 | // `LICENSE`, etc.).
11 | // - Copy the .changeset folder into "dist" so Changesets can pick up the
12 | // markdown changesets when generating the release.
13 | // - Copy CHANGELOG.md into "dist" so Changesets can use it to generate release
14 | // notes.
15 | // - Add both .changeset and CHANGELOG.md to an .npmignore so they are not
16 | // included in the published package.
17 |
18 | const fs = require("fs");
19 | const path = require("path");
20 | const distRoot = `${__dirname}/../dist`;
21 | const srcDir = `${__dirname}/..`;
22 | const destDir = `${srcDir}/dist`;
23 |
24 | /* @apollo/graphql-testing-library */
25 |
26 | const packageJson = require("../package.json");
27 |
28 | // The root package.json is marked as private to prevent publishing
29 | // from happening in the root of the project. This sets the package back to
30 | // public so it can be published from the "dist" directory.
31 | packageJson.private = false;
32 |
33 | // Remove package.json items that we don't need to publish
34 | delete packageJson.relay;
35 | delete packageJson.msw;
36 | delete packageJson.devDependencies;
37 | delete packageJson.scripts;
38 |
39 | // The root package.json points to the CJS/ESM source in "dist", to support
40 | // on-going package development (e.g. running tests, supporting npm link, etc.).
41 | // When publishing from "dist" however, we need to update the package.json
42 | // to point to the files within the same directory.
43 | const distPackageJson = JSON.stringify(packageJson, null, 2) + "\n";
44 |
45 | // Recursive copy function
46 | function copyDir(src, dest) {
47 | fs.mkdirSync(dest, { recursive: true });
48 | const entries = fs.readdirSync(src, { withFileTypes: true });
49 |
50 | for (const entry of entries) {
51 | const srcPath = path.join(src, entry.name);
52 | const destPath = path.join(dest, entry.name);
53 |
54 | entry.isDirectory()
55 | ? copyDir(srcPath, destPath)
56 | : fs.copyFileSync(srcPath, destPath);
57 | }
58 | }
59 |
60 | // Save the modified package.json to "dist"
61 | fs.writeFileSync(`${distRoot}/package.json`, distPackageJson);
62 |
63 | // Copy supporting files into "dist"
64 | fs.copyFileSync(`${srcDir}/README.md`, `${destDir}/README.md`);
65 | fs.copyFileSync(`${srcDir}/LICENSE`, `${destDir}/LICENSE`);
66 | fs.copyFileSync(`${srcDir}/CHANGELOG.md`, `${destDir}/CHANGELOG.md`);
67 |
68 | // Copy changesets to "dist"
69 | copyDir(`${srcDir}/.changeset`, `${destDir}/.changeset`);
70 |
71 | // Add .changeset and CHANGELOG.md to .npmignore in "dist"
72 | fs.writeFileSync(`${destDir}/.npmignore`, `.changeset\nCHANGELOG.md`);
73 |
--------------------------------------------------------------------------------
/.storybook/stories/components/relay/RelayComponent.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense, type ReactNode } from "react";
2 | import type { RelayComponentAppQuery } from "./__generated__/RelayComponentAppQuery.graphql.js";
3 | import {
4 | RelayEnvironmentProvider,
5 | useFragment,
6 | useLazyLoadQuery,
7 | } from "react-relay";
8 | import { graphql } from "relay-runtime";
9 | import { Container } from "../Container.js";
10 | import { Product, Reviews as ReviewsContainer } from "../Product.js";
11 | import { RelayEnvironment } from "./relay-environment.js";
12 |
13 | export function Wrapper({ children }: { children: ReactNode }) {
14 | return (
15 |
16 | Loading...}>{children}
17 |
18 | );
19 | }
20 |
21 | const ratingsFragment = graphql`
22 | fragment RelayComponentReviewsFragment_product on Product {
23 | reviews {
24 | id
25 | rating
26 | }
27 | }
28 | `;
29 |
30 | const appQuery = graphql`
31 | query RelayComponentAppQuery {
32 | products {
33 | id
34 | ...RelayComponentReviewsFragment_product
35 | title
36 | mediaUrl
37 | }
38 | }
39 | `;
40 |
41 | const appQueryWithDefer = graphql`
42 | query RelayComponentWithDeferAppQuery {
43 | products {
44 | id
45 | ...RelayComponentReviewsFragment_product @defer
46 | title
47 | mediaUrl
48 | }
49 | }
50 | `;
51 |
52 | export function RelayApp() {
53 | return (
54 |
55 |
56 |
57 | );
58 | }
59 |
60 | export function RelayAppWithDefer() {
61 | return (
62 |
63 |
64 |
65 | );
66 | }
67 |
68 | function App() {
69 | // Use useLazyLoadQuery here because we want to demo the loading experience
70 | // with/without defer.
71 | const data = useLazyLoadQuery(appQuery, {});
72 |
73 | return (
74 |
75 | {data?.products?.map((product) => (
76 |
77 |
78 |
79 |
80 |
81 | ))}
82 |
83 | );
84 | }
85 |
86 | function AppWithDefer() {
87 | // Use useLazyLoadQuery here because we want to demo the loading experience
88 | // with/without defer.
89 | const data = useLazyLoadQuery(appQueryWithDefer, {});
90 |
91 | return (
92 |
93 | {data?.products?.map((product) => (
94 |
95 |
96 |
97 |
98 |
99 | ))}
100 |
101 | );
102 | }
103 |
104 | function Reviews({ product }) {
105 | const data = useFragment(ratingsFragment, product);
106 |
107 | return ;
108 | }
109 |
--------------------------------------------------------------------------------
/.storybook/stories/schemas/ecommerce.graphql:
--------------------------------------------------------------------------------
1 | directive @defer(
2 | if: Boolean
3 | label: String
4 | ) on FRAGMENT_SPREAD | INLINE_FRAGMENT
5 |
6 | schema {
7 | query: Query
8 | }
9 |
10 | """
11 | An user's saved cart session. Only one cart can be active at a time
12 | """
13 | type Cart {
14 | """
15 | Items saved in the cart session
16 | """
17 | items: [Product]
18 | """
19 | The current total of all the items in the cart, before taxes and shipping
20 | """
21 | subtotal: Float
22 | }
23 |
24 | type Money {
25 | amount: Float
26 | currency: String
27 | }
28 |
29 | """
30 | Returns information about a specific purchase
31 | """
32 | type Order {
33 | """
34 | Each order has a unique id which is separate from the user or items they bought
35 | """
36 | id: ID!
37 | """
38 | The user who made the purchase
39 | """
40 | buyer: User!
41 | """
42 | A list of all the items they purchased.
43 | """
44 | items: [Product!]!
45 | total: Money
46 | """
47 | Calculate the cost to ship all the variants to the users address
48 | """
49 | shippingCost: Float
50 | }
51 |
52 | """
53 | Search filters for when showing an users previous purchases
54 | """
55 | input OrderFilters {
56 | orderId: ID!
57 | priceHigh: Float
58 | priceLow: Float
59 | itemsInOrder: Int
60 | }
61 |
62 | """
63 | A specific product sold by our store. This contains all the high level details but is not the purchasable item.
64 | """
65 | type Product {
66 | id: ID!
67 | title: String
68 | description: String
69 | mediaUrl: String
70 | weight: Float
71 | price: Money
72 | reviews: [Review]
73 | averageRating: Float
74 | }
75 |
76 | """
77 | Search filters for when returning Products
78 | """
79 | input ProductSearchInput {
80 | titleStartsWith: String
81 | }
82 |
83 | type Query {
84 | """
85 | Get a specific order by id. Meant to be used for a detailed view of an order
86 | """
87 | order(id: ID!): Order
88 | """
89 | Get all available products to shop for. Optionally provide some search filters
90 | """
91 | searchProducts(searchInput: ProductSearchInput! = {}): [Product]
92 | """
93 | Get a specific product by id. Useful for the product details page or checkout page
94 | """
95 | product(id: ID!): Product
96 | """
97 | Top products for home display
98 | """
99 | products: [Product]
100 | """
101 | Get the current user from our fake "auth" headers
102 | Set the "x-user-id" header to the user id.
103 | """
104 | viewer: User
105 | }
106 |
107 | type Review {
108 | id: ID!
109 | rating: Float
110 | content: String
111 | }
112 |
113 | """
114 | An user account in our system
115 | """
116 | type User {
117 | id: ID!
118 | """
119 | The users current saved shipping address
120 | """
121 | shippingAddress: String
122 | """
123 | The users login username
124 | """
125 | username: String!
126 | """
127 | The user's active cart session. Once the cart items have been purchases, they transition to an Order
128 | """
129 | cart: Cart
130 | """
131 | The users previous purchases
132 | """
133 | orders(filters: OrderFilters): [Order]
134 | }
135 |
--------------------------------------------------------------------------------
/.storybook/stories/components/apollo-client/EcommerceExample.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense, type ReactNode } from "react";
2 | import type { TypedDocumentNode } from "@apollo/client";
3 | import {
4 | gql,
5 | InMemoryCache,
6 | ApolloClient,
7 | ApolloProvider,
8 | ApolloLink,
9 | HttpLink,
10 | useSuspenseQuery,
11 | } from "@apollo/client";
12 | import { Product, Reviews } from "../Product.js";
13 | import { Container } from "../Container.js";
14 |
15 | const httpLink = new HttpLink({
16 | uri: "https://main--hack-the-e-commerce.apollographos.net/graphql",
17 | });
18 |
19 | export const makeClient = () =>
20 | new ApolloClient({
21 | cache: new InMemoryCache(),
22 | link: ApolloLink.from([httpLink]),
23 | connectToDevTools: true,
24 | });
25 |
26 | export const client = makeClient();
27 |
28 | const APP_QUERY: TypedDocumentNode<{
29 | products: {
30 | id: string;
31 | title: string;
32 | mediaUrl: string;
33 | reviews: Array<{ rating: number; id: string }>;
34 | }[];
35 | }> = gql`
36 | query AppQuery {
37 | products {
38 | id
39 | reviews {
40 | id
41 | rating
42 | }
43 | title
44 | mediaUrl
45 | }
46 | }
47 | `;
48 |
49 | const APP_QUERY_WITH_DEFER: TypedDocumentNode<{
50 | products: {
51 | id: string;
52 | title: string;
53 | mediaUrl: string;
54 | reviews?: Array<{ rating: number; id: string }>;
55 | }[];
56 | }> = gql`
57 | query AppQueryWithDefer {
58 | products {
59 | id
60 | ... @defer {
61 | reviews {
62 | id
63 | rating
64 | }
65 | }
66 | title
67 | mediaUrl
68 | }
69 | }
70 | `;
71 |
72 | function Wrapper({ children }: { children: ReactNode }) {
73 | return (
74 |
75 | Loading...}>{children}
76 |
77 | );
78 | }
79 |
80 | export function ApolloApp() {
81 | return (
82 |
83 |
84 |
85 | );
86 | }
87 |
88 | export function ApolloAppWithDefer() {
89 | return (
90 |
91 |
92 |
93 | );
94 | }
95 |
96 | export function App() {
97 | // Use useSuspenseQuery here because we want to demo the loading experience
98 | // with/without defer.
99 | const { data } = useSuspenseQuery(APP_QUERY);
100 |
101 | return (
102 |
103 | {data.products.map((product) => (
104 |
105 |
106 |
107 | ))}
108 |
109 | );
110 | }
111 |
112 | export function AppWithDefer() {
113 | // Use useSuspenseQuery here because we want to demo the loading experience
114 | // with/without defer.
115 | const { data } = useSuspenseQuery(APP_QUERY_WITH_DEFER);
116 |
117 | return (
118 |
119 | {data.products.map((product) => (
120 |
121 |
122 |
123 | ))}
124 |
125 | );
126 | }
127 |
--------------------------------------------------------------------------------
/.storybook/stories/Welcome.mdx:
--------------------------------------------------------------------------------
1 | import { Meta } from "@storybook/blocks";
2 |
3 |
4 |
5 |
6 |
GraphQL Testing Library
7 |
8 | {/*

*/}
9 |
10 | {" "}
11 |
12 |
Generate Mock Service Worker handlers for your GraphQL APIs.
13 |
14 |
15 |
16 |
17 | **GraphQL Testing Library** provides utilities that make it easy to generate [Mock Service Worker](https://mswjs.io/) handlers for any GraphQL API.
18 |
19 | MSW is the [Testing Library-recommended](https://testing-library.com/docs/react-testing-library/example-intro/#full-example) way to declaratively mock API communication in your tests without stubbing `window.fetch`.
20 |
21 | This library currently supports incremental delivery features `@defer` and `@stream` out of the box, with plans to support subscriptions over multipart HTTP as well as other transports such as WebSockets, [currently in beta in MSW](https://github.com/mswjs/msw/discussions/2010).
22 |
23 | > This project is not affiliated with the ["Testing Library"](https://github.com/testing-library) ecosystem that inspired it. We're just fans :)
24 |
25 | ## Installation
26 |
27 | This library has `peerDependencies` listings for `msw` at `^2.0.0` and `graphql` at `^15.0.0 || ^16.0.0`. Install them along with this library using your preferred package manager:
28 |
29 | ```
30 | npm install --save-dev @apollo/graphql-testing-library msw graphql
31 | pnpm add --save-dev @apollo/graphql-testing-library msw graphql
32 | yarn add --dev @apollo/graphql-testing-library msw graphql
33 | bun add --dev @apollo/graphql-testing-library msw graphql
34 | ```
35 |
36 | ## Usage
37 |
38 | ### `createHandler`
39 |
40 | ```typescript
41 | import { createHandler } from "@apollo/graphql-testing-library";
42 |
43 | // We suggest using @graphql-tools/mock and @graphql-tools/schema
44 | // to create a schema with mock resolvers.
45 | // See https://the-guild.dev/graphql/tools/docs/mocking for more info.
46 | import { addMocksToSchema } from "@graphql-tools/mock";
47 | import { makeExecutableSchema } from "@graphql-tools/schema";
48 | import typeDefs from "./schema.graphql";
49 |
50 | // Create an executable schema
51 | const schema = makeExecutableSchema({ typeDefs });
52 |
53 | // Add mock resolvers
54 | const schemaWithMocks = addMocksToSchema({
55 | schema,
56 | resolvers: {
57 | Query: {
58 | products: () =>
59 | Array.from({ length: 5 }, (_element, id) => ({
60 | id: `product-${id}`,
61 | })),
62 | },
63 | },
64 | });
65 |
66 | // `createHandler` returns an object with a `handler` and `replaceSchema`
67 | // function: `handler` is a MSW handler that will intercept all GraphQL
68 | // operations, and `replaceSchema` allows you to replace the mock schema
69 | // the `handler` use to resolve requests against.
70 | const { handler, replaceSchema } = createHandler(schemaWithMocks, {
71 | // It accepts a config object as the second argument where you can specify a
72 | // delay duration, which uses MSW's delay API:
73 | // https://mswjs.io/docs/api/delay
74 | // Default: "real" (100-400ms in browsers, 20ms in Node-like processes)
75 | delay: number | "infinite" | "real",
76 | });
77 | ```
78 |
--------------------------------------------------------------------------------
/.storybook/stories/components/relay/relay-environment.ts:
--------------------------------------------------------------------------------
1 | import { serializeFetchParameter } from "@apollo/client";
2 | import type { CacheConfig, RequestParameters } from "relay-runtime";
3 | import {
4 | Environment,
5 | Network,
6 | Observable,
7 | RecordSource,
8 | Store,
9 | QueryResponseCache,
10 | } from "relay-runtime";
11 | import type { Variables } from "relay-runtime";
12 | import { maybe } from "@apollo/client/utilities";
13 | import {
14 | handleError,
15 | readMultipartBody,
16 | } from "@apollo/client/link/http/parseAndCheckHttpResponse";
17 |
18 | const uri = "https://main--hack-the-e-commerce.apollographos.net/graphql";
19 |
20 | const oneMinute = 60 * 1000;
21 | const cache = new QueryResponseCache({ size: 250, ttl: oneMinute });
22 |
23 | const backupFetch = maybe(() => fetch);
24 |
25 | function fetchQuery(
26 | operation: RequestParameters,
27 | variables: Variables,
28 | cacheConfig: CacheConfig,
29 | ) {
30 | const queryID = operation.text;
31 | const isMutation = operation.operationKind === "mutation";
32 | const isQuery = operation.operationKind === "query";
33 | const forceFetch = cacheConfig && cacheConfig.force;
34 |
35 | // Try to get data from cache on queries
36 | const fromCache = cache.get(queryID, variables);
37 | if (isQuery && fromCache !== null && !forceFetch) {
38 | return fromCache;
39 | }
40 |
41 | const body = {
42 | operationName: operation.name,
43 | variables,
44 | query: operation.text || "",
45 | };
46 |
47 | const options: {
48 | method: string;
49 | headers: Record;
50 | body?: string;
51 | } = {
52 | method: "POST",
53 | headers: {
54 | "Content-Type": "application/json",
55 | accept: "multipart/mixed;deferSpec=20220824,application/json",
56 | },
57 | };
58 |
59 | return Observable.create((sink) => {
60 | try {
61 | options.body = serializeFetchParameter(body, "Payload");
62 | } catch (parseError) {
63 | sink.error(parseError as Error);
64 | }
65 |
66 | const currentFetch = maybe(() => fetch) || backupFetch;
67 |
68 | const observerNext = (data) => {
69 | if ("incremental" in data) {
70 | for (const item of data.incremental) {
71 | sink.next(item);
72 | }
73 | } else if ("data" in data) {
74 | sink.next(data);
75 | }
76 | };
77 |
78 | currentFetch!(uri, options)
79 | .then(async (response) => {
80 | const ctype = response.headers?.get("content-type");
81 |
82 | if (ctype !== null && /^multipart\/mixed/i.test(ctype)) {
83 | return readMultipartBody(response, observerNext);
84 | } else {
85 | const json = await response.json();
86 |
87 | if (isQuery && json) {
88 | cache.set(queryID, variables, json);
89 | }
90 | // Clear cache on mutations
91 | if (isMutation) {
92 | cache.clear();
93 | }
94 |
95 | observerNext(json);
96 | }
97 | })
98 | .then(() => {
99 | sink.complete();
100 | })
101 | .catch((err: any) => {
102 | handleError(err, sink);
103 | });
104 | });
105 | }
106 |
107 | const network = Network.create(fetchQuery);
108 |
109 | export const RelayEnvironment = new Environment({
110 | network,
111 | store: new Store(new RecordSource()),
112 | });
113 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | concurrency: ${{ github.workflow }}-${{ github.ref }}
9 |
10 | jobs:
11 | release:
12 | name: Changesets Release
13 | # Prevents action from creating a PR on forks
14 | if: github.repository == 'apollographql/graphql-testing-library'
15 | runs-on: ubuntu-latest
16 | # Permissions necessary for Changesets to push a new branch and open PRs
17 | # (for automated Version Packages PRs), and request the JWT for provenance.
18 | # More info: https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#adding-permissions-settings
19 | permissions:
20 | contents: write
21 | pull-requests: write
22 | id-token: write
23 | steps:
24 | - name: Checkout repo
25 | uses: actions/checkout@v4
26 | with:
27 | # Fetch entire git history so Changesets can generate changelogs
28 | # with the correct commits
29 | fetch-depth: 0
30 |
31 | - name: Check for pre.json file existence
32 | id: check_files
33 | uses: andstor/file-existence-action@v3.0.0
34 | with:
35 | files: ".changeset/pre.json"
36 |
37 | - name: Append NPM token to .npmrc
38 | run: |
39 | cat << EOF > "$HOME/.npmrc"
40 | provenance=true
41 | //registry.npmjs.org/:_authToken=$NPM_TOKEN
42 | EOF
43 | env:
44 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
45 |
46 | - name: Setup Node.js 20.x
47 | uses: actions/setup-node@v4
48 | with:
49 | node-version: 20.x
50 |
51 | - name: Install pnpm and dependencies
52 | uses: pnpm/action-setup@v4
53 | with:
54 | version: 9
55 | run_install: true
56 |
57 | - name: Create release PR or publish to npm + GitHub
58 | id: changesets
59 | if: steps.check_files.outputs.files_exists == 'false'
60 | uses: changesets/action@v1
61 | with:
62 | version: pnpm run changeset-version
63 | publish: pnpm run changeset-publish
64 | env:
65 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
66 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
67 |
68 | - name: Send a Slack notification on publish
69 | if: steps.changesets.outcome == 'success' && steps.changesets.outputs.published == 'true'
70 | id: slack
71 | uses: slackapi/slack-github-action@v1.27.0
72 | with:
73 | # Slack channel id, channel name, or user id to post message
74 | # See also: https://api.slack.com/methods/chat.postMessage#channels
75 | # You can pass in multiple channels to post to by providing
76 | # a comma-delimited list of channel IDs
77 | channel-id: "C07K7QQ93FW"
78 | payload: |
79 | {
80 | "blocks": [
81 | {
82 | "type": "section",
83 | "text": {
84 | "type": "mrkdwn",
85 | "text": "A new version of `@apollo/graphql-testing-library` was released: :rocket:"
86 | }
87 | }
88 | ]
89 | }
90 | env:
91 | SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
92 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
GraphQL Testing Library
10 |
11 |
Generate Mock Service Worker handlers for your GraphQL APIs.
12 |
13 | [](https://badge.fury.io/js/%40apollo%2Fgraphql-testing-library)  
14 |
15 |
16 |
17 |
18 | **GraphQL Testing Library** provides utilities that make it easy to generate [Mock Service Worker](https://mswjs.io/) handlers for any GraphQL API.
19 |
20 | MSW is the [Testing Library-recommended](https://testing-library.com/docs/react-testing-library/example-intro/#full-example) way to declaratively mock API communication in your tests without stubbing `window.fetch`.
21 |
22 | This library currently supports incremental delivery features `@defer` and `@stream` out of the box, with plans to support subscriptions over multipart HTTP as well as other transports such as WebSockets, [currently in beta in MSW](https://github.com/mswjs/msw/discussions/2010).
23 |
24 | > This project is not affiliated with the ["Testing Library"](https://github.com/testing-library) ecosystem that inspired it. We're just fans :)
25 |
26 | ## Installation
27 |
28 | This library has `peerDependencies` listings for `msw` at `^2.0.0` and `graphql` at `^15.0.0 || ^16.0.0`. Install them along with this library using your preferred package manager:
29 |
30 | ```
31 | npm install --save-dev @apollo/graphql-testing-library msw graphql
32 | pnpm add --save-dev @apollo/graphql-testing-library msw graphql
33 | yarn add --dev @apollo/graphql-testing-library msw graphql
34 | bun add --dev @apollo/graphql-testing-library msw graphql
35 | ```
36 |
37 | ## Usage
38 |
39 | ### `createHandler`
40 |
41 | ```typescript
42 | import { createHandler } from "@apollo/graphql-testing-library";
43 |
44 | // We suggest using @graphql-tools/mock and @graphql-tools/schema
45 | // to create a schema with mock resolvers.
46 | // See https://the-guild.dev/graphql/tools/docs/mocking for more info.
47 | import { addMocksToSchema } from "@graphql-tools/mock";
48 | import { makeExecutableSchema } from "@graphql-tools/schema";
49 | import typeDefs from "./schema.graphql";
50 |
51 | // Create an executable schema
52 | const schema = makeExecutableSchema({ typeDefs });
53 |
54 | // Add mock resolvers
55 | const schemaWithMocks = addMocksToSchema({
56 | schema,
57 | resolvers: {
58 | Query: {
59 | products: () =>
60 | Array.from({ length: 5 }, (_element, id) => ({
61 | id: `product-${id}`,
62 | })),
63 | },
64 | },
65 | });
66 |
67 | // `createHandler` returns an object with a `handler` and `replaceSchema`
68 | // function: `handler` is a MSW handler that will intercept all GraphQL
69 | // operations, and `replaceSchema` allows you to replace the mock schema
70 | // the `handler` use to resolve requests against.
71 | const { handler, replaceSchema } = createHandler(schemaWithMocks, {
72 | // It accepts a config object as the second argument where you can specify a
73 | // delay duration, which uses MSW's delay API:
74 | // https://mswjs.io/docs/api/delay
75 | // Default: "real" (100-400ms in browsers, 20ms in Node-like processes)
76 | delay: number | "infinite" | "real",
77 | });
78 | ```
79 |
--------------------------------------------------------------------------------
/.storybook/stories/components/relay/__generated__/RelayComponentAppQuery.graphql.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @generated SignedSource<<12bf2f218fc1496a2703731552a99cb9>>
3 | * @lightSyntaxTransform
4 | * @nogrep
5 | */
6 |
7 | /* tslint:disable */
8 | /* eslint-disable */
9 | // @ts-nocheck
10 |
11 | import { ConcreteRequest, Query } from 'relay-runtime';
12 | import { FragmentRefs } from "relay-runtime";
13 | export type RelayComponentAppQuery$variables = Record;
14 | export type RelayComponentAppQuery$data = {
15 | readonly products: ReadonlyArray<{
16 | readonly id: string;
17 | readonly mediaUrl: string | null | undefined;
18 | readonly title: string | null | undefined;
19 | readonly " $fragmentSpreads": FragmentRefs<"RelayComponentReviewsFragment_product">;
20 | } | null | undefined> | null | undefined;
21 | };
22 | export type RelayComponentAppQuery = {
23 | response: RelayComponentAppQuery$data;
24 | variables: RelayComponentAppQuery$variables;
25 | };
26 |
27 | const node: ConcreteRequest = (function(){
28 | var v0 = {
29 | "alias": null,
30 | "args": null,
31 | "kind": "ScalarField",
32 | "name": "id",
33 | "storageKey": null
34 | },
35 | v1 = {
36 | "alias": null,
37 | "args": null,
38 | "kind": "ScalarField",
39 | "name": "title",
40 | "storageKey": null
41 | },
42 | v2 = {
43 | "alias": null,
44 | "args": null,
45 | "kind": "ScalarField",
46 | "name": "mediaUrl",
47 | "storageKey": null
48 | };
49 | return {
50 | "fragment": {
51 | "argumentDefinitions": [],
52 | "kind": "Fragment",
53 | "metadata": null,
54 | "name": "RelayComponentAppQuery",
55 | "selections": [
56 | {
57 | "alias": null,
58 | "args": null,
59 | "concreteType": "Product",
60 | "kind": "LinkedField",
61 | "name": "products",
62 | "plural": true,
63 | "selections": [
64 | (v0/*: any*/),
65 | {
66 | "args": null,
67 | "kind": "FragmentSpread",
68 | "name": "RelayComponentReviewsFragment_product"
69 | },
70 | (v1/*: any*/),
71 | (v2/*: any*/)
72 | ],
73 | "storageKey": null
74 | }
75 | ],
76 | "type": "Query",
77 | "abstractKey": null
78 | },
79 | "kind": "Request",
80 | "operation": {
81 | "argumentDefinitions": [],
82 | "kind": "Operation",
83 | "name": "RelayComponentAppQuery",
84 | "selections": [
85 | {
86 | "alias": null,
87 | "args": null,
88 | "concreteType": "Product",
89 | "kind": "LinkedField",
90 | "name": "products",
91 | "plural": true,
92 | "selections": [
93 | (v0/*: any*/),
94 | {
95 | "alias": null,
96 | "args": null,
97 | "concreteType": "Review",
98 | "kind": "LinkedField",
99 | "name": "reviews",
100 | "plural": true,
101 | "selections": [
102 | (v0/*: any*/),
103 | {
104 | "alias": null,
105 | "args": null,
106 | "kind": "ScalarField",
107 | "name": "rating",
108 | "storageKey": null
109 | }
110 | ],
111 | "storageKey": null
112 | },
113 | (v1/*: any*/),
114 | (v2/*: any*/)
115 | ],
116 | "storageKey": null
117 | }
118 | ]
119 | },
120 | "params": {
121 | "cacheID": "0f4ff7bfd1b621ec073644a2e8b2a199",
122 | "id": null,
123 | "metadata": {},
124 | "name": "RelayComponentAppQuery",
125 | "operationKind": "query",
126 | "text": "query RelayComponentAppQuery {\n products {\n id\n ...RelayComponentReviewsFragment_product\n title\n mediaUrl\n }\n}\n\nfragment RelayComponentReviewsFragment_product on Product {\n reviews {\n id\n rating\n }\n}\n"
127 | }
128 | };
129 | })();
130 |
131 | (node as any).hash = "03cceceba96fb0404eb4759dc44bb7c9";
132 |
133 | export default node;
134 |
--------------------------------------------------------------------------------
/src/utilities.ts:
--------------------------------------------------------------------------------
1 | import {
2 | type ASTNode,
3 | type GraphQLEnumValue,
4 | type GraphQLSchema,
5 | GraphQLEnumType,
6 | isUnionType,
7 | isObjectType,
8 | isScalarType,
9 | visit,
10 | BREAK,
11 | } from "graphql";
12 |
13 | function hasDirectives(names: string[], root: ASTNode, all?: boolean) {
14 | const nameSet = new Set(names);
15 | const uniqueCount = nameSet.size;
16 |
17 | visit(root, {
18 | Directive(node) {
19 | if (nameSet.delete(node.name.value) && (!all || !nameSet.size)) {
20 | return BREAK;
21 | }
22 | },
23 | });
24 |
25 | // If we found all the names, nameSet will be empty. If we only care about
26 | // finding some of them, the < condition is sufficient.
27 | return all ? !nameSet.size : nameSet.size < uniqueCount;
28 | }
29 |
30 | // Generates a Map of possible types. The keys are Union | Interface type names
31 | // which map to Sets of either union members or types that implement the
32 | // interface.
33 | function createPossibleTypesMap(executableSchema: GraphQLSchema) {
34 | const typeMap = executableSchema.getTypeMap();
35 | const possibleTypesMap = new Map>();
36 |
37 | for (const typeName of Object.keys(typeMap)) {
38 | const type = typeMap[typeName];
39 |
40 | if (isUnionType(type) && !possibleTypesMap.has(typeName)) {
41 | possibleTypesMap.set(
42 | typeName,
43 | new Set(type.getTypes().map(({ name }) => name)),
44 | );
45 | }
46 |
47 | if (isObjectType(type) && type.getInterfaces().length > 0) {
48 | for (const interfaceType of type.getInterfaces()) {
49 | if (possibleTypesMap.has(interfaceType.name)) {
50 | const setOfTypes = possibleTypesMap.get(interfaceType.name);
51 | if (setOfTypes) {
52 | possibleTypesMap.set(interfaceType.name, setOfTypes.add(typeName));
53 | }
54 | } else {
55 | possibleTypesMap.set(interfaceType.name, new Set([typeName]));
56 | }
57 | }
58 | }
59 | }
60 | return possibleTypesMap;
61 | }
62 |
63 | // From a Map of possible types, create default resolvers with __resolveType
64 | // functions that pick the first possible type if no __typename is present.
65 | function createDefaultResolvers(typesMap: Map>) {
66 | const defaultResolvers: {
67 | [key: string]: {
68 | __resolveType(data: { __typename?: string }): string;
69 | };
70 | } = {};
71 |
72 | for (const key of typesMap.keys()) {
73 | defaultResolvers[key] = {
74 | __resolveType(data) {
75 | return data.__typename || typesMap.get(key)?.values().next().value;
76 | },
77 | };
78 | }
79 | return defaultResolvers;
80 | }
81 |
82 | // Sorts enum values alphabetically.
83 | const sortEnumValues = () => {
84 | const key = "value";
85 | return (a: GraphQLEnumValue, b: GraphQLEnumValue) =>
86 | a[key] > b[key] ? 1 : b[key] > a[key] ? -1 : 0;
87 | };
88 |
89 | // Creates a map of enum types and mock resolver functions that return
90 | // the first possible value.
91 | function generateEnumMocksFromSchema(schema: GraphQLSchema) {
92 | return Object.fromEntries(
93 | Object.entries(schema.getTypeMap())
94 | .filter(
95 | (arg): arg is [string, GraphQLEnumType] =>
96 | arg[1] instanceof GraphQLEnumType,
97 | )
98 | .map(([typeName, type]) => {
99 | const value = type
100 | .getValues()
101 | .concat()
102 | .sort(sortEnumValues())[0]?.value;
103 | return [typeName, () => value] as const;
104 | }),
105 | );
106 | }
107 |
108 | function mockCustomScalars(schema: GraphQLSchema) {
109 | const typeMap = schema.getTypeMap();
110 |
111 | const mockScalarsMap: {
112 | [typeOrScalarName: string]: () => string;
113 | } = {};
114 |
115 | for (const typeName of Object.keys(typeMap)) {
116 | const type = typeMap[typeName];
117 |
118 | if (isScalarType(type) && type.astNode) {
119 | mockScalarsMap[typeName] = () =>
120 | `Default value for custom scalar \`${typeName}\``;
121 | }
122 | }
123 | return mockScalarsMap;
124 | }
125 |
126 | export {
127 | hasDirectives,
128 | createPossibleTypesMap,
129 | createDefaultResolvers,
130 | generateEnumMocksFromSchema,
131 | mockCustomScalars,
132 | };
133 |
--------------------------------------------------------------------------------
/.storybook/stories/components/relay/__generated__/RelayComponentWithDeferAppQuery.graphql.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @generated SignedSource<<05485eb90f8c7d75d8b2c69e733128a7>>
3 | * @lightSyntaxTransform
4 | * @nogrep
5 | */
6 |
7 | /* tslint:disable */
8 | /* eslint-disable */
9 | // @ts-nocheck
10 |
11 | import { ConcreteRequest, Query } from 'relay-runtime';
12 | import { FragmentRefs } from "relay-runtime";
13 | export type RelayComponentWithDeferAppQuery$variables = Record;
14 | export type RelayComponentWithDeferAppQuery$data = {
15 | readonly products: ReadonlyArray<{
16 | readonly id: string;
17 | readonly mediaUrl: string | null | undefined;
18 | readonly title: string | null | undefined;
19 | readonly " $fragmentSpreads": FragmentRefs<"RelayComponentReviewsFragment_product">;
20 | } | null | undefined> | null | undefined;
21 | };
22 | export type RelayComponentWithDeferAppQuery = {
23 | response: RelayComponentWithDeferAppQuery$data;
24 | variables: RelayComponentWithDeferAppQuery$variables;
25 | };
26 |
27 | const node: ConcreteRequest = (function(){
28 | var v0 = {
29 | "alias": null,
30 | "args": null,
31 | "kind": "ScalarField",
32 | "name": "id",
33 | "storageKey": null
34 | },
35 | v1 = {
36 | "alias": null,
37 | "args": null,
38 | "kind": "ScalarField",
39 | "name": "title",
40 | "storageKey": null
41 | },
42 | v2 = {
43 | "alias": null,
44 | "args": null,
45 | "kind": "ScalarField",
46 | "name": "mediaUrl",
47 | "storageKey": null
48 | };
49 | return {
50 | "fragment": {
51 | "argumentDefinitions": [],
52 | "kind": "Fragment",
53 | "metadata": null,
54 | "name": "RelayComponentWithDeferAppQuery",
55 | "selections": [
56 | {
57 | "alias": null,
58 | "args": null,
59 | "concreteType": "Product",
60 | "kind": "LinkedField",
61 | "name": "products",
62 | "plural": true,
63 | "selections": [
64 | (v0/*: any*/),
65 | {
66 | "kind": "Defer",
67 | "selections": [
68 | {
69 | "args": null,
70 | "kind": "FragmentSpread",
71 | "name": "RelayComponentReviewsFragment_product"
72 | }
73 | ]
74 | },
75 | (v1/*: any*/),
76 | (v2/*: any*/)
77 | ],
78 | "storageKey": null
79 | }
80 | ],
81 | "type": "Query",
82 | "abstractKey": null
83 | },
84 | "kind": "Request",
85 | "operation": {
86 | "argumentDefinitions": [],
87 | "kind": "Operation",
88 | "name": "RelayComponentWithDeferAppQuery",
89 | "selections": [
90 | {
91 | "alias": null,
92 | "args": null,
93 | "concreteType": "Product",
94 | "kind": "LinkedField",
95 | "name": "products",
96 | "plural": true,
97 | "selections": [
98 | (v0/*: any*/),
99 | {
100 | "if": null,
101 | "kind": "Defer",
102 | "label": "RelayComponentWithDeferAppQuery$defer$RelayComponentReviewsFragment_product",
103 | "selections": [
104 | {
105 | "alias": null,
106 | "args": null,
107 | "concreteType": "Review",
108 | "kind": "LinkedField",
109 | "name": "reviews",
110 | "plural": true,
111 | "selections": [
112 | (v0/*: any*/),
113 | {
114 | "alias": null,
115 | "args": null,
116 | "kind": "ScalarField",
117 | "name": "rating",
118 | "storageKey": null
119 | }
120 | ],
121 | "storageKey": null
122 | }
123 | ]
124 | },
125 | (v1/*: any*/),
126 | (v2/*: any*/)
127 | ],
128 | "storageKey": null
129 | }
130 | ]
131 | },
132 | "params": {
133 | "cacheID": "d39365cfca4b78af84446cd9fdb61a63",
134 | "id": null,
135 | "metadata": {},
136 | "name": "RelayComponentWithDeferAppQuery",
137 | "operationKind": "query",
138 | "text": "query RelayComponentWithDeferAppQuery {\n products {\n id\n ...RelayComponentReviewsFragment_product @defer(label: \"RelayComponentWithDeferAppQuery$defer$RelayComponentReviewsFragment_product\")\n title\n mediaUrl\n }\n}\n\nfragment RelayComponentReviewsFragment_product on Product {\n reviews {\n id\n rating\n }\n}\n"
139 | }
140 | };
141 | })();
142 |
143 | (node as any).hash = "f282e572ab3878c9ccb2f4ad43491878";
144 |
145 | export default node;
146 |
--------------------------------------------------------------------------------
/demo/server/src/index.ts:
--------------------------------------------------------------------------------
1 | import { ApolloServer } from "@apollo/server";
2 | import { expressMiddleware } from "@apollo/server/express4";
3 | import { ApolloServerPluginDrainHttpServer } from "@apollo/server/plugin/drainHttpServer";
4 | import express from "express";
5 | import { createServer } from "http";
6 | import { makeExecutableSchema } from "@graphql-tools/schema";
7 | import { WebSocketServer } from "ws";
8 | import { useServer } from "graphql-ws/lib/use/ws";
9 | import { PubSub } from "graphql-subscriptions";
10 | import typeDefs from "../../../.storybook/stories/schemas/wnba.graphql";
11 | import bodyParser from "body-parser";
12 | import cors from "cors";
13 |
14 | const PORT = 4000;
15 | const pubsub = new PubSub();
16 |
17 | const coaches = [
18 | {
19 | id: "1",
20 | name: "Sandy Brondello",
21 | },
22 | {
23 | id: "2",
24 | name: "Becky Hammon",
25 | },
26 | {
27 | id: "3",
28 | name: "Curt Miller",
29 | },
30 | ];
31 |
32 | const teams = [
33 | {
34 | id: "1",
35 | name: "New York Liberty",
36 | wins: 26,
37 | losses: 6,
38 | },
39 | {
40 | id: "2",
41 | name: "Las Vegas Aces",
42 | wins: 18,
43 | losses: 12,
44 | },
45 | {
46 | id: "3",
47 | name: "Los Angeles Sparks",
48 | wins: 7,
49 | losses: 24,
50 | },
51 | {
52 | id: "4",
53 | name: "Atlanta Dream",
54 | wins: 10,
55 | losses: 20,
56 | },
57 | {
58 | id: "5",
59 | name: "Chicago Sky",
60 | wins: 11,
61 | losses: 19,
62 | },
63 | {
64 | id: "6",
65 | name: "Connecticut Sun",
66 | wins: 22,
67 | losses: 8,
68 | },
69 | {
70 | id: "7",
71 | name: "Indiana Fever",
72 | wins: 15,
73 | losses: 16,
74 | },
75 | {
76 | id: "8",
77 | name: "Washington Mystics",
78 | wins: 9,
79 | losses: 22,
80 | },
81 | {
82 | id: "9",
83 | name: "Dallas Wings",
84 | wins: 8,
85 | losses: 22,
86 | },
87 | {
88 | id: "10",
89 | name: "Minnesota Lynx",
90 | wins: 23,
91 | losses: 8,
92 | },
93 | {
94 | id: "11",
95 | name: "Phoenix Mercury",
96 | wins: 16,
97 | losses: 16,
98 | },
99 | {
100 | id: "12",
101 | name: "Seattle Storm",
102 | wins: 19,
103 | losses: 11,
104 | },
105 | ];
106 |
107 | let currentTeam = "1";
108 |
109 | // Resolver map
110 | const resolvers = {
111 | Query: {
112 | team(_parent, { id }) {
113 | return teams.find((team) => team.id === (id || currentTeam));
114 | },
115 | teams() {
116 | return teams;
117 | },
118 | coaches() {
119 | return coaches;
120 | },
121 | },
122 | Subscription: {
123 | numberIncremented: {
124 | subscribe: () => pubsub.asyncIterator(["NUMBER_INCREMENTED"]),
125 | },
126 | },
127 | Mutation: {
128 | setCurrentTeam(_parent, { team }) {
129 | currentTeam = team;
130 | return teams.find((t) => t.id === team);
131 | },
132 | },
133 | };
134 |
135 | // Create schema, which will be used separately by ApolloServer and
136 | // the WebSocket server.
137 | const schema = makeExecutableSchema({ typeDefs, resolvers });
138 |
139 | // Create an Express app and HTTP server; we will attach the WebSocket
140 | // server and the ApolloServer to this HTTP server.
141 | const app = express();
142 | const httpServer = createServer(app);
143 |
144 | // Set up WebSocket server.
145 | const wsServer = new WebSocketServer({
146 | server: httpServer,
147 | path: "/graphql",
148 | });
149 | const serverCleanup = useServer({ schema }, wsServer);
150 |
151 | // Set up ApolloServer.
152 | const server = new ApolloServer({
153 | schema,
154 | plugins: [
155 | // Proper shutdown for the HTTP server.
156 | ApolloServerPluginDrainHttpServer({ httpServer }),
157 |
158 | // Proper shutdown for the WebSocket server.
159 | {
160 | async serverWillStart() {
161 | return {
162 | async drainServer() {
163 | await serverCleanup.dispose();
164 | },
165 | };
166 | },
167 | },
168 | ],
169 | });
170 |
171 | await server.start();
172 | app.use(
173 | "/graphql",
174 | cors(),
175 | bodyParser.json(),
176 | expressMiddleware(server),
177 | );
178 |
179 | // Now that our HTTP server is fully set up, actually listen.
180 | httpServer.listen(PORT, () => {
181 | console.log(`🚀 Query endpoint ready at http://localhost:${PORT}/graphql`);
182 | console.log(
183 | `🚀 Subscription endpoint ready at ws://localhost:${PORT}/graphql`,
184 | );
185 | });
186 |
187 | // In the background, increment a number every second and notify subscribers when it changes.
188 | let currentNumber = 0;
189 | function incrementNumber() {
190 | currentNumber++;
191 | pubsub.publish("NUMBER_INCREMENTED", { numberIncremented: currentNumber });
192 | setTimeout(incrementNumber, 1000);
193 | }
194 |
195 | // Start incrementing
196 | incrementNumber();
197 |
--------------------------------------------------------------------------------
/docs/src/content/docs/integrations/node.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Usage in Node.js
3 | # description: A guide in my new Starlight docs site.
4 | ---
5 |
6 | import { Aside } from "@astrojs/starlight/components";
7 |
8 | Let's set up [Mock Service Worker](https://mswjs.io/docs) and configure it with GraphQL Testing Library for use in a [Node.js environment](https://mswjs.io/docs/integrations/node) with popular test runners like [Jest](https://jestjs.io/) and [Vitest](https://vitest.dev/).
9 |
10 | ## With Jest
11 |
12 | In order to use GraphQL Testing Library and MSW with Jest, missing Node.js globals must be polyfilled in your environment.
13 |
14 | Create a `jest.polyfills.js` file with the following contents:
15 |
16 | ```ts
17 | // jest.polyfills.js
18 | /**
19 | * @note The block below contains polyfills for Node.js globals
20 | * required for Jest to function when running JSDOM tests.
21 | * These HAVE to be require's and HAVE to be in this exact
22 | * order, since "undici" depends on the "TextEncoder" global API.
23 | *
24 | * Consider migrating to a more modern test runner if
25 | * you don't want to deal with this.
26 | */
27 |
28 | const { TextDecoder, TextEncoder } = require("node:util");
29 | const { ReadableStream } = require("node:stream/web");
30 |
31 | Object.defineProperties(globalThis, {
32 | TextDecoder: { value: TextDecoder },
33 | TextEncoder: { value: TextEncoder },
34 | ReadableStream: { value: ReadableStream },
35 | });
36 |
37 | const { Blob, File } = require("node:buffer");
38 | const { fetch, Headers, FormData, Request, Response } = require("undici");
39 |
40 | Object.defineProperties(globalThis, {
41 | fetch: { value: fetch, writable: true },
42 | Blob: { value: Blob },
43 | File: { value: File },
44 | Headers: { value: Headers },
45 | FormData: { value: FormData },
46 | Request: { value: Request },
47 | Response: { value: Response },
48 | });
49 |
50 | // Polyfill for "Symbol.dispose is not defined" error
51 | // Jest bug: https://github.com/jestjs/jest/issues/14874
52 | // The fix is available in https://github.com/jestjs/jest/releases/tag/v30.0.0-alpha.3 - this polyfill is necessary for earlier versions
53 | if (!Symbol.dispose) {
54 | Object.defineProperty(Symbol, "dispose", {
55 | value: Symbol("dispose"),
56 | });
57 | }
58 | if (!Symbol.asyncDispose) {
59 | Object.defineProperty(Symbol, "asyncDispose", {
60 | value: Symbol("asyncDispose"),
61 | });
62 | }
63 | ```
64 |
65 | This file mostly contains the [Jest polyfills recommended by MSW](https://mswjs.io/docs/faq/#requestresponsetextencoder-is-not-defined-jest), with two additions: the version above includes `ReadableStream` which is needed for incremental delivery features, and a polyfill for `Symbol.dispose` which is not available in Jest in versions before `30.0.0-alpha.3`.
66 |
67 |
72 |
73 | Then, set the `setupFiles` option in `jest.config.js` to point to your `jest.polyfills.js`:
74 |
75 | ```js ins={3}
76 | // jest.config.js
77 | module.exports = {
78 | setupFiles: ["./jest.polyfills.js"],
79 | };
80 | ```
81 |
82 | Next, follow the Mock Service Worker documentation for [setting up MSW in Node.js](https://mswjs.io/docs/integrations/node).
83 |
84 | Finally, install `@graphql-tools/jest-transform` as a dev dependency and configure Jest to transform `.gql`/`.graphql` files, since your GraphQL API's schema is needed to configure the Mock Service Worker [request handler](https://mswjs.io/docs/concepts/request-handler/) this library generates.
85 |
86 | Here are the relevant parts of your final `jest.config.js`:
87 |
88 | ```js ins={12-14}
89 | // jest.config.js
90 | module.exports = {
91 | testEnvironment: "jsdom",
92 | setupFiles: ["./jest.polyfills.js"],
93 | setupFilesAfterEnv: ["/setupTests.js"],
94 | // Opt out of the browser export condition for MSW tests.
95 | // For more information, see:
96 | // https://github.com/mswjs/msw/issues/1786#issuecomment-1782559851
97 | testEnvironmentOptions: {
98 | customExportConditions: [""],
99 | },
100 | transform: {
101 | "\\.(gql|graphql)$": "@graphql-tools/jest-transform",
102 | },
103 | };
104 | ```
105 |
106 | ## With Vitest
107 |
108 | No polyfills are needed in Vitest. In order to transform `.gql`/`.graphql` files, install `vite-plugin-graphql-loader` as a dev dependency and configure it in your `vitest.config.ts`.
109 |
110 |
115 |
116 | ```ts ins={4,7, 13-17}
117 | // vitest.config.ts
118 | ///
119 | import { defineConfig } from "vite";
120 | import { vitePluginGraphqlLoader } from "vite-plugin-graphql-loader";
121 |
122 | export default defineConfig({
123 | plugins: [vitePluginGraphqlLoader()],
124 | test: {
125 | include: ["**/*.test.tsx"],
126 | globals: true,
127 | environment: "jsdom",
128 | setupFiles: ["./setupTests.ts"],
129 | server: {
130 | deps: {
131 | fallbackCJS: true,
132 | },
133 | },
134 | },
135 | });
136 | ```
137 |
138 | Next, follow the Mock Service Worker documentation for [setting up MSW in Node.js](https://mswjs.io/docs/integrations/node).
139 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@apollo/graphql-testing-library",
3 | "version": "0.3.0",
4 | "private": true,
5 | "repository": {
6 | "url": "git+https://github.com/apollographql/graphql-testing-library"
7 | },
8 | "keywords": [
9 | "apollo",
10 | "apollo-client",
11 | "graphql",
12 | "msw"
13 | ],
14 | "publishConfig": {
15 | "access": "public"
16 | },
17 | "type": "module",
18 | "main": "./index.cjs",
19 | "module": "./index.js",
20 | "exports": {
21 | "require": {
22 | "types": "./index.d.cts",
23 | "default": "./index.cjs"
24 | },
25 | "default": {
26 | "types": "./index.d.ts",
27 | "default": "./index.js"
28 | }
29 | },
30 | "types": "./dist/index.d.ts",
31 | "scripts": {
32 | "build": "tsup",
33 | "clean": "rm -rf dist",
34 | "clean-storybook": "rm -rf storybook-static",
35 | "prepdist:changesets": "node scripts/prepareDist.cjs",
36 | "changeset-publish": "pnpm run clean && pnpm run build && pnpm run prepdist:changesets && cd dist && changeset publish",
37 | "changeset-version": "changeset version && pnpm i",
38 | "changeset-check": "changeset status --verbose --since=origin/main",
39 | "generate-types": "graphql-codegen --config codegen.ts",
40 | "test:jest": "jest",
41 | "test:vitest": "vitest run",
42 | "test:storybook": "test-storybook",
43 | "relay": "relay-compiler",
44 | "lint": "eslint --ext .ts src",
45 | "prettier": "prettier --check .",
46 | "type-check": "tsc --noEmit",
47 | "prepublishOnly": "pnpm run build",
48 | "build-and-test-storybook": "pnpm run build-storybook && npx concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"npx http-server storybook-static --port 6006 --silent\" \"npx wait-on tcp:6006 && pnpm run test:storybook\"",
49 | "storybook": "storybook dev -p 6006",
50 | "build-storybook": "pnpm run clean-storybook && storybook build"
51 | },
52 | "license": "MIT",
53 | "devDependencies": {
54 | "@apollo/client": "3.12.4",
55 | "@apollo/tailwind-preset": "0.2.0",
56 | "@changesets/changelog-github": "0.5.0",
57 | "@changesets/cli": "2.27.8",
58 | "@graphql-codegen/cli": "5.0.2",
59 | "@graphql-codegen/typescript": "4.1.2",
60 | "@graphql-codegen/typescript-resolvers": "4.4.1",
61 | "@graphql-tools/jest-transform": "2.0.0",
62 | "@playwright/test": "1.49.1",
63 | "@storybook/addon-docs": "8.4.7",
64 | "@storybook/addon-essentials": "8.4.7",
65 | "@storybook/addon-interactions": "8.4.7",
66 | "@storybook/addon-links": "8.4.7",
67 | "@storybook/blocks": "8.4.7",
68 | "@storybook/react": "8.4.7",
69 | "@storybook/react-vite": "8.4.7",
70 | "@storybook/test": "8.4.7",
71 | "@storybook/test-runner": "0.20.1",
72 | "@svgr/plugin-jsx": "8.1.0",
73 | "@svgr/plugin-svgo": "8.1.0",
74 | "@tailwindcss/aspect-ratio": "0.4.2",
75 | "@testing-library/dom": "10.4.0",
76 | "@testing-library/jest-dom": "6.6.3",
77 | "@testing-library/react": "16.1.0",
78 | "@tsconfig/recommended": "1.0.7",
79 | "@types/jest": "29.5.13",
80 | "@types/node": "22.10.2",
81 | "@types/react": "18.3.10",
82 | "@types/react-dom": "18.3.0",
83 | "@types/react-relay": "16.0.6",
84 | "@types/relay-runtime": "17.0.4",
85 | "@typescript-eslint/eslint-plugin": "8.18.1",
86 | "@typescript-eslint/parser": "8.18.1",
87 | "babel-plugin-relay": "17.0.0",
88 | "concurrently": "8.2.2",
89 | "eslint": "8.57.0",
90 | "eslint-plugin-storybook": "0.11.1",
91 | "graphql": "16.10.0",
92 | "graphql-ws": "5.16.0",
93 | "http-server": "14.1.1",
94 | "jest": "29.7.0",
95 | "jest-environment-jsdom": "29.7.0",
96 | "jsdom": "25.0.1",
97 | "msw": "2.7.0",
98 | "msw-storybook-addon": "2.0.3",
99 | "playwright": "1.49.1",
100 | "postcss": "8.4.47",
101 | "prettier": "3.4.2",
102 | "react": "18.3.1",
103 | "react-relay": "17.0.0",
104 | "relay-compiler": "17.0.0",
105 | "relay-runtime": "17.0.0",
106 | "storybook": "8.4.7",
107 | "tailwind": "4.0.0",
108 | "tailwindcss": "3.4.13",
109 | "ts-jest": "29.2.5",
110 | "ts-jest-resolver": "2.0.1",
111 | "tsup": "8.3.0",
112 | "tw-colors": "3.3.2",
113 | "typescript": "5.7.2",
114 | "undici": "6.21.0",
115 | "vite": "5.4.8",
116 | "vite-plugin-graphql-loader": "4.0.4",
117 | "vite-plugin-relay": "2.1.0",
118 | "vite-plugin-svgr": "4.3.0",
119 | "vitest": "2.1.1",
120 | "wait-on": "8.0.1"
121 | },
122 | "dependencies": {
123 | "@bundled-es-modules/statuses": "^1.0.1",
124 | "@graphql-tools/executor": "^1.2.7",
125 | "@graphql-tools/merge": "^9.0.4",
126 | "@graphql-tools/mock": "^9.0.4",
127 | "@graphql-tools/schema": "^10.0.4",
128 | "@graphql-tools/utils": "^10.3.2",
129 | "@types/statuses": "^2.0.5",
130 | "graphql-tag": "^2.12.6",
131 | "is-node-process": "^1.2.0",
132 | "outvariant": "^1.4.3"
133 | },
134 | "peerDependencies": {
135 | "graphql": "^15.0.0 || ^16.0.0",
136 | "msw": "^2.0.0"
137 | },
138 | "msw": {
139 | "workerDirectory": [
140 | ".storybook/public"
141 | ]
142 | },
143 | "relay": {
144 | "src": "./.storybook/stories/components/relay",
145 | "schema": "./.storybook/stories/schemas/ecommerce.graphql",
146 | "language": "typescript",
147 | "eagerEsModules": true,
148 | "exclude": [
149 | "**/node_modules/**",
150 | "**/__mocks__/**",
151 | "**/__generated__/**"
152 | ]
153 | }
154 | }
155 |
--------------------------------------------------------------------------------
/.storybook/stories/components/apollo-client/WNBAExample.tsx:
--------------------------------------------------------------------------------
1 | import React, { Suspense, type ReactElement, type ReactNode } from "react";
2 | import type { TypedDocumentNode } from "@apollo/client";
3 | import { createClient } from "graphql-ws";
4 | import { GraphQLWsLink } from "@apollo/client/link/subscriptions";
5 | import {
6 | gql,
7 | InMemoryCache,
8 | ApolloClient,
9 | ApolloProvider,
10 | ApolloLink,
11 | HttpLink,
12 | useSuspenseQuery,
13 | useMutation,
14 | useFragment,
15 | } from "@apollo/client";
16 |
17 | import { TeamLogo } from "../TeamLogo.js";
18 |
19 | const httpLink = new HttpLink({
20 | uri: "http://localhost:4000/graphql",
21 | });
22 |
23 | const wsUrl = "ws://localhost:4000/graphql";
24 |
25 | const wsLink = new GraphQLWsLink(
26 | createClient({
27 | url: wsUrl,
28 | }),
29 | );
30 |
31 | const definitionIsSubscription = (d: any) => {
32 | return d.kind === "OperationDefinition" && d.operation === "subscription";
33 | };
34 |
35 | const link = ApolloLink.split(
36 | (operation) => operation.query.definitions.some(definitionIsSubscription),
37 | wsLink,
38 | httpLink,
39 | );
40 |
41 | export const makeClient = () =>
42 | new ApolloClient({
43 | cache: new InMemoryCache(),
44 | link,
45 | connectToDevTools: true,
46 | });
47 |
48 | export const client = makeClient();
49 |
50 | const TEAM_FRAGMENT = gql`
51 | fragment TeamFragment on Team {
52 | id
53 | name
54 | wins
55 | losses
56 | }
57 | `;
58 |
59 | const APP_QUERY: TypedDocumentNode<{
60 | team: {
61 | id: string;
62 | name: string;
63 | wins: number;
64 | losses: number;
65 | };
66 | teams: {
67 | name: string;
68 | id: string;
69 | }[];
70 | }> = gql`
71 | query AppQuery {
72 | team {
73 | ...TeamFragment
74 | }
75 | teams {
76 | name
77 | id
78 | }
79 | }
80 |
81 | ${TEAM_FRAGMENT}
82 | `;
83 |
84 | const APP_MUTATION = gql`
85 | mutation SetCurrentTeam($team: ID!) {
86 | setCurrentTeam(team: $team) {
87 | ...TeamFragment
88 | }
89 | }
90 | ${TEAM_FRAGMENT}
91 | `;
92 |
93 | function Wrapper({ children }: { children: ReactNode }) {
94 | return (
95 |
96 | Loading...}>{children}
97 |
98 | );
99 | }
100 |
101 | export function ApolloApp() {
102 | return (
103 |
104 |
105 |
106 | );
107 | }
108 |
109 | export function App() {
110 | // Use useSuspenseQuery here because we want to demo the loading experience
111 | // with/without defer.
112 | const { data } = useSuspenseQuery(APP_QUERY);
113 |
114 | // this slug of the franchise name is used to set the theme name on the parent
115 | // div which corresponds to the theme names in tailwind.config.js
116 | const currentTeamSlug = data?.teams
117 | .find((team) => team.id === data?.team.id)
118 | ?.name.split(" ")
119 | .pop()
120 | ?.toLowerCase();
121 |
122 | return (
123 |
126 |
127 |
Welcome to the W 🏀
128 |
129 |
130 |
136 | WNBA stats central
137 |
138 |
139 |
140 |
141 | );
142 | }
143 |
144 | function TeamSelect({
145 | currentTeam,
146 | teams,
147 | }: {
148 | currentTeam: string;
149 | teams: Array<{ name: string; id: string }>;
150 | }) {
151 | const [setCurrentTeam] = useMutation(APP_MUTATION, {
152 | update(cache, { data: { setCurrentTeam } }) {
153 | cache.modify({
154 | fields: {
155 | team(_existingRef, { toReference }) {
156 | return toReference({
157 | __typename: "Team",
158 | id: setCurrentTeam.id,
159 | });
160 | },
161 | },
162 | });
163 | },
164 | });
165 |
166 | return (
167 |
168 |
171 |
191 |
192 | );
193 | }
194 |
195 | function Team({ team }: { team: string }) {
196 | const { data } = useFragment({
197 | fragment: TEAM_FRAGMENT,
198 | from: {
199 | __typename: "Team",
200 | id: team,
201 | },
202 | });
203 |
204 | return (
205 |
206 |
207 |
208 |
209 | {data ? (
210 | <>
211 |
Wins: {data?.wins}
212 |
Losses: {data?.losses}
213 | >
214 | ) : null}
215 |
216 | );
217 | }
218 |
--------------------------------------------------------------------------------
/src/requestHandler.ts:
--------------------------------------------------------------------------------
1 | import statuses from "@bundled-es-modules/statuses";
2 | import { format } from "outvariant";
3 |
4 | import {
5 | GraphQLHandler,
6 | type GraphQLVariables,
7 | type Match,
8 | type ParsedGraphQLRequest,
9 | } from "msw";
10 |
11 | // These utilities are taken wholesale from MSW, as they're not exported from
12 | // the package, in order to build an augmented version of MSW's
13 | // graphql.operation catchall request handler class.
14 |
15 | // MIT License
16 |
17 | // Copyright (c) 2018–present Artem Zakharchenko
18 |
19 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
20 |
21 | // The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
22 |
23 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
24 |
25 | // From https://github.com/mswjs/msw/blob/63b78315cdbe8435f9e6ec627022d67fa38a9703/src/core/handlers/GraphQLHandler.ts#L35
26 | export type GraphQLRequestParsedResult = {
27 | match: Match;
28 | cookies: Record;
29 | } & (
30 | | ParsedGraphQLRequest
31 | /**
32 | * An empty version of the ParsedGraphQLRequest
33 | * which simplifies the return type of the resolver
34 | * when the request is to a non-matching endpoint
35 | */
36 | | {
37 | operationType?: undefined;
38 | operationName?: undefined;
39 | query?: undefined;
40 | variables?: undefined;
41 | }
42 | );
43 |
44 | export interface LoggedRequest {
45 | url: URL;
46 | method: string;
47 | headers: Record;
48 | body: string;
49 | }
50 |
51 | export interface SerializedResponse {
52 | status: number;
53 | statusText: string;
54 | headers: Record;
55 | body: string;
56 | }
57 |
58 | export async function serializeRequest(
59 | request: Request,
60 | ): Promise {
61 | const requestClone = request.clone();
62 | const requestText = await requestClone.text();
63 |
64 | return {
65 | url: new URL(request.url),
66 | method: request.method,
67 | headers: Object.fromEntries(request.headers.entries()),
68 | body: requestText,
69 | };
70 | }
71 |
72 | const { message } = statuses;
73 |
74 | export async function serializeResponse(
75 | response: Response,
76 | ): Promise {
77 | const responseClone = response.clone();
78 | const responseText = await responseClone.text();
79 |
80 | // Normalize the response status and status text when logging
81 | // since the default Response instance doesn't infer status texts
82 | // from status codes. This has no effect on the actual response instance.
83 | const responseStatus = responseClone.status || 200;
84 | const responseStatusText =
85 | responseClone.statusText || message[responseStatus] || "OK";
86 |
87 | return {
88 | status: responseStatus,
89 | statusText: responseStatusText,
90 | headers: Object.fromEntries(responseClone.headers.entries()),
91 | body: responseText,
92 | };
93 | }
94 |
95 | export function getTimestamp(): string {
96 | const now = new Date();
97 |
98 | return [now.getHours(), now.getMinutes(), now.getSeconds()]
99 | .map(String)
100 | .map((chunk) => chunk.slice(0, 2))
101 | .map((chunk) => chunk.padStart(2, "0"))
102 | .join(":");
103 | }
104 |
105 | export enum StatusCodeColor {
106 | Success = "#69AB32",
107 | Warning = "#F0BB4B",
108 | Danger = "#E95F5D",
109 | }
110 |
111 | /**
112 | * Returns a HEX color for a given response status code number.
113 | */
114 | export function getStatusCodeColor(status: number): StatusCodeColor {
115 | if (status < 300) {
116 | return StatusCodeColor.Success;
117 | }
118 |
119 | if (status < 400) {
120 | return StatusCodeColor.Warning;
121 | }
122 |
123 | return StatusCodeColor.Danger;
124 | }
125 |
126 | const LIBRARY_PREFIX = "[MSW]";
127 |
128 | /**
129 | * Formats a given message by appending the library's prefix string.
130 | */
131 | function formatMessage(message: string, ...positionals: any[]): string {
132 | const interpolatedMessage = format(message, ...positionals);
133 | return `${LIBRARY_PREFIX} ${interpolatedMessage}`;
134 | }
135 |
136 | const devUtils = {
137 | formatMessage,
138 | };
139 |
140 | export class CustomRequestHandler extends GraphQLHandler {
141 | override async log(args: {
142 | request: Request;
143 | response: Response;
144 | parsedResult: GraphQLRequestParsedResult;
145 | }) {
146 | const loggedRequest = await serializeRequest(args.request);
147 | const loggedResponse = await serializeResponse(args.response);
148 | const statusColor = getStatusCodeColor(loggedResponse.status);
149 | const requestInfo = args.parsedResult.operationName
150 | ? `${args.parsedResult.operationType} ${args.parsedResult.operationName}`
151 | : `anonymous ${args.parsedResult.operationType}`;
152 |
153 | console.groupCollapsed(
154 | devUtils.formatMessage(
155 | `${getTimestamp()} ${requestInfo} (%c${loggedResponse.status} ${
156 | loggedResponse.statusText
157 | }%c)`,
158 | ),
159 | `color:${statusColor}`,
160 | "color:inherit",
161 | );
162 | console.log("Request:", loggedRequest);
163 | console.log("Handler:", this);
164 | console.log("Response:", loggedResponse);
165 | console.groupEnd();
166 | }
167 | }
168 |
--------------------------------------------------------------------------------
/.storybook/stories/components/logos/LosAngelesSparks.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/workflows/snapshot-release.yml:
--------------------------------------------------------------------------------
1 | name: Snapshot Release
2 |
3 | on:
4 | issue_comment:
5 | types:
6 | - created
7 |
8 | jobs:
9 | release_next:
10 | name: release:next
11 | runs-on: ubuntu-latest
12 | # Permissions necessary for Changesets to push a new branch and open PRs
13 | # (for automated Version Packages PRs), and request the JWT for provenance.
14 | # More info: https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#adding-permissions-settings
15 | permissions:
16 | contents: write
17 | pull-requests: write
18 | id-token: write
19 | if: |
20 | github.repository == 'apollographql/graphql-testing-library' &&
21 | github.event.issue.pull_request &&
22 | (
23 | github.event.sender.login == 'alessbell' ||
24 | github.event.sender.login == 'bignimbus' ||
25 | github.event.sender.login == 'jerelmiller' ||
26 | github.event.sender.login == 'phryneas'
27 | ) &&
28 | startsWith(github.event.comment.body, '/release:pr')
29 |
30 | steps:
31 | - uses: alessbell/pull-request-comment-branch@v2.1.0
32 | id: comment-branch
33 |
34 | - name: Get sha
35 | id: parse-sha
36 | continue-on-error: true
37 | run: |
38 | if [ "${{ steps.comment-branch.outputs.head_owner }}" == "apollographql" ]; then
39 | echo "sha=${{ steps.comment-branch.outputs.head_sha }}" >> "${GITHUB_OUTPUT}"
40 | else
41 | sha_from_comment="$(echo $COMMENT_BODY | tr -s ' ' | cut -d ' ' -f2)"
42 |
43 | if [ $sha_from_comment == "/release:pr" ]; then
44 | exit 1
45 | else
46 | echo "sha=$sha_from_comment" >> "${GITHUB_OUTPUT}"
47 | fi
48 | fi
49 | env:
50 | COMMENT_BODY: ${{ github.event.comment.body }}
51 |
52 | - name: Comment sha reminder
53 | if: steps.parse-sha.outcome == 'failure'
54 | uses: peter-evans/create-or-update-comment@v4.0.0
55 | with:
56 | issue-number: ${{ github.event.issue.number }}
57 | body: |
58 | Did you forget to add the sha? Please use `/release:pr `
59 |
60 | - name: Fail job
61 | if: steps.parse-sha.outcome == 'failure'
62 | run: |
63 | exit 1
64 |
65 | - name: Checkout ref
66 | uses: actions/checkout@v4
67 | with:
68 | ## specify the owner + repository in order to checkout the fork
69 | ## for community PRs
70 | repository: ${{ steps.comment-branch.outputs.head_owner }}/${{ steps.comment-branch.outputs.head_repo }}
71 | ref: ${{ steps.parse-sha.outputs.sha }}
72 | fetch-depth: 0
73 |
74 | - name: Detect new changesets
75 | id: added-files
76 | run: |
77 | delimiter="$(openssl rand -hex 8)"
78 | echo "changesets<<${delimiter}" >> "${GITHUB_OUTPUT}"
79 | echo "$(git diff --name-only --diff-filter=A ${{ steps.comment-branch.outputs.base_sha }} ${{ steps.parse-sha.outputs.sha }} .changeset/*.md)" >> "${GITHUB_OUTPUT}"
80 | echo "${delimiter}" >> "${GITHUB_OUTPUT}"
81 |
82 | - name: Append NPM token to .npmrc
83 | run: |
84 | cat << EOF > "$HOME/.npmrc"
85 | provenance=true
86 | //registry.npmjs.org/:_authToken=$NPM_TOKEN
87 | EOF
88 | env:
89 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
90 |
91 | - name: Setup Node.js 20.x
92 | uses: actions/setup-node@v4
93 | with:
94 | node-version: 20.x
95 |
96 | - name: Install pnpm and dependencies
97 | uses: pnpm/action-setup@v4
98 | with:
99 | version: 9
100 | run_install: true
101 |
102 | - name: Check for pre.json file existence
103 | id: check_files
104 | uses: andstor/file-existence-action@v3.0.0
105 | with:
106 | files: ".changeset/pre.json"
107 |
108 | - name: Exit pre mode if pre.json exists
109 | # Changesets prevents us from generating a snapshot release
110 | # if we're in prerelease mode, so we remove `pre.json` if it exists
111 | # (but do not commit this change since we want the branch to remain
112 | # in pre mode)
113 | if: steps.check_files.outputs.files_exists == 'true'
114 | run: rm .changeset/pre.json
115 |
116 | - name: Add comment if no new changesets exist
117 | if: ${{ steps.added-files.outputs.changesets == '' }}
118 | uses: peter-evans/create-or-update-comment@v4.0.0
119 | with:
120 | issue-number: ${{ github.event.issue.number }}
121 | body: |
122 | Please add a changeset via `npx changeset` before attempting a snapshot release.
123 |
124 | # https://github.com/atlassian/changesets/blob/master/docs/snapshot-releases.md
125 | - name: Release to pr tag
126 | if: ${{ steps.added-files.outputs.changesets != '' }}
127 | run: |
128 | npx changeset version --snapshot pr-${{ github.event.issue.number }} && pnpm i
129 | pnpm run clean
130 | pnpm run build
131 | pnpm run prepdist:changesets
132 | cd dist
133 | npx changeset publish --no-git-tag --snapshot --tag pr
134 | env:
135 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
136 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
137 |
138 | - name: Get released version
139 | if: ${{ steps.added-files.outputs.changesets != '' }}
140 | id: get-version
141 | run: echo "version=$(node -p "require('./dist/package.json').version")" >> "$GITHUB_OUTPUT"
142 |
143 | - name: Deprecate version
144 | if: ${{ steps.added-files.outputs.changesets != '' }}
145 | run: pnpm deprecate @apollo/graphql-testing-library@${{ steps.get-version.outputs.version }} "This is a snapshot release from https://github.com/apollographql/graphql-testing-library/pull/${{ github.event.issue.number }}. Use at your own discretion."
146 |
147 | - name: Create comment
148 | if: ${{ steps.added-files.outputs.changesets != '' }}
149 | uses: peter-evans/create-or-update-comment@v4.0.0
150 | with:
151 | issue-number: ${{ github.event.issue.number }}
152 | body: |
153 | A new release has been made for this PR. You can install it with:
154 |
155 | ```
156 | npm i @apollo/graphql-testing-library@${{ steps.get-version.outputs.version }}
157 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Run
2 |
3 | on: [push]
4 |
5 | concurrency: ${{ github.workflow }}-${{ github.ref }}
6 |
7 | jobs:
8 | install-and-cache:
9 | name: Install and cache
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - name: Checkout
14 | uses: actions/checkout@v4
15 |
16 | - uses: pnpm/action-setup@v4
17 | name: Install pnpm
18 | with:
19 | version: 9
20 | run_install: false
21 |
22 | - name: Install Node.js
23 | uses: actions/setup-node@v4
24 | with:
25 | node-version: 20
26 | cache: "pnpm"
27 |
28 | - name: Get pnpm store directory
29 | shell: bash
30 | run: |
31 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
32 |
33 | - uses: actions/cache@v4
34 | name: Setup pnpm cache
35 | with:
36 | path: ${{ env.STORE_PATH }}
37 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
38 | restore-keys: |
39 | ${{ runner.os }}-pnpm-store-
40 |
41 | - name: Install dependencies
42 | run: pnpm install
43 |
44 | test-vitest:
45 | name: Vitest tests
46 | if: github.repository == 'apollographql/graphql-testing-library'
47 | runs-on: ubuntu-latest
48 | steps:
49 | - name: Checkout repo
50 | uses: actions/checkout@v4
51 |
52 | - name: Install pnpm
53 | uses: pnpm/action-setup@v4
54 | with:
55 | version: 9
56 | run_install: false
57 |
58 | - name: Install Node.js
59 | uses: actions/setup-node@v4
60 | with:
61 | node-version: 20
62 | cache: "pnpm"
63 |
64 | - name: Install dependencies
65 | run: pnpm install
66 |
67 | - name: Run Vitest tests
68 | run: pnpm run test:vitest
69 |
70 | test-jest:
71 | name: Jest tests
72 | if: github.repository == 'apollographql/graphql-testing-library'
73 | runs-on: ubuntu-latest
74 | steps:
75 | - name: Checkout repo
76 | uses: actions/checkout@v4
77 |
78 | - name: Install pnpm
79 | uses: pnpm/action-setup@v4
80 | with:
81 | version: 9
82 | run_install: false
83 |
84 | - name: Install Node.js
85 | uses: actions/setup-node@v4
86 | with:
87 | node-version: 20
88 | cache: "pnpm"
89 |
90 | - name: Install dependencies
91 | run: pnpm install
92 |
93 | - name: Run Jest tests
94 | run: pnpm run test:jest --coverage | tee ./coverage.txt && exit ${PIPESTATUS[0]}
95 |
96 | - name: Jest Coverage Comment
97 | id: coverageComment
98 | uses: MishaKav/jest-coverage-comment@main
99 | with:
100 | coverage-path: ./coverage.txt
101 | junitxml-path: ./coverage/junit.xml
102 |
103 | - name: Check the output coverage
104 | run: |
105 | echo "Summary Report"
106 | echo "Coverage Percentage - ${{ steps.coverageComment.outputs.coverage }}"
107 | echo "Coverage Color - ${{ steps.coverageComment.outputs.color }}"
108 | echo "Summary Html - ${{ steps.coverageComment.outputs.summaryHtml }}"
109 |
110 | echo "JUnit Report"
111 | echo "tests - ${{ steps.coverageComment.outputs.tests }}"
112 | echo "skipped - ${{ steps.coverageComment.outputs.skipped }}"
113 | echo "failures - ${{ steps.coverageComment.outputs.failures }}"
114 | echo "errors - ${{ steps.coverageComment.outputs.errors }}"
115 | echo "time - ${{ steps.coverageComment.outputs.time }}"
116 |
117 | echo "Coverage Report"
118 | echo "lines - ${{ steps.coverageComment.outputs.lines }}"
119 | echo "branches - ${{ steps.coverageComment.outputs.branches }}"
120 | echo "functions - ${{ steps.coverageComment.outputs.functions }}"
121 | echo "statements - ${{ steps.coverageComment.outputs.statements }}"
122 | echo "coverage - ${{ steps.coverageComment.outputs.coverage }}"
123 | echo "color - ${{ steps.coverageComment.outputs.color }}"
124 | echo "Coverage Html - ${{ steps.coverageComment.outputs.coverageHtml }}"
125 |
126 | - name: Create the badge
127 | if: github.ref == 'refs/heads/main'
128 | uses: schneegans/dynamic-badges-action@v1.7.0
129 | with:
130 | auth: ${{ secrets.GIST_BADGE_SECRET_CLASSIC }}
131 | gistID: 3fd56e82b55e134ee9cf57f28b0b3d49
132 | filename: jest-coverage-comment__main.json
133 | label: coverage
134 | host: https://api.github.com/gists/
135 | message: ${{ steps.coverageComment.outputs.coverage }}%
136 | color: ${{ steps.coverageComment.outputs.color }}
137 | namedLogo: typescript
138 |
139 | test-playwright:
140 | name: Playwright tests
141 | if: github.repository == 'apollographql/graphql-testing-library'
142 | runs-on: ubuntu-latest
143 | steps:
144 | - name: Checkout repo
145 | uses: actions/checkout@v4
146 |
147 | - name: Install pnpm
148 | uses: pnpm/action-setup@v4
149 | with:
150 | version: 9
151 | run_install: false
152 |
153 | - name: Install Node.js
154 | uses: actions/setup-node@v4
155 | with:
156 | node-version: 20
157 | cache: "pnpm"
158 |
159 | - name: Install dependencies
160 | run: pnpm install
161 |
162 | - name: Get installed Playwright version
163 | id: playwright-version
164 | run: echo "PLAYWRIGHT_VERSION=$(node -e "console.log(require('./package.json').devDependencies['@playwright/test'])")" >> $GITHUB_ENV
165 |
166 | - name: Cache Playwright binaries
167 | uses: actions/cache@v4
168 | id: playwright-cache
169 | with:
170 | path: |
171 | ~/.cache/ms-playwright
172 | key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
173 |
174 | - run: npx playwright install --with-deps
175 | if: steps.playwright-cache.outputs.cache-hit != 'true'
176 |
177 | - name: Serve Storybook and run tests
178 | run: pnpm run build-and-test-storybook
179 |
180 | lint:
181 | name: Lint
182 | if: github.repository == 'apollographql/graphql-testing-library'
183 | runs-on: ubuntu-latest
184 | steps:
185 | - name: Checkout repo
186 | uses: actions/checkout@v4
187 |
188 | - name: Install pnpm
189 | uses: pnpm/action-setup@v4
190 | with:
191 | version: 9
192 | run_install: false
193 |
194 | - name: Install Node.js
195 | uses: actions/setup-node@v4
196 | with:
197 | node-version: 20
198 | cache: "pnpm"
199 |
200 | - name: Install dependencies
201 | run: pnpm install
202 |
203 | - name: Lint
204 | run: pnpm run lint
205 |
206 | prettier:
207 | name: Prettier
208 | if: github.repository == 'apollographql/graphql-testing-library'
209 | runs-on: ubuntu-latest
210 | steps:
211 | - name: Checkout repo
212 | uses: actions/checkout@v4
213 |
214 | - name: Install pnpm
215 | uses: pnpm/action-setup@v4
216 | with:
217 | version: 9
218 | run_install: false
219 |
220 | - name: Install Node.js
221 | uses: actions/setup-node@v4
222 | with:
223 | node-version: 20
224 | cache: "pnpm"
225 |
226 | - name: Install dependencies
227 | run: pnpm install
228 |
229 | - name: Lint
230 | run: pnpm run prettier
231 |
232 | type-check:
233 | name: Check types
234 | if: github.repository == 'apollographql/graphql-testing-library'
235 | runs-on: ubuntu-latest
236 | steps:
237 | - name: Checkout repo
238 | uses: actions/checkout@v4
239 |
240 | - name: Install pnpm
241 | uses: pnpm/action-setup@v4
242 | with:
243 | version: 9
244 | run_install: false
245 |
246 | - name: Install Node.js
247 | uses: actions/setup-node@v4
248 | with:
249 | node-version: 20
250 | cache: "pnpm"
251 |
252 | - name: Install dependencies
253 | run: pnpm install
254 |
255 | - name: Check types
256 | run: pnpm run type-check
257 |
--------------------------------------------------------------------------------
/.storybook/public/mockServiceWorker.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /* tslint:disable */
3 |
4 | /**
5 | * Mock Service Worker.
6 | * @see https://github.com/mswjs/msw
7 | * - Please do NOT modify this file.
8 | * - Please do NOT serve this file on production.
9 | */
10 |
11 | const PACKAGE_VERSION = '2.3.5'
12 | const INTEGRITY_CHECKSUM = '26357c79639bfa20d64c0efca2a87423'
13 | const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
14 | const activeClientIds = new Set()
15 |
16 | self.addEventListener('install', function () {
17 | self.skipWaiting()
18 | })
19 |
20 | self.addEventListener('activate', function (event) {
21 | event.waitUntil(self.clients.claim())
22 | })
23 |
24 | self.addEventListener('message', async function (event) {
25 | const clientId = event.source.id
26 |
27 | if (!clientId || !self.clients) {
28 | return
29 | }
30 |
31 | const client = await self.clients.get(clientId)
32 |
33 | if (!client) {
34 | return
35 | }
36 |
37 | const allClients = await self.clients.matchAll({
38 | type: 'window',
39 | })
40 |
41 | switch (event.data) {
42 | case 'KEEPALIVE_REQUEST': {
43 | sendToClient(client, {
44 | type: 'KEEPALIVE_RESPONSE',
45 | })
46 | break
47 | }
48 |
49 | case 'INTEGRITY_CHECK_REQUEST': {
50 | sendToClient(client, {
51 | type: 'INTEGRITY_CHECK_RESPONSE',
52 | payload: {
53 | packageVersion: PACKAGE_VERSION,
54 | checksum: INTEGRITY_CHECKSUM,
55 | },
56 | })
57 | break
58 | }
59 |
60 | case 'MOCK_ACTIVATE': {
61 | activeClientIds.add(clientId)
62 |
63 | sendToClient(client, {
64 | type: 'MOCKING_ENABLED',
65 | payload: true,
66 | })
67 | break
68 | }
69 |
70 | case 'MOCK_DEACTIVATE': {
71 | activeClientIds.delete(clientId)
72 | break
73 | }
74 |
75 | case 'CLIENT_CLOSED': {
76 | activeClientIds.delete(clientId)
77 |
78 | const remainingClients = allClients.filter((client) => {
79 | return client.id !== clientId
80 | })
81 |
82 | // Unregister itself when there are no more clients
83 | if (remainingClients.length === 0) {
84 | self.registration.unregister()
85 | }
86 |
87 | break
88 | }
89 | }
90 | })
91 |
92 | self.addEventListener('fetch', function (event) {
93 | const { request } = event
94 |
95 | // Bypass navigation requests.
96 | if (request.mode === 'navigate') {
97 | return
98 | }
99 |
100 | // Opening the DevTools triggers the "only-if-cached" request
101 | // that cannot be handled by the worker. Bypass such requests.
102 | if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
103 | return
104 | }
105 |
106 | // Bypass all requests when there are no active clients.
107 | // Prevents the self-unregistered worked from handling requests
108 | // after it's been deleted (still remains active until the next reload).
109 | if (activeClientIds.size === 0) {
110 | return
111 | }
112 |
113 | // Generate unique request ID.
114 | const requestId = crypto.randomUUID()
115 | event.respondWith(handleRequest(event, requestId))
116 | })
117 |
118 | async function handleRequest(event, requestId) {
119 | const client = await resolveMainClient(event)
120 | const response = await getResponse(event, client, requestId)
121 |
122 | // Send back the response clone for the "response:*" life-cycle events.
123 | // Ensure MSW is active and ready to handle the message, otherwise
124 | // this message will pend indefinitely.
125 | if (client && activeClientIds.has(client.id)) {
126 | ;(async function () {
127 | const responseClone = response.clone()
128 |
129 | sendToClient(
130 | client,
131 | {
132 | type: 'RESPONSE',
133 | payload: {
134 | requestId,
135 | isMockedResponse: IS_MOCKED_RESPONSE in response,
136 | type: responseClone.type,
137 | status: responseClone.status,
138 | statusText: responseClone.statusText,
139 | body: responseClone.body,
140 | headers: Object.fromEntries(responseClone.headers.entries()),
141 | },
142 | },
143 | [responseClone.body],
144 | )
145 | })()
146 | }
147 |
148 | return response
149 | }
150 |
151 | // Resolve the main client for the given event.
152 | // Client that issues a request doesn't necessarily equal the client
153 | // that registered the worker. It's with the latter the worker should
154 | // communicate with during the response resolving phase.
155 | async function resolveMainClient(event) {
156 | const client = await self.clients.get(event.clientId)
157 |
158 | if (client?.frameType === 'top-level') {
159 | return client
160 | }
161 |
162 | const allClients = await self.clients.matchAll({
163 | type: 'window',
164 | })
165 |
166 | return allClients
167 | .filter((client) => {
168 | // Get only those clients that are currently visible.
169 | return client.visibilityState === 'visible'
170 | })
171 | .find((client) => {
172 | // Find the client ID that's recorded in the
173 | // set of clients that have registered the worker.
174 | return activeClientIds.has(client.id)
175 | })
176 | }
177 |
178 | async function getResponse(event, client, requestId) {
179 | const { request } = event
180 |
181 | // Clone the request because it might've been already used
182 | // (i.e. its body has been read and sent to the client).
183 | const requestClone = request.clone()
184 |
185 | function passthrough() {
186 | const headers = Object.fromEntries(requestClone.headers.entries())
187 |
188 | // Remove internal MSW request header so the passthrough request
189 | // complies with any potential CORS preflight checks on the server.
190 | // Some servers forbid unknown request headers.
191 | delete headers['x-msw-intention']
192 |
193 | return fetch(requestClone, { headers })
194 | }
195 |
196 | // Bypass mocking when the client is not active.
197 | if (!client) {
198 | return passthrough()
199 | }
200 |
201 | // Bypass initial page load requests (i.e. static assets).
202 | // The absence of the immediate/parent client in the map of the active clients
203 | // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
204 | // and is not ready to handle requests.
205 | if (!activeClientIds.has(client.id)) {
206 | return passthrough()
207 | }
208 |
209 | // Notify the client that a request has been intercepted.
210 | const requestBuffer = await request.arrayBuffer()
211 | const clientMessage = await sendToClient(
212 | client,
213 | {
214 | type: 'REQUEST',
215 | payload: {
216 | id: requestId,
217 | url: request.url,
218 | mode: request.mode,
219 | method: request.method,
220 | headers: Object.fromEntries(request.headers.entries()),
221 | cache: request.cache,
222 | credentials: request.credentials,
223 | destination: request.destination,
224 | integrity: request.integrity,
225 | redirect: request.redirect,
226 | referrer: request.referrer,
227 | referrerPolicy: request.referrerPolicy,
228 | body: requestBuffer,
229 | keepalive: request.keepalive,
230 | },
231 | },
232 | [requestBuffer],
233 | )
234 |
235 | switch (clientMessage.type) {
236 | case 'MOCK_RESPONSE': {
237 | return respondWithMock(clientMessage.data)
238 | }
239 |
240 | case 'PASSTHROUGH': {
241 | return passthrough()
242 | }
243 | }
244 |
245 | return passthrough()
246 | }
247 |
248 | function sendToClient(client, message, transferrables = []) {
249 | return new Promise((resolve, reject) => {
250 | const channel = new MessageChannel()
251 |
252 | channel.port1.onmessage = (event) => {
253 | if (event.data && event.data.error) {
254 | return reject(event.data.error)
255 | }
256 |
257 | resolve(event.data)
258 | }
259 |
260 | client.postMessage(
261 | message,
262 | [channel.port2].concat(transferrables.filter(Boolean)),
263 | )
264 | })
265 | }
266 |
267 | async function respondWithMock(response) {
268 | // Setting response status code to 0 is a no-op.
269 | // However, when responding with a "Response.error()", the produced Response
270 | // instance will have status code set to 0. Since it's not possible to create
271 | // a Response instance with status code 0, handle that use-case separately.
272 | if (response.status === 0) {
273 | return Response.error()
274 | }
275 |
276 | const mockedResponse = new Response(response.body, response)
277 |
278 | Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
279 | value: true,
280 | enumerable: true,
281 | })
282 |
283 | return mockedResponse
284 | }
285 |
--------------------------------------------------------------------------------
/.storybook/stories/components/logos/ChicagoSky.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/handlers.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "graphql-tag";
2 | import { execute } from "@graphql-tools/executor";
3 | import { isNodeProcess } from "is-node-process";
4 | import type { GraphQLSchema, DocumentNode } from "graphql";
5 | import { HttpResponse, delay as mswDelay, type ResponseResolver } from "msw";
6 | import type {
7 | InitialIncrementalExecutionResult,
8 | SingularExecutionResult,
9 | SubsequentIncrementalExecutionResult,
10 | } from "@graphql-tools/executor";
11 | import {
12 | addResolversToSchema,
13 | makeExecutableSchema,
14 | } from "@graphql-tools/schema";
15 | import {
16 | addMocksToSchema,
17 | type IMocks,
18 | type IMockStore,
19 | } from "@graphql-tools/mock";
20 | import { mergeResolvers } from "@graphql-tools/merge";
21 | import {
22 | createDefaultResolvers,
23 | createPossibleTypesMap,
24 | mockCustomScalars,
25 | hasDirectives,
26 | generateEnumMocksFromSchema,
27 | } from "./utilities.ts";
28 | import type { IResolvers, Maybe } from "@graphql-tools/utils";
29 | import { CustomRequestHandler } from "./requestHandler.ts";
30 |
31 | const encoder = new TextEncoder();
32 |
33 | type Delay = number | "infinite" | "real";
34 |
35 | interface DelayOptions {
36 | delay?: Delay;
37 | }
38 |
39 | type DocumentResolversWithOptions = {
40 | typeDefs: DocumentNode;
41 | resolvers: Resolvers;
42 | mocks?: IMocks;
43 | } & DelayOptions;
44 |
45 | type Resolvers = TResolvers | ((store: IMockStore) => TResolvers);
46 |
47 | type SchemaWithOptions = {
48 | schema: GraphQLSchema;
49 | resolvers?: Resolvers;
50 | } & DelayOptions;
51 |
52 | function createHandler(
53 | documentResolversWithOptions: DocumentResolversWithOptions,
54 | ) {
55 | const { resolvers, typeDefs, mocks, ...rest } = documentResolversWithOptions;
56 |
57 | const schemaWithMocks = createSchemaWithDefaultMocks(
58 | typeDefs,
59 | resolvers,
60 | mocks,
61 | );
62 |
63 | return createHandlerFromSchema({
64 | schema: schemaWithMocks,
65 | ...rest,
66 | });
67 | }
68 |
69 | function createSchemaWithDefaultMocks(
70 | typeDefs: DocumentNode,
71 | resolvers?: TResolvers | ((store: IMockStore) => TResolvers),
72 | mocks?: IMocks,
73 | ) {
74 | const executableSchema = makeExecutableSchema({ typeDefs });
75 | const enumMocks = generateEnumMocksFromSchema(executableSchema);
76 | const customScalarMocks = mockCustomScalars(executableSchema);
77 | const typesMap = createPossibleTypesMap(executableSchema);
78 | const defaultResolvers = createDefaultResolvers(typesMap);
79 |
80 | return addMocksToSchema({
81 | schema: executableSchema,
82 | mocks: {
83 | ...enumMocks,
84 | ...customScalarMocks,
85 | ...mocks,
86 | } as IMocks,
87 | resolvers: mergeResolvers([
88 | defaultResolvers,
89 | (resolvers ?? {}) as Maybe<
90 | IResolvers<{ __typename?: string | undefined }, unknown>
91 | >,
92 | ]) as TResolvers,
93 | preserveResolvers: true,
94 | });
95 | }
96 |
97 | function createHandlerFromSchema(
98 | schemaWithOptions: SchemaWithOptions,
99 | ) {
100 | const { schema, delay } = schemaWithOptions;
101 |
102 | let _delay = delay ?? "real";
103 | // The default node server response time in MSW's delay utility is 5ms.
104 | // See https://github.com/mswjs/msw/blob/main/src/core/delay.ts#L16
105 | // This sometimes caused multipart responses to be batched into a single
106 | // render by React, so we'll use a longer delay of 20ms.
107 | if (_delay === "real" && isNodeProcess()) {
108 | _delay = 20;
109 | }
110 |
111 | let testSchema: GraphQLSchema = schema;
112 |
113 | function replaceSchema(newSchema: GraphQLSchema) {
114 | const oldSchema = testSchema;
115 |
116 | testSchema = newSchema;
117 |
118 | function restore() {
119 | testSchema = oldSchema;
120 | }
121 |
122 | return Object.assign(restore, {
123 | [Symbol.dispose]() {
124 | restore();
125 | },
126 | });
127 | }
128 |
129 | function withResolvers(resolvers: Resolvers) {
130 | const oldSchema = testSchema;
131 |
132 | testSchema = addResolversToSchema({
133 | schema: oldSchema,
134 | // @ts-expect-error reconcile mock resolver types
135 | resolvers,
136 | });
137 |
138 | function restore() {
139 | testSchema = oldSchema;
140 | }
141 |
142 | return Object.assign(restore, {
143 | [Symbol.dispose]() {
144 | restore();
145 | },
146 | });
147 | }
148 |
149 | function withMocks(mocks: IMocks) {
150 | const oldSchema = testSchema;
151 |
152 | testSchema = addMocksToSchema({
153 | schema: oldSchema,
154 | mocks: mocks,
155 | });
156 |
157 | function restore() {
158 | testSchema = oldSchema;
159 | }
160 |
161 | return Object.assign(restore, {
162 | [Symbol.dispose]() {
163 | restore();
164 | },
165 | });
166 | }
167 |
168 | function replaceDelay(newDelay: Delay) {
169 | const oldDelay = _delay;
170 | _delay = newDelay;
171 |
172 | function restore() {
173 | _delay = oldDelay;
174 | }
175 |
176 | return Object.assign(restore, {
177 | [Symbol.dispose]() {
178 | restore();
179 | },
180 | });
181 | }
182 |
183 | Object.defineProperty(replaceDelay, "currentDelay", {
184 | get() {
185 | return _delay;
186 | },
187 | });
188 |
189 | const boundaryStr = "-";
190 | const contentType = "Content-Type: application/json";
191 | const boundary = `--${boundaryStr}`;
192 | const terminatingBoundary = `--${boundaryStr}--`;
193 | const CRLF = "\r\n";
194 |
195 | function createChunkArray(
196 | value:
197 | | InitialIncrementalExecutionResult>
198 | | SubsequentIncrementalExecutionResult>,
199 | ) {
200 | return [
201 | CRLF,
202 | boundary,
203 | CRLF,
204 | contentType,
205 | CRLF,
206 | CRLF,
207 | JSON.stringify(value),
208 | ];
209 | }
210 |
211 | const requestHandler = createCustomRequestHandler();
212 |
213 | return Object.assign(
214 | requestHandler(async ({ query, variables, operationName }) => {
215 | const document = gql(query as string);
216 | const hasDeferOrStream = hasDirectives(["defer", "stream"], document);
217 |
218 | if (hasDeferOrStream) {
219 | const result = await execute({
220 | document,
221 | operationName: operationName as string,
222 | schema: testSchema,
223 | variableValues: variables,
224 | });
225 |
226 | const chunks: Array = [];
227 |
228 | if ("initialResult" in result) {
229 | chunks.push(...createChunkArray(result.initialResult));
230 | }
231 |
232 | let finished = false;
233 | if ("subsequentResults" in result) {
234 | while (!finished) {
235 | const nextResult = await result.subsequentResults.next();
236 |
237 | if (nextResult.value) {
238 | const currentResult = createChunkArray(nextResult.value);
239 |
240 | if (nextResult.value && !nextResult.value.hasNext) {
241 | finished = true;
242 | currentResult.push(CRLF, terminatingBoundary, CRLF);
243 | }
244 |
245 | chunks.push(...currentResult);
246 | }
247 | }
248 | }
249 |
250 | const stream = new ReadableStream({
251 | async start(controller) {
252 | try {
253 | for (const chunk of chunks) {
254 | if (
255 | ![CRLF, contentType, terminatingBoundary, boundary].includes(
256 | chunk,
257 | )
258 | ) {
259 | await mswDelay(_delay);
260 | }
261 | controller.enqueue(encoder.encode(chunk));
262 | }
263 | } finally {
264 | controller.close();
265 | }
266 | },
267 | });
268 |
269 | return new HttpResponse(stream, {
270 | headers: {
271 | "Content-Type": "multipart/mixed",
272 | },
273 | });
274 | } else {
275 | const result = await execute({
276 | document,
277 | operationName: operationName as string,
278 | schema: testSchema,
279 | variableValues: variables,
280 | });
281 |
282 | await mswDelay(_delay);
283 |
284 | return HttpResponse.json(result as SingularExecutionResult);
285 | }
286 | }),
287 | {
288 | replaceSchema,
289 | replaceDelay,
290 | withResolvers,
291 | withMocks,
292 | },
293 | );
294 | }
295 |
296 | const createCustomRequestHandler = () => {
297 | return (resolver: ResponseResolver) =>
298 | new CustomRequestHandler("all", new RegExp(".*"), "*", resolver);
299 | };
300 |
301 | export { createHandler, createHandlerFromSchema, createSchemaWithDefaultMocks };
302 |
--------------------------------------------------------------------------------
/.storybook/stories/components/logos/MinnesotaLynx.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/.storybook/stories/components/logos/ConnecticutSun.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/__tests__/handlers.test.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from "react";
2 | import {
3 | ApolloProvider,
4 | gql,
5 | useSuspenseQuery,
6 | type TypedDocumentNode,
7 | } from "@apollo/client";
8 | import { render, screen, waitFor } from "@testing-library/react";
9 | import {
10 | App,
11 | AppWithDefer,
12 | makeClient,
13 | } from "../../.storybook/stories/components/apollo-client/EcommerceExample.tsx";
14 | import { App as WNBAApp } from "../../.storybook/stories/components/apollo-client/WNBAExample.tsx";
15 | import { ecommerceHandler, products } from "./mocks/handlers.js";
16 | import { createSchemaWithDefaultMocks } from "../handlers.ts";
17 | import githubTypeDefs from "../../.storybook/stories/schemas/github.graphql";
18 | import wnbaTypeDefs from "../../.storybook/stories/schemas/wnba.graphql";
19 | import type { Resolvers } from "../__generated__/resolvers-types-github.ts";
20 |
21 | describe("integration tests", () => {
22 | describe("single execution result response", () => {
23 | it("intercepts and resolves with a single payload", async () => {
24 | const client = makeClient();
25 |
26 | render(
27 |
28 | Loading...}>
29 |
30 |
31 | ,
32 | );
33 |
34 | // The app kicks off the request and we see the initial loading indicator...
35 | await waitFor(() =>
36 | expect(
37 | screen.getByRole("heading", { name: /loading/i }),
38 | ).toHaveTextContent("Loading..."),
39 | );
40 |
41 | await waitFor(() =>
42 | expect(
43 | screen.getByRole("heading", { name: /customers/i }),
44 | ).toHaveTextContent("Customers also purchased"),
45 | );
46 |
47 | expect(screen.getByText(/beanie/i)).toBeInTheDocument();
48 | expect(screen.getAllByTestId(/rating/i)[0]).not.toHaveTextContent("-");
49 | });
50 | });
51 | describe("multipart response", () => {
52 | it("uses the initial mock schema set in the handler by default", async () => {
53 | const client = makeClient();
54 |
55 | render(
56 |
57 | Loading...}>
58 |
59 |
60 | ,
61 | );
62 |
63 | // The app kicks off the request and we see the initial loading indicator...
64 | await waitFor(() =>
65 | expect(
66 | screen.getByRole("heading", { name: /loading/i }),
67 | ).toHaveTextContent("Loading..."),
68 | );
69 |
70 | await waitFor(() =>
71 | expect(
72 | screen.getByRole("heading", { name: /customers/i }),
73 | ).toHaveTextContent("Customers also purchased"),
74 | );
75 |
76 | // The default "real" delay in a Node process is 20ms to avoid render
77 | // batching
78 | // Once the screen unsuspends, we have rendered the first chunk
79 | expect(screen.getAllByTestId(/rating/i)[0]).toHaveTextContent("-");
80 | expect(screen.getByText(/beanie/i)).toBeInTheDocument();
81 |
82 | await waitFor(() => {
83 | expect(screen.getAllByTestId(/rating/i)[0]).not.toHaveTextContent("-");
84 | });
85 | });
86 | it("can set a new schema via replaceSchema", async () => {
87 | using _restore = ecommerceHandler.withResolvers({
88 | Query: {
89 | products: () => {
90 | return Array.from({ length: 6 }, (_element, id) => ({
91 | id: `${id}`,
92 | title: `Foo bar ${id}`,
93 | mediaUrl: `https://storage.googleapis.com/hack-the-supergraph/apollo-${products[id]}.jpg`,
94 | reviews: [
95 | {
96 | id: `review-${id}`,
97 | rating: id,
98 | },
99 | ],
100 | }));
101 | },
102 | },
103 | });
104 |
105 | const client = makeClient();
106 |
107 | render(
108 |
109 | Loading...}>
110 |
111 |
112 | ,
113 | );
114 |
115 | // The app kicks off the request and we see the initial loading indicator...
116 | await waitFor(() =>
117 | expect(
118 | screen.getByRole("heading", { name: /loading/i }),
119 | ).toHaveTextContent("Loading..."),
120 | );
121 |
122 | // The default "real" delay in a Node process is 20ms to avoid batching
123 | // Once the screen unsuspends, we have rendered the first chunk
124 | await waitFor(() =>
125 | expect(
126 | screen.getByRole("heading", { name: /customers/i }),
127 | ).toHaveTextContent("Customers also purchased"),
128 | );
129 |
130 | // expect(screen.getAllByTestId(/rating/i)[0]).toHaveTextContent("-");
131 | expect(screen.getByText(/foo bar 1/i)).toBeInTheDocument();
132 |
133 | await waitFor(() => {
134 | // This "5/5" review value was set when we called `replaceSchema` with a
135 | // new executable schema in this test
136 | expect(screen.getByText(/5\/5/i)).toBeInTheDocument();
137 | });
138 | });
139 | it("can set a new delay via replaceDelay", async () => {
140 | // In this test we set a new delay value via `replaceDelay` which is used to
141 | // simulate network latency
142 | // Usually, in Jest tests we want this to be 20ms (the default in Node
143 | // processes) so renders are *not* auto-batched, but in certain tests we may
144 | // want a shorter or longer delay before chunks or entire responses resolve
145 | using _restore = ecommerceHandler.replaceDelay(1);
146 |
147 | const client = makeClient();
148 |
149 | render(
150 |
151 | Loading...}>
152 |
153 |
154 | ,
155 | );
156 |
157 | // The app kicks off the request and we see the initial loading indicator...
158 | await waitFor(() =>
159 | expect(
160 | screen.getByRole("heading", { name: /loading/i }),
161 | ).toHaveTextContent("Loading..."),
162 | );
163 |
164 | await waitFor(() =>
165 | expect(
166 | screen.getByRole("heading", { name: /customers/i }),
167 | ).toHaveTextContent("Customers also purchased"),
168 | );
169 |
170 | // Since our renders are batched, we will see the final review value
171 | // in the initial render, since renders have been batched
172 | // TODO: investigate flakiness when running with Vite that prompted
173 | // it to be wrapped in waitFor when it shouldn't need to be
174 | // await waitFor(() => {
175 | // expect(screen.getAllByTestId(/rating/i)[0]).toHaveTextContent("0/5");
176 | // });
177 | expect(screen.getByText(/beanie/i)).toBeInTheDocument();
178 | });
179 | });
180 | describe("mutations", () => {
181 | it("uses the initial mock schema", async () => {
182 | const schemaWithMocks = createSchemaWithDefaultMocks(wnbaTypeDefs, {
183 | Query: {
184 | team: () => {
185 | return {
186 | id: "1",
187 | name: "New York Liberty",
188 | };
189 | },
190 | teams: () => {
191 | return [
192 | {
193 | id: "1",
194 | name: "New York Liberty",
195 | },
196 | {
197 | id: "2",
198 | name: "Las Vegas Aces",
199 | },
200 | ];
201 | },
202 | },
203 | });
204 |
205 | using _restore = ecommerceHandler.replaceSchema(schemaWithMocks);
206 | const client = makeClient();
207 |
208 | render(
209 |
210 | Loading...}>
211 |
212 |
213 | ,
214 | );
215 |
216 | // The app kicks off the request and we see the initial loading indicator...
217 | await waitFor(() =>
218 | expect(
219 | screen.getByRole("heading", { name: /loading/i }),
220 | ).toHaveTextContent("Loading..."),
221 | );
222 |
223 | await waitFor(() =>
224 | expect(screen.getByText("New York Liberty")).toBeInTheDocument(),
225 | );
226 | });
227 | });
228 | });
229 |
230 | describe("integration tests with github schema", () => {
231 | it("renders a component fetching from the GitHub api", async () => {
232 | const client = makeClient();
233 |
234 | const schemaWithMocks = createSchemaWithDefaultMocks(
235 | githubTypeDefs,
236 | {
237 | IssueConnection: {
238 | // @ts-expect-error TODO: improve types to accept a deep partial of
239 | // whatever the resolver type returns here
240 | edges: (_parent, _args, _context, info) => {
241 | return Array(parseInt(info.variableValues.last as string))
242 | .fill(null)
243 | .map((_item, idx) => ({
244 | cursor: "2",
245 | node: {
246 | title: `Some issue ${idx}`,
247 | url: `https://github.com/foo-bar/issues/${idx}`,
248 | id: `${idx}`,
249 | },
250 | }));
251 | },
252 | },
253 | },
254 | );
255 |
256 | using _restore = ecommerceHandler.replaceSchema(schemaWithMocks);
257 |
258 | const APP_QUERY: TypedDocumentNode<{
259 | repository: {
260 | issues: {
261 | edges: {
262 | node: {
263 | id: string;
264 | title: string;
265 | url: string;
266 | author: { login: string };
267 | };
268 | }[];
269 | };
270 | };
271 | }> = gql`
272 | query AppQuery($owner: String, $name: String, $last: String) {
273 | repository(owner: $owner, name: $name) {
274 | issues(last: $last, states: CLOSED) {
275 | edges {
276 | node {
277 | id
278 | title
279 | url
280 | author {
281 | login
282 | }
283 | }
284 | }
285 | }
286 | }
287 | }
288 | `;
289 |
290 | const Shell = () => {
291 | return (
292 |
293 | Loading...}>
294 |
295 |
296 |
297 | );
298 | };
299 |
300 | const App = () => {
301 | const { data } = useSuspenseQuery(APP_QUERY, {
302 | variables: { owner: "octocat", name: "Hello World", last: "5" },
303 | });
304 |
305 | if (!data) return null;
306 |
307 | return (
308 |
309 | {data.repository.issues.edges.map((item) => (
310 | - {item.node.url}
311 | ))}
312 |
313 | );
314 | };
315 |
316 | render();
317 |
318 | await waitFor(() =>
319 | expect(
320 | screen.getByText("https://github.com/foo-bar/issues/0"),
321 | ).toBeInTheDocument(),
322 | );
323 |
324 | [1, 2, 3, 4].forEach((num) => {
325 | expect(
326 | screen.getByText(`https://github.com/foo-bar/issues/${num}`),
327 | ).toBeInTheDocument();
328 | });
329 | });
330 | });
331 |
332 | describe("unit tests", () => {
333 | it("can roll back delay via disposable", () => {
334 | function innerFn() {
335 | using _restore = ecommerceHandler.replaceDelay(250);
336 | // @ts-expect-error intentionally accessing a property that has been
337 | // excluded from the type
338 | expect(ecommerceHandler.replaceDelay["currentDelay"]).toBe(250);
339 | }
340 |
341 | innerFn();
342 |
343 | // @ts-expect-error intentionally accessing a property that has been
344 | // excluded from the type
345 | expect(ecommerceHandler.replaceDelay["currentDelay"]).toBe(20);
346 | });
347 | });
348 |
--------------------------------------------------------------------------------
/.storybook/stories/components/logos/NewYorkLiberty.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.storybook/stories/components/logos/IndianaFever.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------