├── .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 | [![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build) 4 | 5 | ``` 6 | npm create astro@latest -- --template starlight 7 | ``` 8 | 9 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/starlight/tree/main/examples/basics) 10 | [![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/starlight/tree/main/examples/basics) 11 | [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/withastro/starlight&create_from_path=examples/basics) 12 | [![Deploy with Vercel](https://vercel.com/button)](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 | {/* Apollo Client */} 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 | [![npm version](https://badge.fury.io/js/%40apollo%2Fgraphql-testing-library.svg)](https://badge.fury.io/js/%40apollo%2Fgraphql-testing-library) ![badge](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/alessbell/3fd56e82b55e134ee9cf57f28b0b3d49/raw/jest-coverage-comment__main.json) ![workflow](https://github.com/apollographql/graphql-testing-library/actions/workflows/test.yml/badge.svg) 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 | Los Angeles Sparks logo -------------------------------------------------------------------------------- /.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 | Connecticut Sun logo -------------------------------------------------------------------------------- /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 | New York Liberty logo -------------------------------------------------------------------------------- /.storybook/stories/components/logos/IndianaFever.svg: -------------------------------------------------------------------------------- 1 | 2 | --------------------------------------------------------------------------------