├── .github └── workflows │ └── main.yml ├── .gitignore ├── .yarn └── install-state.gz ├── .yarnrc.yml ├── LICENSE ├── README.md ├── babel.config.js ├── introspection.schema.graphql ├── jest.config.js ├── mockServiceWorker.js ├── package.json ├── src ├── createMockServer.test.ts ├── createMockServer.ts ├── index.test.ts ├── index.ts ├── types.ts └── utils │ ├── createMockHandler.ts │ ├── mergeMocks.ts │ └── setupWorker.ts ├── tsconfig.json ├── tsconfig.lint.json └── yarn.lock /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} 6 | 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | node: ['12.x', '14.x'] 11 | os: [ubuntu-latest, windows-latest, macOS-latest] 12 | 13 | steps: 14 | - name: Checkout repo 15 | uses: actions/checkout@v2 16 | 17 | - name: Use Node ${{ matrix.node }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node }} 21 | 22 | - name: Install deps and build (with cache) 23 | uses: bahmutov/npm-install@v1 24 | 25 | - name: Lint 26 | run: yarn lint 27 | 28 | - name: Test 29 | run: yarn test --ci 30 | 31 | - name: Build 32 | run: yarn build 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | dist 5 | .cache -------------------------------------------------------------------------------- /.yarn/install-state.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/warrenday/graphql-mock-network/54aa62989225862b84bc96795aec0744681dc7a0/.yarn/install-state.gz -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 GraphQL Mock Network Authors 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GraphQL Mock Network 2 | 3 | Simple network mocking for your GraphQL API. 4 | 5 | ## Problem 6 | 7 | Mocking network requests usually requires monkey patching or replacing native APIs such as Fetch or XHR. This is problematic as it is not realistic to your production application. With GraphQL Mock Network you can write tests with more confidence by mocking via service workers. Eliminating differences between test and production. 8 | 9 | Internally we use [MSW](https://github.com/mswjs/msw) for the heavy lifting in applying these mocks. This means you can even see the mocked requests in the network tab! 10 | 11 | ## Features 12 | 13 | By combining a graphql mock server with MSW you can achieve simple auto-mocking of your entire GraphQL schema at the network level. With the option of applying specific mocks on a per-test basis. 14 | 15 | GraphQL Mock Network provides: 16 | 17 | - Network level mocking 18 | - Auto-mocking against a graphql schema 19 | - Manual mocking for individual tests 20 | 21 | ## Installation 22 | 23 | `npm i graphql-mock-network` 24 | 25 | ## Usage Example 26 | 27 | ```ts 28 | import { MockNetwork } from 'graphql-mock-network'; 29 | import schema from './introspection.schema.graphql'; 30 | 31 | const mockNetwork = new MockNetwork({ 32 | schema, 33 | mocks: { 34 | Query: { 35 | todo: () => ({ 36 | id: 'xyz', 37 | title: 'I am a manually mocked todo!', 38 | }), 39 | }, 40 | }, 41 | }); 42 | 43 | mockNetwork.start(); 44 | 45 | // Now when making a network request. The data will be mocked. 46 | 47 | const res = await axios.post( 48 | '/graphql', 49 | { 50 | query: ` 51 | query todo($id: ID!) { 52 | todo(id: $id) { 53 | id 54 | title 55 | } 56 | } 57 | `, 58 | variables: { 59 | id: 1, 60 | }, 61 | }, 62 | { 63 | headers: { 'Content-Type': 'application/json' }, 64 | } 65 | ); 66 | 67 | console.log(res.data); 68 | 69 | // { 70 | // data: { 71 | // todo: { 72 | // id: 'xyz', 73 | // title: 'I am a manually mocked todo!', 74 | // }, 75 | // } 76 | // } 77 | ``` 78 | 79 | ## Browser Usage (Important) 80 | 81 | If you are running the mock api in browser a service worker is required given Mock Service Worker is used internally. You can read more over at the [MSW browser integrate guide](https://mswjs.io/docs/getting-started/integrate/browser). 82 | 83 | TL;DR you can copy the file [./mockServiceWorker.js](./mockServiceWorker.js) to be served from the root directory of your site. i.e. so it can be accessed in browser at `http://{{yourdomain}}/mockServiceWorker.js`. 84 | 85 | ## Documentation 86 | 87 | ### MockNetwork 88 | 89 | Create a new instance of MockNetwork which takes your graphql schema and some initial mocks. 90 | 91 | ```ts 92 | const mockNetwork = new MockNetwork({ 93 | schema, 94 | mocks: { 95 | Query: { 96 | todo: () => ({ 97 | id: 'xyz', 98 | title: 'I am a manually mocked todo!', 99 | }), 100 | }, 101 | }, 102 | }); 103 | ``` 104 | 105 | ### start 106 | 107 | Start the server to ensure all graphql requests are listened for. 108 | 109 | ```ts 110 | mockNetwork.start(); 111 | ``` 112 | 113 | ### stop 114 | 115 | Stop the server to prevent all mocking. Allows requests to continue on as normal. 116 | 117 | ```ts 118 | mockNetwork.stop(); 119 | ``` 120 | 121 | ### addMocks 122 | 123 | Add or replace a new or existing mock. 124 | 125 | ```ts 126 | mockNetwork.addMocks({ 127 | Query: { 128 | photo: () => ({ 129 | id: 'abc', 130 | title: 'I am a manually mocked photo!', 131 | }), 132 | }, 133 | }); 134 | ``` 135 | 136 | ### resetMocks 137 | 138 | Reset mocks back to the state when the `mockNetwork` was instantiated. 139 | 140 | ```ts 141 | mockNetwork.resetMocks(); 142 | ``` 143 | 144 | ## Licence 145 | 146 | The MIT License (MIT) 147 | 148 | Copyright (c) 2020 GraphQL Mock Network Authors 149 | 150 | 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: 151 | 152 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 153 | 154 | 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. 155 | ` 156 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | // babel.config.js 2 | module.exports = { 3 | presets: [ 4 | [ 5 | '@babel/preset-env', 6 | { 7 | targets: 8 | process.env.TARGET === 'web' ? { chrome: '86' } : { node: 'current' }, 9 | }, 10 | ], 11 | '@babel/preset-typescript', 12 | ], 13 | plugins: [ 14 | 'transform-inline-environment-variables', 15 | 'minify-dead-code-elimination', 16 | '@babel/plugin-proposal-class-properties', 17 | ], 18 | }; 19 | 20 | // TODO add different target depending on process.env.TARGET 21 | -------------------------------------------------------------------------------- /introspection.schema.graphql: -------------------------------------------------------------------------------- 1 | type Address { 2 | street: String 3 | suite: String 4 | city: String 5 | zipcode: String 6 | geo: Geo 7 | } 8 | 9 | input AddressInput { 10 | street: String 11 | suite: String 12 | city: String 13 | zipcode: String 14 | geo: GeoInput 15 | } 16 | 17 | type Album { 18 | id: ID 19 | title: String 20 | user: User 21 | photos(options: PageQueryOptions): PhotosPage 22 | } 23 | 24 | type AlbumsPage { 25 | data: [Album] 26 | links: PaginationLinks 27 | meta: PageMetadata 28 | } 29 | 30 | enum CacheControlScope { 31 | PUBLIC 32 | PRIVATE 33 | } 34 | 35 | type Comment { 36 | id: ID 37 | name: String 38 | email: String 39 | body: String 40 | post: Post 41 | } 42 | 43 | type CommentsPage { 44 | data: [Comment] 45 | links: PaginationLinks 46 | meta: PageMetadata 47 | } 48 | 49 | type Company { 50 | name: String 51 | catchPhrase: String 52 | bs: String 53 | } 54 | 55 | input CompanyInput { 56 | name: String 57 | catchPhrase: String 58 | bs: String 59 | } 60 | 61 | input CreateAlbumInput { 62 | title: String! 63 | userId: ID! 64 | } 65 | 66 | input CreateCommentInput { 67 | name: String! 68 | email: String! 69 | body: String! 70 | } 71 | 72 | input CreatePhotoInput { 73 | title: String! 74 | url: String! 75 | thumbnailUrl: String! 76 | } 77 | 78 | input CreatePostInput { 79 | title: String! 80 | body: String! 81 | } 82 | 83 | input CreateTodoInput { 84 | title: String! 85 | completed: Boolean! 86 | } 87 | 88 | input CreateUserInput { 89 | name: String! 90 | username: String! 91 | email: String! 92 | address: AddressInput 93 | phone: String 94 | website: String 95 | company: CompanyInput 96 | } 97 | 98 | type Geo { 99 | lat: Float 100 | lng: Float 101 | } 102 | 103 | input GeoInput { 104 | lat: Float 105 | lng: Float 106 | } 107 | 108 | type Mutation { 109 | _: Int 110 | createAlbum(input: CreateAlbumInput!): Album 111 | updateAlbum(id: ID!, input: UpdateAlbumInput!): Album 112 | deleteAlbum(id: ID!): Boolean 113 | createComment(input: CreateCommentInput!): Comment 114 | updateComment(id: ID!, input: UpdateCommentInput!): Comment 115 | deleteComment(id: ID!): Boolean 116 | createPhoto(input: CreatePhotoInput!): Photo 117 | updatePhoto(id: ID!, input: UpdatePhotoInput!): Photo 118 | deletePhoto(id: ID!): Boolean 119 | createPost(input: CreatePostInput!): Post 120 | updatePost(id: ID!, input: UpdatePostInput!): Post 121 | deletePost(id: ID!): Boolean 122 | createTodo(input: CreateTodoInput!): Todo 123 | updateTodo(id: ID!, input: UpdateTodoInput!): Todo 124 | deleteTodo(id: ID!): Boolean 125 | createUser(input: CreateUserInput!): User 126 | updateUser(id: ID!, input: UpdateUserInput!): User 127 | deleteUser(id: ID!): Boolean 128 | } 129 | 130 | enum OperatorKindEnum { 131 | GTE 132 | LTE 133 | NE 134 | LIKE 135 | } 136 | 137 | input OperatorOptions { 138 | kind: OperatorKindEnum 139 | field: String 140 | value: String 141 | } 142 | 143 | type PageLimitPair { 144 | page: Int 145 | limit: Int 146 | } 147 | 148 | type PageMetadata { 149 | totalCount: Int 150 | } 151 | 152 | input PageQueryOptions { 153 | paginate: PaginateOptions 154 | slice: SliceOptions 155 | sort: [SortOptions] 156 | operators: [OperatorOptions] 157 | search: SearchOptions 158 | } 159 | 160 | input PaginateOptions { 161 | page: Int 162 | limit: Int 163 | } 164 | 165 | type PaginationLinks { 166 | first: PageLimitPair 167 | prev: PageLimitPair 168 | next: PageLimitPair 169 | last: PageLimitPair 170 | } 171 | 172 | type Photo { 173 | id: ID 174 | title: String 175 | url: String 176 | thumbnailUrl: String 177 | album: Album 178 | } 179 | 180 | type PhotosPage { 181 | data: [Photo] 182 | links: PaginationLinks 183 | meta: PageMetadata 184 | } 185 | 186 | type Post { 187 | id: ID 188 | title: String 189 | body: String 190 | user: User 191 | comments(options: PageQueryOptions): CommentsPage 192 | } 193 | 194 | type PostsPage { 195 | data: [Post] 196 | links: PaginationLinks 197 | meta: PageMetadata 198 | } 199 | 200 | type Query { 201 | _: Int 202 | albums(options: PageQueryOptions): AlbumsPage 203 | album(id: ID!): Album 204 | comments(options: PageQueryOptions): CommentsPage 205 | comment(id: ID!): Comment 206 | photos(options: PageQueryOptions): PhotosPage 207 | photo(id: ID!): Photo 208 | posts(options: PageQueryOptions): PostsPage 209 | post(id: ID!): Post 210 | todos(options: PageQueryOptions): TodosPage 211 | todo(id: ID!): Todo 212 | users(options: PageQueryOptions): UsersPage 213 | user(id: ID!): User 214 | pets(options: PageQueryOptions): PetsPage 215 | pet(id: ID!): Pet 216 | } 217 | 218 | input SearchOptions { 219 | q: String 220 | } 221 | 222 | input SliceOptions { 223 | start: Int 224 | end: Int 225 | limit: Int 226 | } 227 | 228 | input SortOptions { 229 | field: String 230 | order: SortOrderEnum 231 | } 232 | 233 | enum SortOrderEnum { 234 | ASC 235 | DESC 236 | } 237 | 238 | type Dog { 239 | id: ID 240 | name: String 241 | breed: String 242 | } 243 | 244 | type Cat { 245 | id: ID 246 | name: String 247 | color: String 248 | } 249 | 250 | union Pet = Dog | Cat 251 | 252 | type PetsPage { 253 | data: [Pet] 254 | links: PaginationLinks 255 | meta: PageMetadata 256 | } 257 | 258 | type Todo { 259 | id: ID 260 | title: String 261 | completed: Boolean 262 | user(id: String): User 263 | } 264 | 265 | type TodosPage { 266 | data: [Todo] 267 | links: PaginationLinks 268 | meta: PageMetadata 269 | } 270 | 271 | input UpdateAlbumInput { 272 | title: String 273 | userId: ID 274 | } 275 | 276 | input UpdateCommentInput { 277 | name: String 278 | email: String 279 | body: String 280 | } 281 | 282 | input UpdatePhotoInput { 283 | title: String 284 | url: String 285 | thumbnailUrl: String 286 | } 287 | 288 | input UpdatePostInput { 289 | title: String 290 | body: String 291 | } 292 | 293 | input UpdateTodoInput { 294 | title: String 295 | completed: Boolean 296 | } 297 | 298 | input UpdateUserInput { 299 | name: String 300 | username: String 301 | email: String 302 | address: AddressInput 303 | phone: String 304 | website: String 305 | company: CompanyInput 306 | } 307 | 308 | # The `Upload` scalar type represents a file upload. 309 | scalar Upload 310 | 311 | type User { 312 | id: ID 313 | name: String 314 | username: String 315 | email: String 316 | address: Address 317 | phone: String 318 | website: String 319 | company: Company 320 | posts(options: PageQueryOptions): PostsPage 321 | albums(options: PageQueryOptions): AlbumsPage 322 | todos(options: PageQueryOptions): TodosPage 323 | } 324 | 325 | type UsersPage { 326 | data: [User] 327 | links: PaginationLinks 328 | meta: PageMetadata 329 | } 330 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rootDir: 'src', 3 | }; 4 | -------------------------------------------------------------------------------- /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 INTEGRITY_CHECKSUM = '82ef9b96d8393b6da34527d1d6e19187'; 12 | const bypassHeaderName = 'x-msw-bypass'; 13 | const activeClientIds = new Set(); 14 | 15 | self.addEventListener('install', function () { 16 | return self.skipWaiting(); 17 | }); 18 | 19 | self.addEventListener('activate', async function (event) { 20 | return self.clients.claim(); 21 | }); 22 | 23 | self.addEventListener('message', async function (event) { 24 | const clientId = event.source.id; 25 | 26 | if (!clientId || !self.clients) { 27 | return; 28 | } 29 | 30 | const client = await self.clients.get(clientId); 31 | 32 | if (!client) { 33 | return; 34 | } 35 | 36 | const allClients = await self.clients.matchAll(); 37 | 38 | switch (event.data) { 39 | case 'KEEPALIVE_REQUEST': { 40 | sendToClient(client, { 41 | type: 'KEEPALIVE_RESPONSE', 42 | }); 43 | break; 44 | } 45 | 46 | case 'INTEGRITY_CHECK_REQUEST': { 47 | sendToClient(client, { 48 | type: 'INTEGRITY_CHECK_RESPONSE', 49 | payload: INTEGRITY_CHECKSUM, 50 | }); 51 | break; 52 | } 53 | 54 | case 'MOCK_ACTIVATE': { 55 | activeClientIds.add(clientId); 56 | 57 | sendToClient(client, { 58 | type: 'MOCKING_ENABLED', 59 | payload: true, 60 | }); 61 | break; 62 | } 63 | 64 | case 'MOCK_DEACTIVATE': { 65 | activeClientIds.delete(clientId); 66 | break; 67 | } 68 | 69 | case 'CLIENT_CLOSED': { 70 | activeClientIds.delete(clientId); 71 | 72 | const remainingClients = allClients.filter((client) => { 73 | return client.id !== clientId; 74 | }); 75 | 76 | // Unregister itself when there are no more clients 77 | if (remainingClients.length === 0) { 78 | self.registration.unregister(); 79 | } 80 | 81 | break; 82 | } 83 | } 84 | }); 85 | 86 | // Resolve the "master" client for the given event. 87 | // Client that issues a request doesn't necessarily equal the client 88 | // that registered the worker. It's with the latter the worker should 89 | // communicate with during the response resolving phase. 90 | async function resolveMasterClient(event) { 91 | const client = await self.clients.get(event.clientId); 92 | 93 | if (client.frameType === 'top-level') { 94 | return client; 95 | } 96 | 97 | const allClients = await self.clients.matchAll(); 98 | 99 | return allClients 100 | .filter((client) => { 101 | // Get only those clients that are currently visible. 102 | return client.visibilityState === 'visible'; 103 | }) 104 | .find((client) => { 105 | // Find the client ID that's recorded in the 106 | // set of clients that have registered the worker. 107 | return activeClientIds.has(client.id); 108 | }); 109 | } 110 | 111 | async function handleRequest(event, requestId) { 112 | const client = await resolveMasterClient(event); 113 | const response = await getResponse(event, client, requestId); 114 | 115 | // Send back the response clone for the "response:*" life-cycle events. 116 | // Ensure MSW is active and ready to handle the message, otherwise 117 | // this message will pend indefinitely. 118 | if (client && activeClientIds.has(client.id)) { 119 | (async function () { 120 | const clonedResponse = response.clone(); 121 | sendToClient(client, { 122 | type: 'RESPONSE', 123 | payload: { 124 | requestId, 125 | type: clonedResponse.type, 126 | ok: clonedResponse.ok, 127 | status: clonedResponse.status, 128 | statusText: clonedResponse.statusText, 129 | body: 130 | clonedResponse.body === null ? null : await clonedResponse.text(), 131 | headers: serializeHeaders(clonedResponse.headers), 132 | redirected: clonedResponse.redirected, 133 | }, 134 | }); 135 | })(); 136 | } 137 | 138 | return response; 139 | } 140 | 141 | async function getResponse(event, client, requestId) { 142 | const { request } = event; 143 | const requestClone = request.clone(); 144 | const getOriginalResponse = () => fetch(requestClone); 145 | 146 | // Bypass mocking when the request client is not active. 147 | if (!client) { 148 | return getOriginalResponse(); 149 | } 150 | 151 | // Bypass initial page load requests (i.e. static assets). 152 | // The absence of the immediate/parent client in the map of the active clients 153 | // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet 154 | // and is not ready to handle requests. 155 | if (!activeClientIds.has(client.id)) { 156 | return await getOriginalResponse(); 157 | } 158 | 159 | // Bypass requests with the explicit bypass header 160 | if (requestClone.headers.get(bypassHeaderName) === 'true') { 161 | const cleanRequestHeaders = serializeHeaders(requestClone.headers); 162 | 163 | // Remove the bypass header to comply with the CORS preflight check. 164 | delete cleanRequestHeaders[bypassHeaderName]; 165 | 166 | const originalRequest = new Request(requestClone, { 167 | headers: new Headers(cleanRequestHeaders), 168 | }); 169 | 170 | return fetch(originalRequest); 171 | } 172 | 173 | // Send the request to the client-side MSW. 174 | const reqHeaders = serializeHeaders(request.headers); 175 | const body = await request.text(); 176 | 177 | const clientMessage = await sendToClient(client, { 178 | type: 'REQUEST', 179 | payload: { 180 | id: requestId, 181 | url: request.url, 182 | method: request.method, 183 | headers: reqHeaders, 184 | cache: request.cache, 185 | mode: request.mode, 186 | credentials: request.credentials, 187 | destination: request.destination, 188 | integrity: request.integrity, 189 | redirect: request.redirect, 190 | referrer: request.referrer, 191 | referrerPolicy: request.referrerPolicy, 192 | body, 193 | bodyUsed: request.bodyUsed, 194 | keepalive: request.keepalive, 195 | }, 196 | }); 197 | 198 | switch (clientMessage.type) { 199 | case 'MOCK_SUCCESS': { 200 | return delayPromise( 201 | () => respondWithMock(clientMessage), 202 | clientMessage.payload.delay 203 | ); 204 | } 205 | 206 | case 'MOCK_NOT_FOUND': { 207 | return getOriginalResponse(); 208 | } 209 | 210 | case 'NETWORK_ERROR': { 211 | const { name, message } = clientMessage.payload; 212 | const networkError = new Error(message); 213 | networkError.name = name; 214 | 215 | // Rejecting a request Promise emulates a network error. 216 | throw networkError; 217 | } 218 | 219 | case 'INTERNAL_ERROR': { 220 | const parsedBody = JSON.parse(clientMessage.payload.body); 221 | 222 | console.error( 223 | `\ 224 | [MSW] Request handler function for "%s %s" has thrown the following exception: 225 | 226 | ${parsedBody.errorType}: ${parsedBody.message} 227 | (see more detailed error stack trace in the mocked response body) 228 | 229 | This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error. 230 | If you wish to mock an error response, please refer to this guide: https://mswjs.io/docs/recipes/mocking-error-responses\ 231 | `, 232 | request.method, 233 | request.url 234 | ); 235 | 236 | return respondWithMock(clientMessage); 237 | } 238 | } 239 | 240 | return getOriginalResponse(); 241 | } 242 | 243 | self.addEventListener('fetch', function (event) { 244 | const { request } = event; 245 | 246 | // Bypass navigation requests. 247 | if (request.mode === 'navigate') { 248 | return; 249 | } 250 | 251 | // Opening the DevTools triggers the "only-if-cached" request 252 | // that cannot be handled by the worker. Bypass such requests. 253 | if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { 254 | return; 255 | } 256 | 257 | // Bypass all requests when there are no active clients. 258 | // Prevents the self-unregistered worked from handling requests 259 | // after it's been deleted (still remains active until the next reload). 260 | if (activeClientIds.size === 0) { 261 | return; 262 | } 263 | 264 | const requestId = uuidv4(); 265 | 266 | return event.respondWith( 267 | handleRequest(event, requestId).catch((error) => { 268 | console.error( 269 | '[MSW] Failed to mock a "%s" request to "%s": %s', 270 | request.method, 271 | request.url, 272 | error 273 | ); 274 | }) 275 | ); 276 | }); 277 | 278 | function serializeHeaders(headers) { 279 | const reqHeaders = {}; 280 | headers.forEach((value, name) => { 281 | reqHeaders[name] = reqHeaders[name] 282 | ? [].concat(reqHeaders[name]).concat(value) 283 | : value; 284 | }); 285 | return reqHeaders; 286 | } 287 | 288 | function sendToClient(client, message) { 289 | return new Promise((resolve, reject) => { 290 | const channel = new MessageChannel(); 291 | 292 | channel.port1.onmessage = (event) => { 293 | if (event.data && event.data.error) { 294 | return reject(event.data.error); 295 | } 296 | 297 | resolve(event.data); 298 | }; 299 | 300 | client.postMessage(JSON.stringify(message), [channel.port2]); 301 | }); 302 | } 303 | 304 | function delayPromise(cb, duration) { 305 | return new Promise((resolve) => { 306 | setTimeout(() => resolve(cb()), duration); 307 | }); 308 | } 309 | 310 | function respondWithMock(clientMessage) { 311 | return new Response(clientMessage.payload.body, { 312 | ...clientMessage.payload, 313 | headers: clientMessage.payload.headers, 314 | }); 315 | } 316 | 317 | function uuidv4() { 318 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 319 | const r = (Math.random() * 16) | 0; 320 | const v = c == 'x' ? r : (r & 0x3) | 0x8; 321 | return v.toString(16); 322 | }); 323 | } 324 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.2", 3 | "license": "MIT", 4 | "main": "dist/node/index.js", 5 | "browser": "dist/web/index.js", 6 | "typings": "dist/types/index.d.ts", 7 | "files": [ 8 | "dist", 9 | "src" 10 | ], 11 | "engines": { 12 | "node": ">=10" 13 | }, 14 | "homepage": "https://github.com/warrenday/graphql-mock-network", 15 | "scripts": { 16 | "build": "yarn build:node & yarn build:web & yarn build:types", 17 | "build:node": "TARGET=node babel src -d dist/node --extensions \".ts\"", 18 | "build:web": "TARGET=web babel src -d dist/web --extensions \".ts\"", 19 | "build:types": "tsc --project tsconfig.json", 20 | "test": "jest", 21 | "lint": "tsc --project tsconfig.lint.json", 22 | "prepare": "yarn build", 23 | "clear": "jest --clearCache" 24 | }, 25 | "husky": { 26 | "hooks": { 27 | "pre-commit": "yarn lint" 28 | } 29 | }, 30 | "prettier": { 31 | "printWidth": 80, 32 | "semi": true, 33 | "singleQuote": true, 34 | "trailingComma": "es5" 35 | }, 36 | "name": "graphql-mock-network", 37 | "author": "warren", 38 | "size-limit": [ 39 | { 40 | "path": "dist/graphql-mock-network.cjs.production.min.js", 41 | "limit": "10 KB" 42 | }, 43 | { 44 | "path": "dist/graphql-mock-network.esm.js", 45 | "limit": "10 KB" 46 | } 47 | ], 48 | "browserslist": "> 0.25%, not dead", 49 | "devDependencies": { 50 | "@babel/cli": "^7.12.1", 51 | "@babel/core": "^7.12.3", 52 | "@babel/plugin-proposal-class-properties": "^7.12.1", 53 | "@babel/preset-env": "^7.12.1", 54 | "@babel/preset-typescript": "^7.12.1", 55 | "@size-limit/preset-small-lib": "^4.6.0", 56 | "@types/jest": "^26.0.15", 57 | "@types/node": "^14.14.0", 58 | "axios": "^0.20.0", 59 | "babel-jest": "^26.6.0", 60 | "babel-plugin-minify-dead-code-elimination": "^0.5.1", 61 | "babel-plugin-transform-inline-environment-variables": "^0.4.3", 62 | "core-js": "3", 63 | "graphql-scalars": "^1.4.0", 64 | "graphql-tag": "^2.11.0", 65 | "husky": "^4.3.0", 66 | "jest": "^26.6.3", 67 | "regenerator-runtime": "^0.13.7", 68 | "size-limit": "^4.6.0", 69 | "tslib": "^2.3.1", 70 | "typescript": "^4.0.3" 71 | }, 72 | "dependencies": { 73 | "@graphql-tools/mock": "^8.5.1", 74 | "@graphql-tools/schema": "^8.2.0", 75 | "graphql": "^15.5.3", 76 | "msw": "^0.29.0" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/createMockServer.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import { createMockServer } from './createMockServer'; 4 | 5 | const schema = fs.readFileSync( 6 | path.resolve(__dirname, '../introspection.schema.graphql'), 7 | 'utf8' 8 | ); 9 | 10 | describe('createMockServer', () => { 11 | it('creates a new mock server', () => { 12 | const server = createMockServer({ schema }); 13 | 14 | expect(server).toHaveProperty('query'); 15 | }); 16 | 17 | it('provides an auto-mocked server given the provided schema', async () => { 18 | const server = createMockServer({ 19 | schema, 20 | }); 21 | 22 | const res = await server.query( 23 | ` 24 | query todo($id: ID!) { 25 | todo(id: $id) { 26 | id 27 | title 28 | } 29 | } 30 | `, 31 | { 32 | id: 1, 33 | } 34 | ); 35 | 36 | expect(res).toHaveProperty('data.todo.id'); 37 | expect(res).toHaveProperty('data.todo.title'); 38 | expect(typeof res.data?.todo?.id).toBe('string'); 39 | expect(typeof res.data?.todo?.title).toBe('string'); 40 | }); 41 | 42 | it('allows manual mocking of queries when providing a custom mock', async () => { 43 | const server = createMockServer({ 44 | schema, 45 | mocks: { 46 | Query: { 47 | todo: () => ({ 48 | id: 'xyz', 49 | title: 'I am manually mocked!', 50 | }), 51 | }, 52 | }, 53 | }); 54 | 55 | const res = await server.query( 56 | ` 57 | query todo($id: ID!) { 58 | todo(id: $id) { 59 | id 60 | title 61 | } 62 | } 63 | `, 64 | { 65 | id: 1, 66 | } 67 | ); 68 | 69 | expect(res).toEqual({ 70 | data: { 71 | todo: { 72 | id: 'xyz', 73 | title: 'I am manually mocked!', 74 | }, 75 | }, 76 | }); 77 | }); 78 | 79 | it('preserves auto-mocking when providing manual mocks', async () => { 80 | const server = createMockServer({ 81 | schema, 82 | mocks: { 83 | Query: { 84 | todo: () => ({ 85 | id: 'xyz', 86 | title: 'I am manually mocked!', 87 | }), 88 | }, 89 | }, 90 | }); 91 | 92 | const res = await server.query( 93 | ` 94 | query photo($id: ID!) { 95 | photo(id: $id) { 96 | id 97 | title 98 | } 99 | } 100 | `, 101 | { 102 | id: 1, 103 | } 104 | ); 105 | 106 | expect(res).toHaveProperty('data.photo.id'); 107 | expect(res).toHaveProperty('data.photo.title'); 108 | expect(typeof res.data?.photo?.id).toBe('string'); 109 | expect(typeof res.data?.photo?.title).toBe('string'); 110 | }); 111 | 112 | it('applies query arguments to a mock', async () => { 113 | const mockArgs = jest.fn(); 114 | const server = createMockServer({ 115 | schema, 116 | mocks: { 117 | Query: { 118 | todo: (_: any, args: {}) => { 119 | mockArgs(args); 120 | return { 121 | id: 'xyz', 122 | title: 'I am manually mocked!', 123 | }; 124 | }, 125 | }, 126 | }, 127 | }); 128 | 129 | await server.query( 130 | ` 131 | query todo($id: ID!) { 132 | todo(id: $id) { 133 | id 134 | title 135 | } 136 | } 137 | `, 138 | { 139 | id: 'some-id', 140 | } 141 | ); 142 | 143 | expect(mockArgs).toHaveBeenCalledWith({ id: 'some-id' }); 144 | }); 145 | 146 | it('applies query arguments to a mock when nested queries used', async () => { 147 | const mockArgs = jest.fn(); 148 | const mockNestedArgs = jest.fn(); 149 | const server = createMockServer({ 150 | schema, 151 | mocks: { 152 | Query: { 153 | todo: (_: any, args: {}) => { 154 | mockArgs(args); 155 | return { 156 | id: 'xyz', 157 | title: 'I am manually mocked!', 158 | user: (nestedArgs: {}) => { 159 | mockNestedArgs(nestedArgs); 160 | return { 161 | id: 'some-user-id', 162 | }; 163 | }, 164 | }; 165 | }, 166 | }, 167 | }, 168 | }); 169 | 170 | await server.query( 171 | ` 172 | query todo($id: ID!, $userId: String) { 173 | todo(id: $id) { 174 | id 175 | title 176 | user(id: $userId) { 177 | id 178 | } 179 | } 180 | } 181 | `, 182 | { 183 | id: 'some-id', 184 | userId: 'some-user-id', 185 | } 186 | ); 187 | 188 | expect(mockArgs).toHaveBeenCalledWith({ id: 'some-id' }); 189 | expect(mockNestedArgs).toHaveBeenCalledWith({ id: 'some-user-id' }); 190 | }); 191 | 192 | it('applies mocked scalars only when fields are not mocked', async () => { 193 | const server = createMockServer({ 194 | schema, 195 | mocks: { 196 | ID: () => 'MOCKED_ID_SCALAR', 197 | Query: { 198 | todos: () => ({ 199 | data: [{ id: '123' }, {}, { id: '456' }], 200 | }), 201 | }, 202 | }, 203 | }); 204 | 205 | const res = await server.query( 206 | ` 207 | query todos { 208 | todos { 209 | data { 210 | id 211 | } 212 | } 213 | } 214 | `, 215 | { 216 | id: 1, 217 | } 218 | ); 219 | 220 | expect(res.data).toEqual({ 221 | todos: { 222 | data: [{ id: '123' }, { id: 'MOCKED_ID_SCALAR' }, { id: '456' }], 223 | }, 224 | }); 225 | }); 226 | }); 227 | -------------------------------------------------------------------------------- /src/createMockServer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | graphql, 3 | buildClientSchema, 4 | GraphQLSchema, 5 | ExecutionResult, 6 | IntrospectionQuery, 7 | } from 'graphql'; 8 | import { makeExecutableSchema } from '@graphql-tools/schema'; 9 | import { addMocksToSchema } from '@graphql-tools/mock'; 10 | import { IMockPayload } from './types'; 11 | 12 | type CreateMockServerArgs = { 13 | schema: string; 14 | mocks?: IMockPayload; 15 | }; 16 | 17 | export type MockServer = { 18 | query: ( 19 | query: string, 20 | variables: {} 21 | ) => Promise>; 22 | }; 23 | 24 | const getJsonOrString = (jsonOrString: string): string | IntrospectionQuery => { 25 | try { 26 | return JSON.parse(jsonOrString); 27 | } catch (e) { 28 | return jsonOrString; 29 | } 30 | }; 31 | 32 | const getGraphqlSchema = (schemaString: string): GraphQLSchema => { 33 | const schema = getJsonOrString(schemaString); 34 | if (typeof schema === 'string') { 35 | return makeExecutableSchema({ typeDefs: schema }); 36 | } 37 | return buildClientSchema(schema); 38 | }; 39 | 40 | const extractResolvers = (mocks: IMockPayload) => { 41 | let resolvers: Record = {}; 42 | for (const key in mocks) { 43 | const mock = mocks[key]; 44 | if (typeof mock === 'object' && mock !== null) { 45 | resolvers[key] = mock; 46 | } 47 | } 48 | return resolvers; 49 | }; 50 | 51 | export const createMockServer = ({ 52 | schema, 53 | mocks = {}, 54 | }: CreateMockServerArgs): MockServer => { 55 | // Apply Query and Mutation to resolvers so they have access 56 | // to query args. Apply the rest (Scalars) as general mocks. 57 | const resolvers = extractResolvers(mocks); 58 | 59 | const graphqlSchema = getGraphqlSchema(schema); 60 | const schemaWithMocks = addMocksToSchema({ 61 | schema: graphqlSchema, 62 | mocks: mocks, 63 | resolvers, 64 | }); 65 | 66 | return { 67 | query: (query, variables) => { 68 | return graphql({ 69 | schema: schemaWithMocks, 70 | source: query, 71 | variableValues: variables, 72 | }); 73 | }, 74 | }; 75 | }; 76 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import axios from 'axios'; 4 | import { MockNetwork } from './index'; 5 | 6 | const rejectTimeout = (ms: number) => { 7 | return new Promise((_, reject) => { 8 | setTimeout(() => reject(new Error('timeout')), ms); 9 | }); 10 | }; 11 | 12 | const schema = fs.readFileSync( 13 | path.resolve(__dirname, '../introspection.schema.graphql'), 14 | 'utf8' 15 | ); 16 | 17 | const networkRequest = (query: string, variables: object = { id: 1 }) => { 18 | return axios.post( 19 | '/graphql', 20 | { 21 | query, 22 | variables, 23 | }, 24 | { 25 | headers: { 'Content-Type': 'application/json' }, 26 | } 27 | ); 28 | }; 29 | 30 | const mockNetwork = new MockNetwork({ 31 | schema, 32 | mocks: { 33 | Query: { 34 | todo: () => ({ 35 | id: 'xyz', 36 | title: 'I am a manually mocked todo!', 37 | }), 38 | }, 39 | }, 40 | }); 41 | 42 | describe('MockNetwork', () => { 43 | beforeEach(() => { 44 | mockNetwork.resetMocks(); 45 | }); 46 | 47 | beforeAll(() => { 48 | mockNetwork.start(); 49 | }); 50 | 51 | afterAll(() => { 52 | mockNetwork.stop(); 53 | }); 54 | 55 | it('mocks a query', async () => { 56 | const res = await networkRequest(` 57 | query todo($id: ID!) { 58 | todo(id: $id) { 59 | id 60 | title 61 | } 62 | } 63 | `); 64 | 65 | expect(res.data).toEqual({ 66 | data: { 67 | todo: { 68 | id: 'xyz', 69 | title: 'I am a manually mocked todo!', 70 | }, 71 | }, 72 | }); 73 | }); 74 | 75 | it('mocks a mutation', async () => { 76 | mockNetwork.addMocks({ 77 | Mutation: { 78 | createPhoto: () => ({ 79 | id: '1', 80 | title: 'Family Holiday', 81 | url: 'http://url.com', 82 | thumbnailUrl: 'http://url.com/thumbnail', 83 | }), 84 | }, 85 | }); 86 | 87 | const res = await networkRequest( 88 | ` 89 | mutation createPhoto($title: String!, $url: String!, $thumbnailUrl: String!) { 90 | createPhoto(input: { title: $title, url: $url, thumbnailUrl: $thumbnailUrl }) { 91 | id 92 | title 93 | } 94 | } 95 | `, 96 | { 97 | title: 'Family Holiday', 98 | url: 'http://url.com', 99 | thumbnailUrl: 'http://url.com/thumbnail', 100 | } 101 | ); 102 | 103 | expect(res.data).toEqual({ 104 | data: { 105 | createPhoto: { 106 | id: '1', 107 | title: 'Family Holiday', 108 | }, 109 | }, 110 | }); 111 | }); 112 | 113 | it('mocks a type/scalar', async () => { 114 | mockNetwork.addMocks({ 115 | ID: () => '200', 116 | Query: { 117 | todo: () => ({ 118 | title: 'I am a manually mocked todo!', 119 | }), 120 | }, 121 | }); 122 | 123 | const res = await networkRequest(` 124 | query todo($id: ID!) { 125 | todo(id: $id) { 126 | id 127 | title 128 | } 129 | } 130 | `); 131 | 132 | expect(res.data).toEqual({ 133 | data: { 134 | todo: { 135 | id: '200', 136 | title: 'I am a manually mocked todo!', 137 | }, 138 | }, 139 | }); 140 | }); 141 | 142 | it('mocks an array', async () => { 143 | mockNetwork.addMocks({ 144 | ID: () => 'ID_MOCKED', 145 | Query: { 146 | todos: () => ({ 147 | data: [{ id: '123' }], 148 | }), 149 | }, 150 | }); 151 | 152 | const res = await networkRequest(` 153 | query todos { 154 | todos { 155 | data { 156 | id 157 | title 158 | } 159 | } 160 | } 161 | `); 162 | 163 | expect(res.data).toEqual({ 164 | data: { 165 | todos: { 166 | data: [{ id: '123', title: expect.any(String) }], 167 | }, 168 | }, 169 | }); 170 | }); 171 | 172 | it('mocks an union', async () => { 173 | mockNetwork.addMocks({ 174 | Query: { 175 | pets: () => ({ 176 | data: [ 177 | { id: '123', name: 'Indy', breed: 'Dachshund', __typename: 'Dog' }, 178 | { id: '123', name: 'Milo', color: 'Blue', __typename: 'Cat' }, 179 | ], 180 | }), 181 | }, 182 | Pet: { 183 | __resolveType: (obj: any) => obj.__typename, 184 | }, 185 | }); 186 | 187 | const res = await networkRequest(` 188 | query pets { 189 | pets { 190 | data { 191 | ... on Dog { 192 | id 193 | name 194 | breed 195 | } 196 | ... on Cat { 197 | id 198 | name 199 | color 200 | } 201 | } 202 | } 203 | } 204 | `); 205 | 206 | expect(res.data).toEqual({ 207 | data: { 208 | pets: { 209 | data: [ 210 | { id: '123', name: 'Indy', breed: 'Dachshund' }, 211 | { id: '123', name: 'Milo', color: 'Blue' }, 212 | ], 213 | }, 214 | }, 215 | }); 216 | }); 217 | 218 | it('mocks a fragment with a union', async () => { 219 | mockNetwork.addMocks({ 220 | Query: { 221 | pets: () => ({ 222 | data: [ 223 | { id: '123', name: 'Indy', breed: 'Dachshund', __typename: 'Dog' }, 224 | { id: '123', name: 'Milo', color: 'Blue', __typename: 'Cat' }, 225 | ], 226 | }), 227 | }, 228 | Pet: { 229 | __resolveType: (obj: any) => obj.__typename, 230 | }, 231 | }); 232 | 233 | const res = await networkRequest(` 234 | fragment PetPageFragment on PetsPage { 235 | data { 236 | ... on Dog { 237 | id 238 | name 239 | breed 240 | } 241 | ... on Cat { 242 | id 243 | name 244 | color 245 | } 246 | } 247 | } 248 | 249 | query pets { 250 | pets { 251 | ...PetPageFragment 252 | } 253 | } 254 | `); 255 | 256 | expect(res.data).toEqual({ 257 | data: { 258 | pets: { 259 | data: [ 260 | { id: '123', name: 'Indy', breed: 'Dachshund' }, 261 | { id: '123', name: 'Milo', color: 'Blue' }, 262 | ], 263 | }, 264 | }, 265 | }); 266 | }); 267 | 268 | it('mocks an error', async () => { 269 | mockNetwork.addMocks({ 270 | Query: { 271 | photo: () => { 272 | throw new Error('Oh dear, this is bad'); 273 | }, 274 | }, 275 | }); 276 | 277 | const res = await networkRequest(` 278 | query photo($id: ID!) { 279 | photo(id: $id) { 280 | id 281 | title 282 | } 283 | } 284 | `); 285 | 286 | expect(res.data.errors).toHaveLength(1); 287 | expect(res.data.errors[0]).toHaveProperty( 288 | 'message', 289 | 'Oh dear, this is bad' 290 | ); 291 | }); 292 | 293 | it('adds addition mocks', async () => { 294 | mockNetwork.addMocks({ 295 | Query: { 296 | photo: () => ({ 297 | id: 'abc', 298 | title: 'I am a manually mocked photo!', 299 | }), 300 | }, 301 | }); 302 | 303 | const res = await networkRequest(` 304 | query photo($id: ID!) { 305 | photo(id: $id) { 306 | id 307 | title 308 | } 309 | } 310 | `); 311 | 312 | expect(res.data).toEqual({ 313 | data: { 314 | photo: { 315 | id: 'abc', 316 | title: 'I am a manually mocked photo!', 317 | }, 318 | }, 319 | }); 320 | }); 321 | 322 | it('preserves previous mocks when adding new mocks', async () => { 323 | mockNetwork.addMocks({ 324 | Query: { 325 | photo: () => ({ 326 | id: 'abc', 327 | title: 'I am a manually mocked photo!', 328 | }), 329 | }, 330 | }); 331 | 332 | const res = await networkRequest(` 333 | query todo($id: ID!) { 334 | todo(id: $id) { 335 | id 336 | title 337 | } 338 | } 339 | `); 340 | 341 | expect(res.data).toEqual({ 342 | data: { 343 | todo: { 344 | id: 'xyz', 345 | title: 'I am a manually mocked todo!', 346 | }, 347 | }, 348 | }); 349 | }); 350 | 351 | it('resets mocks back to state when originally instantiated', async () => { 352 | mockNetwork.addMocks({ 353 | Query: { 354 | photo: () => ({ 355 | id: 'abc', 356 | title: 'I am a manually mocked photo!', 357 | }), 358 | }, 359 | }); 360 | mockNetwork.resetMocks(); 361 | 362 | const res1 = await networkRequest(` 363 | query photo($id: ID!) { 364 | photo(id: $id) { 365 | id 366 | title 367 | } 368 | } 369 | `); 370 | 371 | expect(res1.data).not.toEqual({ 372 | data: { 373 | photo: { 374 | id: 'abc', 375 | title: 'I am a manually mocked photo!', 376 | }, 377 | }, 378 | }); 379 | 380 | const res2 = await networkRequest(` 381 | query todo($id: ID!) { 382 | todo(id: $id) { 383 | id 384 | title 385 | } 386 | } 387 | `); 388 | 389 | expect(res2.data).toEqual({ 390 | data: { 391 | todo: { 392 | id: 'xyz', 393 | title: 'I am a manually mocked todo!', 394 | }, 395 | }, 396 | }); 397 | }); 398 | 399 | it('can stop and restart mockNetwork server', async () => { 400 | mockNetwork.stop(); 401 | 402 | await expect( 403 | Promise.race([ 404 | networkRequest(` 405 | query todo($id: ID!) { 406 | todo(id: $id) { 407 | id 408 | title 409 | } 410 | } 411 | `), 412 | rejectTimeout(4000), 413 | ]) 414 | ).rejects.toThrow(Error); 415 | 416 | mockNetwork.start(); 417 | 418 | const res2 = await networkRequest(` 419 | query todo($id: ID!) { 420 | todo(id: $id) { 421 | id 422 | title 423 | } 424 | } 425 | `); 426 | 427 | expect(res2.data).toEqual({ 428 | data: { 429 | todo: { 430 | id: 'xyz', 431 | title: 'I am a manually mocked todo!', 432 | }, 433 | }, 434 | }); 435 | }); 436 | }); 437 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { IMocks } from '@graphql-tools/mock'; 2 | import { createMockHandler, HandleRequest } from './utils/createMockHandler'; 3 | import { createMockServer, MockServer } from './createMockServer'; 4 | import { mergeMocks } from './utils/mergeMocks'; 5 | import { setupWorker, Worker } from './utils/setupWorker'; 6 | import { IMockPayload } from './types'; 7 | 8 | export { IMockPayload }; 9 | 10 | export type MockNetworkArgs = { 11 | schema: string; 12 | mocks?: IMockPayload; 13 | }; 14 | 15 | export class MockNetwork { 16 | private mockServer: MockServer; 17 | private worker: Worker; 18 | private schema: string; 19 | private mocks: IMocks; 20 | private defaultMocks: IMocks; 21 | 22 | constructor({ schema, mocks: initialMocks = {} }: MockNetworkArgs) { 23 | const mocks = initialMocks; 24 | this.mockServer = createMockServer({ schema, mocks }); 25 | this.defaultMocks = mocks; 26 | this.mocks = mocks; 27 | this.schema = schema; 28 | this.worker = setupWorker(createMockHandler(this.handleRequest)); 29 | } 30 | 31 | private recreateMockServer(newMocks: IMocks) { 32 | this.mocks = newMocks; 33 | this.mockServer = createMockServer({ 34 | schema: this.schema, 35 | mocks: this.mocks, 36 | }); 37 | } 38 | 39 | private handleRequest: HandleRequest = (query, variables) => { 40 | return this.mockServer.query(query, variables); 41 | }; 42 | 43 | start() { 44 | return this.worker.start(); 45 | } 46 | 47 | stop() { 48 | this.worker.stop(); 49 | this.worker = setupWorker(createMockHandler(this.handleRequest)); 50 | } 51 | 52 | addMocks(mocks: IMocks) { 53 | this.recreateMockServer(mergeMocks([this.mocks, mocks])); 54 | } 55 | 56 | resetMocks() { 57 | this.recreateMockServer(this.defaultMocks); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { IMocks, IMockStore } from '@graphql-tools/mock'; 2 | 3 | type IMockResolver = ( 4 | store: IMockStore, 5 | args: Record 6 | ) => unknown; 7 | 8 | export interface IMockPayload extends IMocks { 9 | Query?: { 10 | [key: string]: IMockResolver; 11 | }; 12 | Mutation?: { 13 | [key: string]: IMockResolver; 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/createMockHandler.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionResult } from 'graphql'; 2 | import { graphql, GraphQLRequest, GraphQLHandler } from 'msw'; 3 | 4 | export type HandleRequest = ( 5 | query: string, 6 | variables: Record 7 | ) => Promise>; 8 | 9 | export type HandlerResponse = GraphQLHandler>; 10 | 11 | export const createMockHandler = ( 12 | handleRequest: HandleRequest 13 | ): HandlerResponse => { 14 | const handler = graphql.operation(async (req, res, ctx) => { 15 | const { body } = req; 16 | if (!body) { 17 | throw new Error('Request body missing'); 18 | } 19 | 20 | const payload = await handleRequest(body.query, req.variables); 21 | 22 | if (payload.errors) { 23 | return res(ctx.errors([...payload.errors])); 24 | } 25 | 26 | return res(ctx.data(payload.data || {})); 27 | }); 28 | 29 | return handler; 30 | }; 31 | -------------------------------------------------------------------------------- /src/utils/mergeMocks.ts: -------------------------------------------------------------------------------- 1 | import { IMocks } from '@graphql-tools/mock'; 2 | import { mergeResolvers } from '@graphql-tools/merge'; 3 | 4 | export const mergeMocks = (mocks: IMocks[]): IMocks => { 5 | return mergeResolvers(mocks as {}); 6 | }; 7 | -------------------------------------------------------------------------------- /src/utils/setupWorker.ts: -------------------------------------------------------------------------------- 1 | import { HandlerResponse } from './createMockHandler'; 2 | 3 | export type Worker = { 4 | start: () => Promise; 5 | stop: () => void; 6 | }; 7 | 8 | export const setupWorker = (handlers: HandlerResponse): Worker => { 9 | if (process.env.TARGET === 'web') { 10 | const { setupWorker } = require('msw'); 11 | return setupWorker(handlers); 12 | } else { 13 | const { setupServer } = require('msw/node'); 14 | const server = setupServer(handlers); 15 | 16 | return { 17 | start: () => server.listen(), 18 | stop: () => server.close(), 19 | }; 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | "emitDeclarationOnly": true, 9 | "declaration": true, 10 | "declarationDir": "dist/types", 11 | "outDir": "dist/types", 12 | "sourceMap": true, 13 | "rootDir": "./src", 14 | "strict": true, 15 | "noImplicitReturns": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "moduleResolution": "node", 20 | "jsx": "react", 21 | "esModuleInterop": true, 22 | "skipLibCheck": true, 23 | "forceConsistentCasingInFileNames": true 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.lint.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | "sourceMap": true, 9 | "rootDir": "./src", 10 | "noEmit": true, 11 | "strict": true, 12 | "noImplicitReturns": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "moduleResolution": "node", 17 | "jsx": "react", 18 | "esModuleInterop": true, 19 | "skipLibCheck": true, 20 | "forceConsistentCasingInFileNames": true 21 | } 22 | } 23 | --------------------------------------------------------------------------------