diff --git a/.release-please-manifest.json b/.release-please-manifest.json index e87620c2..cea869c1 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,6 +1,6 @@ { - "packages/fetch-mock": "12.2.0", - "packages/vitest": "0.2.6", - "packages/jest": "0.2.6", + "packages/fetch-mock": "12.2.1", + "packages/vitest": "0.2.7", + "packages/jest": "0.2.9", "packages/codemods": "0.1.2" } diff --git a/docs/docs/API/more-routing-methods.md b/docs/docs/API/more-routing-methods.md index 5ebca9ca..d1554efc 100644 --- a/docs/docs/API/more-routing-methods.md +++ b/docs/docs/API/more-routing-methods.md @@ -7,6 +7,8 @@ sidebar_label: More routing methods These methods allow defining routes for common use cases while avoiding writing hard to read configuration objects. They all return the fetchMock instance, and are therefor chainable. Unless noted otherwise, each of the methods below have the same signature as `.route()`. +> Note that in older versions of fetch-mock these methods also replaced global `fetch` with your mock. From version 12 onwards this is no longer the case, and you will need to start your tests with `fetchMock.mockGlobal()` or similar. [Read the mocking and spying docs](/fetch-mock/docs/API/mocking-and-spying). + ## .catch() `.catch(response)` diff --git a/docs/docs/API/route/index.md b/docs/docs/API/route/index.md index b0a162e4..54aeb361 100644 --- a/docs/docs/API/route/index.md +++ b/docs/docs/API/route/index.md @@ -108,7 +108,7 @@ fetchMock .route('*', 'ok') .route('*', 404) .route('*', {results: []}) - .route('*', {throw: new Error('Bad kitty'))) + .route('*', {throw: new Error('Bad kitty')}) .route('*', new Promise(res => setTimeout(res, 1000, 404))) .route('*', (url, opts) => { status: 302, diff --git a/docs/docs/Usage/upgrade-guide.md b/docs/docs/Usage/upgrade-guide.md index e1096e6b..e2528ba7 100644 --- a/docs/docs/Usage/upgrade-guide.md +++ b/docs/docs/Usage/upgrade-guide.md @@ -16,6 +16,10 @@ Previously this was true in browsers, but in node.js the node-fetch library was This combined adding a route with mocking the global instance of `fetch`. These are now split into 2 methods: `.route()` and `.mockGlobal()`. +### Routing convenience methods no longer mock `fetch` + +Similar to the last point, `.once()`, `.get()`, `.post()`, `getOnce()`, `postOnce()` and all other routing convenience methods no longer mock the global instance of `fetch`, and you will need to call `.mockGlobal()` explicitly. + ### Reset methods changed `.reset()`, `.restore()`, `.resetBehavior()` and `.resetHistory()` have been removed and replaced with [methods that are more granular and clearly named](/fetch-mock/docs/API/resetting). Note that the [jest](/fetch-mock/docs/wrappers/jest) and [vitest](/fetch-mock/docs/wrappers/vitest) wrappers for fetch-mock still implement `.mockClear()`, `mockReset()` and `mockRestore()`. diff --git a/docs/docs/wrappers/index.md b/docs/docs/wrappers/index.md index 96e06bce..207086a0 100644 --- a/docs/docs/wrappers/index.md +++ b/docs/docs/wrappers/index.md @@ -2,3 +2,6 @@ sidebar_label: Wrappers sidebar_position: 8 --- + +- [@fetch-mock/jest](/fetch-mock/docs/wrappers/jest) +- [@fetch-mock/vitest](/fetch-mock/docs/wrappers/vitest) diff --git a/docs/docs/wrappers/jest.md b/docs/docs/wrappers/jest.md index 346b9e71..292548d2 100644 --- a/docs/docs/wrappers/jest.md +++ b/docs/docs/wrappers/jest.md @@ -70,7 +70,7 @@ Note that these **will not** clear any sticky routes added to fetchMock. You wil ### Expect extensions -These are added to jest automatically and are available on any expect call that is passed fetchMock as an argument. Their behaviour is similar to the jest expectation methods mentioned in the comments below +These are added to jest automatically and are available on any expect call that is passed `fetchMock` (or `fetch`, if it has been mocked globally by fetchMock) as an argument. Their behaviour is similar to the jest expectation methods mentioned in the comments below ```js expect(fetchMock).toHaveFetched(filter, options); // .toHaveBeenCalled()/.toHaveBeenCalledWith() diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index c8855b0c..1b287d47 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -39,7 +39,7 @@ const config = { sidebarPath: './sidebars.js', // Please change this to your repo. // Remove this to remove the "edit this page" links. - editUrl: 'https://github.com/wheresrhys/fetch-mock/edit/main', + editUrl: 'https://github.com/wheresrhys/fetch-mock/edit/main/docs', }, theme: { customCss: './src/css/custom.css', diff --git a/package-lock.json b/package-lock.json index fe3fa365..39df37f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "eslint": "9.14.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", + "expect-type": "^1.1.0", "globals": "^15.9.0", "husky": "^9.0.11", "jest": "^29.7.0", @@ -12852,7 +12853,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.1.0.tgz", "integrity": "sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==", - "license": "Apache-2.0", "engines": { "node": ">=12.0.0" } @@ -20946,16 +20946,15 @@ "link": true }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -27332,9 +27331,9 @@ } }, "node_modules/vite": { - "version": "5.4.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", - "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", + "version": "5.4.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz", + "integrity": "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==", "license": "MIT", "dependencies": { "esbuild": "^0.21.3", @@ -28524,7 +28523,7 @@ } }, "packages/fetch-mock": { - "version": "12.2.0", + "version": "12.2.1", "license": "MIT", "dependencies": { "@types/glob-to-regexp": "^0.4.4", @@ -28539,10 +28538,10 @@ }, "packages/jest": { "name": "@fetch-mock/jest", - "version": "0.2.6", + "version": "0.2.9", "license": "MIT", "dependencies": { - "fetch-mock": "^12.2.0" + "fetch-mock": "^12.2.1" }, "engines": { "node": ">=18.11.0" @@ -28554,10 +28553,10 @@ }, "packages/vitest": { "name": "@fetch-mock/vitest", - "version": "0.2.6", + "version": "0.2.7", "license": "MIT", "dependencies": { - "fetch-mock": "^12.2.0" + "fetch-mock": "^12.2.1" }, "engines": { "node": ">=18.11.0" diff --git a/package.json b/package.json index 03e9f9a7..3cd4efba 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "eslint": "9.14.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", + "expect-type": "^1.1.0", "globals": "^15.9.0", "husky": "^9.0.11", "jest": "^29.7.0", diff --git a/packages/fetch-mock/CHANGELOG.md b/packages/fetch-mock/CHANGELOG.md index 35572ae9..10b55059 100644 --- a/packages/fetch-mock/CHANGELOG.md +++ b/packages/fetch-mock/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [12.2.1](https://github.com/wheresrhys/fetch-mock/compare/fetch-mock-v12.2.0...fetch-mock-v12.2.1) (2025-01-28) + + +### Bug Fixes + +* fix failure to spy in browsers ([bfaa5f3](https://github.com/wheresrhys/fetch-mock/commit/bfaa5f33c133af17a0bd097d2d3dbcb01966a0a8)) + ## [12.2.0](https://github.com/wheresrhys/fetch-mock/compare/fetch-mock-v12.1.0...fetch-mock-v12.2.0) (2024-11-15) diff --git a/packages/fetch-mock/package.json b/packages/fetch-mock/package.json index c8fdef99..ece9e07c 100644 --- a/packages/fetch-mock/package.json +++ b/packages/fetch-mock/package.json @@ -1,7 +1,7 @@ { "name": "fetch-mock", "description": "Mock http requests made using fetch", - "version": "12.2.0", + "version": "12.2.1", "exports": { "browser": "./dist/esm/index.js", "import": { diff --git a/packages/fetch-mock/src/CallHistory.ts b/packages/fetch-mock/src/CallHistory.ts index d796ba13..5959ab6c 100644 --- a/packages/fetch-mock/src/CallHistory.ts +++ b/packages/fetch-mock/src/CallHistory.ts @@ -127,7 +127,10 @@ class CallHistory { called(filter?: CallHistoryFilter, options?: RouteConfig): boolean { return Boolean(this.calls(filter, options).length); } - lastCall(filter?: CallHistoryFilter, options?: RouteConfig): CallLog | void { + lastCall( + filter?: CallHistoryFilter, + options?: RouteConfig, + ): CallLog | undefined { return this.calls(filter, options).pop(); } diff --git a/packages/fetch-mock/src/FetchMock.ts b/packages/fetch-mock/src/FetchMock.ts index aad608c0..ca54c639 100644 --- a/packages/fetch-mock/src/FetchMock.ts +++ b/packages/fetch-mock/src/FetchMock.ts @@ -162,12 +162,18 @@ export class FetchMock { matcher?: RouteMatcher | UserRouteConfig, name?: RouteName, ): FetchMock { + const boundFetch = this.config.fetch.bind(globalThis); if (matcher) { - //@ts-expect-error TODO findo out how to overload an overload - this.route(matcher, ({ args }) => this.config.fetch(...args), name); + this.route( + // @ts-expect-error related to the overloading of .route() + matcher, + // @ts-expect-error this is just args from a fetch call being passed into a bound fetch - no idea why the error + ({ args }) => boundFetch(...args), + name, + ); } else { - //@ts-expect-error TODO findo out how to overload an overload - this.catch(({ args }) => this.config.fetch(...args)); + // @ts-expect-error this is just args from a fetch call being passed into a bound fetch - no idea why the error + this.catch(({ args }) => boundFetch(...args)); } return this; diff --git a/packages/fetch-mock/src/__tests__/FetchMock/mock-and-spy.test.js b/packages/fetch-mock/src/__tests__/FetchMock/mock-and-spy.test.js index 36524f0d..dd883190 100644 --- a/packages/fetch-mock/src/__tests__/FetchMock/mock-and-spy.test.js +++ b/packages/fetch-mock/src/__tests__/FetchMock/mock-and-spy.test.js @@ -59,6 +59,7 @@ describe('mock and spy', () => { describe('.spy()', () => { testChainableMethod('spy'); testChainableMethod('spyGlobal'); + it('passes all requests through to the network by default', async () => { vi.spyOn(fm.config, 'fetch'); fm.spy(); @@ -134,5 +135,13 @@ describe('mock and spy', () => { method: 'post', }); }); + + it('can call actual native fetch without erroring', async () => { + fm.spyGlobal(); + const isBrowser = Boolean(globalThis.location); + // avoids getting caught by a cors error + const testUrl = isBrowser ? '/' : 'http://example.com/'; + await expect(fm.fetchHandler(testUrl)).resolves.toBeInstanceOf(Response); + }); }); }); diff --git a/packages/jest/CHANGELOG.md b/packages/jest/CHANGELOG.md index 3e428351..46fd1c80 100644 --- a/packages/jest/CHANGELOG.md +++ b/packages/jest/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog + +## [0.2.9](https://github.com/wheresrhys/fetch-mock/compare/jest-v0.2.8...jest-v0.2.9) (2025-01-28) + + +### Bug Fixes + +* force release of @fetch-mock/jest ([7ff5915](https://github.com/wheresrhys/fetch-mock/commit/7ff59159cf3e770249db6b4216c1764291cb8c8d)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * fetch-mock bumped from ^12.2.0 to ^12.2.1 + +## [0.2.8](https://github.com/wheresrhys/fetch-mock/compare/jest-v0.2.7...jest-v0.2.8) (2025-01-11) + + +### Bug Fixes + +* incorrect Jest extension TypeScript type ([9d47c33](https://github.com/wheresrhys/fetch-mock/commit/9d47c333a097ed9d1bd68f24bd745d200f3982b3)) + +## [0.2.7](https://github.com/wheresrhys/fetch-mock/compare/jest-v0.2.6...jest-v0.2.7) (2024-11-29) + + +### Bug Fixes + +* add not type definitions ([68b24e7](https://github.com/wheresrhys/fetch-mock/commit/68b24e74f508a42dcfa795c040019eff446281d6)) +* adding jestMock extension support to match documentation ([9e41a81](https://github.com/wheresrhys/fetch-mock/commit/9e41a8165bd2caf2cda1d88615be907fcf6f0bc4)) +* adding types to jest matchers ([033048a](https://github.com/wheresrhys/fetch-mock/commit/033048a47ffc07508fc0cb2ce79078b4facb86fb)) + ## [0.2.6](https://github.com/wheresrhys/fetch-mock/compare/jest-v0.2.5...jest-v0.2.6) (2024-11-15) diff --git a/packages/jest/package.json b/packages/jest/package.json index 9a46cd0a..7ab2f32e 100644 --- a/packages/jest/package.json +++ b/packages/jest/package.json @@ -1,7 +1,7 @@ { "name": "@fetch-mock/jest", "description": "jest wrapper for fetch-mock", - "version": "0.2.6", + "version": "0.2.9", "exports": { "browser": "./dist/esm/index.js", "import": { @@ -21,7 +21,7 @@ "node": ">=18.11.0" }, "dependencies": { - "fetch-mock": "^12.2.0" + "fetch-mock": "^12.2.1" }, "peerDependencies": { "jest": "*", diff --git a/packages/jest/src/__tests__/extensions.spec.js b/packages/jest/src/__tests__/extensions.spec.ts similarity index 51% rename from packages/jest/src/__tests__/extensions.spec.js rename to packages/jest/src/__tests__/extensions.spec.ts index 63f03498..5c25b72c 100644 --- a/packages/jest/src/__tests__/extensions.spec.js +++ b/packages/jest/src/__tests__/extensions.spec.ts @@ -1,19 +1,26 @@ -import { describe, it, beforeAll, afterAll, expect } from '@jest/globals'; - -import fetchMockModule from '../index'; -const fetchMock = fetchMockModule.default; - -describe('expect extensions', () => { - [ - 'Fetched', - 'Got:get', - 'Posted:post', - 'Put:put', - 'Deleted:delete', - 'FetchedHead:head', - 'Patched:patch', - ].forEach((verbs) => { - const [humanVerb, method] = verbs.split(':'); +import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'; + +import fetchMock from '../index'; +import { expectTypeOf } from 'expect-type'; + +const humanVerbToMethods = [ + { humanVerb: 'Fetched', method: 'get' }, + { humanVerb: 'Got', method: 'get' }, + { humanVerb: 'Posted', method: 'post' }, + { humanVerb: 'Put', method: 'put' }, + { humanVerb: 'Deleted', method: 'delete' }, + { humanVerb: 'FetchedHead', method: 'head' }, + { humanVerb: 'Patched', method: 'patch' }, +] as const; + +// initialize a mock here so fetch is patched across all tests +fetchMock.mockGlobal(); + +describe.each([ + ['patched fetch input', fetch], + ['fetchMock input', fetchMock], +])('expect extensions %s', (_str, expectInput) => { + humanVerbToMethods.forEach(({ humanVerb, method }) => { describe(`${humanVerb} expectations`, () => { describe('when no calls', () => { beforeAll(() => { @@ -21,24 +28,30 @@ describe('expect extensions', () => { }); afterAll(() => fetchMock.mockReset()); it(`toHave${humanVerb} should be falsy`, () => { - expect(fetch).not[`toHave${humanVerb}`]('http://example.com/path'); + expect(expectInput).not[`toHave${humanVerb}`](); + expect(expectInput).not[`toHave${humanVerb}`]( + 'http://example.com/path', + ); }); it(`toHaveLast${humanVerb} should be falsy`, () => { - expect(fetch).not[`toHaveLast${humanVerb}`]( + expect(expectInput).not[`toHaveLast${humanVerb}`](); + expect(expectInput).not[`toHaveLast${humanVerb}`]( 'http://example.com/path', ); }); it(`toHaveNth${humanVerb} should be falsy`, () => { - expect(fetch).not[`toHaveNth${humanVerb}`]( + expect(expectInput).not[`toHaveNth${humanVerb}`](1); + expect(expectInput).not[`toHaveNth${humanVerb}`]( 1, 'http://example.com/path', ); }); it(`toHave${humanVerb}Times should be falsy`, () => { - expect(fetch).not[`toHave${humanVerb}Times`]( + expect(expectInput).not[`toHave${humanVerb}Times`](1); + expect(expectInput).not[`toHave${humanVerb}Times`]( 1, 'http://example.com/path', ); @@ -48,13 +61,13 @@ describe('expect extensions', () => { beforeAll(() => { fetchMock.mockGlobal().route('*', 200); fetch('http://example.com/path2', { - method: method || 'get', + method, headers: { test: 'header', }, }); fetch('http://example.com/path', { - method: method || 'get', + method, headers: { test: 'header', }, @@ -62,16 +75,22 @@ describe('expect extensions', () => { }); afterAll(() => fetchMock.mockReset()); + it('matches without any matcher supplied', () => { + expect(expectInput)[`toHave${humanVerb}`](); + }); + it('matches with just url', () => { - expect(fetch)[`toHave${humanVerb}`]('http://example.com/path'); + expect(expectInput)[`toHave${humanVerb}`]('http://example.com/path'); }); it('matches with fetch-mock matcher', () => { - expect(fetch)[`toHave${humanVerb}`]('begin:http://example.com/path'); + expect(expectInput)[`toHave${humanVerb}`]( + 'begin:http://example.com/path', + ); }); it('matches with matcher and options', () => { - expect(fetch)[`toHave${humanVerb}`]('http://example.com/path', { + expect(expectInput)[`toHave${humanVerb}`]('http://example.com/path', { headers: { test: 'header', }, @@ -79,15 +98,18 @@ describe('expect extensions', () => { }); it("doesn't match if matcher but not options is correct", () => { - expect(fetch).not[`toHave${humanVerb}`]('http://example.com/path', { - headers: { - test: 'not-header', + expect(expectInput).not[`toHave${humanVerb}`]( + 'http://example.com/path', + { + headers: { + test: 'not-header', + }, }, - }); + ); }); it("doesn't match if options but not matcher is correct", () => { - expect(fetch).not[`toHave${humanVerb}`]( + expect(expectInput).not[`toHave${humanVerb}`]( 'http://example-no.com/path', { headers: { @@ -96,12 +118,19 @@ describe('expect extensions', () => { }, ); }); + + it('should not be any', () => { + expectTypeOf(expect(expectInput)[`toHave${humanVerb}`]).not.toBeAny(); + expectTypeOf( + expect(expectInput).not[`toHave${humanVerb}`], + ).not.toBeAny(); + }); }); describe(`toHaveLast${humanVerb}`, () => { beforeAll(() => { fetchMock.mockGlobal().route('*', 200); fetch('http://example.com/path', { - method: method || 'get', + method, headers: { test: 'header', }, @@ -109,26 +138,35 @@ describe('expect extensions', () => { }); afterAll(() => fetchMock.mockReset()); + it('matches without any matcher supplied', () => { + expect(expectInput)[`toHaveLast${humanVerb}`](); + }); + it('matches with just url', () => { - expect(fetch)[`toHaveLast${humanVerb}`]('http://example.com/path'); + expect(expectInput)[`toHaveLast${humanVerb}`]( + 'http://example.com/path', + ); }); it('matches with fetch-mock matcher', () => { - expect(fetch)[`toHaveLast${humanVerb}`]( + expect(expectInput)[`toHaveLast${humanVerb}`]( 'begin:http://example.com/path', ); }); it('matches with matcher and options', () => { - expect(fetch)[`toHaveLast${humanVerb}`]('http://example.com/path', { - headers: { - test: 'header', + expect(expectInput)[`toHaveLast${humanVerb}`]( + 'http://example.com/path', + { + headers: { + test: 'header', + }, }, - }); + ); }); it("doesn't match if matcher but not options is correct", () => { - expect(fetch).not[`toHaveLast${humanVerb}`]( + expect(expectInput).not[`toHaveLast${humanVerb}`]( 'http://example.com/path', { headers: { @@ -139,7 +177,7 @@ describe('expect extensions', () => { }); it("doesn't match if options but not matcher is correct", () => { - expect(fetch).not[`toHaveLast${humanVerb}`]( + expect(expectInput).not[`toHaveLast${humanVerb}`]( 'http://example-no.com/path', { headers: { @@ -148,19 +186,28 @@ describe('expect extensions', () => { }, ); }); + + it('should not be any', () => { + expectTypeOf( + expect(expectInput)[`toHaveLast${humanVerb}`], + ).not.toBeAny(); + expectTypeOf( + expect(expectInput).not[`toHaveLast${humanVerb}`], + ).not.toBeAny(); + }); }); describe(`toHaveNth${humanVerb}`, () => { beforeAll(() => { fetchMock.mockGlobal().route('*', 200); fetch('http://example1.com/path', { - method: method || 'get', + method, headers: { test: 'header', }, }); fetch('http://example2.com/path', { - method: method || 'get', + method, headers: { test: 'header', }, @@ -168,19 +215,26 @@ describe('expect extensions', () => { }); afterAll(() => fetchMock.mockReset()); + it('matches without any matcher supplied', () => { + expect(expectInput)[`toHaveNth${humanVerb}`](2); + }); + it('matches with just url', () => { - expect(fetch)[`toHaveNth${humanVerb}`](2, 'http://example2.com/path'); + expect(expectInput)[`toHaveNth${humanVerb}`]( + 2, + 'http://example2.com/path', + ); }); it('matches with fetch-mock matcher', () => { - expect(fetch)[`toHaveNth${humanVerb}`]( + expect(expectInput)[`toHaveNth${humanVerb}`]( 2, 'begin:http://example2.com/path', ); }); it('matches with matcher and options', () => { - expect(fetch)[`toHaveNth${humanVerb}`]( + expect(expectInput)[`toHaveNth${humanVerb}`]( 2, 'http://example2.com/path', { @@ -192,7 +246,7 @@ describe('expect extensions', () => { }); it("doesn't match if matcher but not options is correct", () => { - expect(fetch).not[`toHaveNth${humanVerb}`]( + expect(expectInput).not[`toHaveNth${humanVerb}`]( 2, 'http://example2.com/path', { @@ -204,7 +258,7 @@ describe('expect extensions', () => { }); it("doesn't match if options but not matcher is correct", () => { - expect(fetch).not[`toHaveNth${humanVerb}`]( + expect(expectInput).not[`toHaveNth${humanVerb}`]( 2, 'http://example-no.com/path', { @@ -216,24 +270,33 @@ describe('expect extensions', () => { }); it("doesn't match if wrong n", () => { - expect(fetch).not[`toHaveNth${humanVerb}`]( + expect(expectInput).not[`toHaveNth${humanVerb}`]( 1, 'http://example2.com/path', ); }); + + it('should not be any', () => { + expectTypeOf( + expect(expectInput)[`toHaveNth${humanVerb}`], + ).not.toBeAny(); + expectTypeOf( + expect(expectInput).not[`toHaveNth${humanVerb}`], + ).not.toBeAny(); + }); }); describe(`toHave${humanVerb}Times`, () => { beforeAll(() => { fetchMock.mockGlobal().route('*', 200); fetch('http://example.com/path', { - method: method || 'get', + method, headers: { test: 'header', }, }); fetch('http://example.com/path', { - method: method || 'get', + method, headers: { test: 'header', }, @@ -241,22 +304,26 @@ describe('expect extensions', () => { }); afterAll(() => fetchMock.mockReset()); + it('matches without any matcher supplied', () => { + expect(expectInput)[`toHave${humanVerb}Times`](2); + }); + it('matches with just url', () => { - expect(fetch)[`toHave${humanVerb}Times`]( + expect(expectInput)[`toHave${humanVerb}Times`]( 2, 'http://example.com/path', ); }); it('matches with fetch-mock matcher', () => { - expect(fetch)[`toHave${humanVerb}Times`]( + expect(expectInput)[`toHave${humanVerb}Times`]( 2, 'begin:http://example.com/path', ); }); it('matches with matcher and options', () => { - expect(fetch)[`toHave${humanVerb}Times`]( + expect(expectInput)[`toHave${humanVerb}Times`]( 2, 'http://example.com/path', { @@ -268,7 +335,7 @@ describe('expect extensions', () => { }); it("doesn't match if matcher but not options is correct", () => { - expect(fetch).not[`toHave${humanVerb}Times`]( + expect(expectInput).not[`toHave${humanVerb}Times`]( 2, 'http://example.com/path', { @@ -280,7 +347,7 @@ describe('expect extensions', () => { }); it("doesn't match if options but not matcher is correct", () => { - expect(fetch).not[`toHave${humanVerb}Times`]( + expect(expectInput).not[`toHave${humanVerb}Times`]( 2, 'http://example-no.com/path', { @@ -292,18 +359,27 @@ describe('expect extensions', () => { }); it("doesn't match if too few calls", () => { - expect(fetch).not[`toHave${humanVerb}Times`]( + expect(expectInput).not[`toHave${humanVerb}Times`]( 1, 'http://example.com/path', ); }); it("doesn't match if too many calls", () => { - expect(fetch).not[`toHave${humanVerb}Times`]( + expect(expectInput).not[`toHave${humanVerb}Times`]( 3, 'http://example.com/path', ); }); + + it('should not be any', () => { + expectTypeOf( + expect(expectInput)[`toHave${humanVerb}Times`], + ).not.toBeAny(); + expectTypeOf( + expect(expectInput).not[`toHave${humanVerb}Times`], + ).not.toBeAny(); + }); }); }); }); @@ -326,15 +402,33 @@ describe('expect extensions', () => { }); afterAll(() => fetchMock.mockReset()); // it('toBeDone should be falsy only if routes defined', () => { - // expect(fetch).not.toBeDone(); - // expect(fetch).not.toBeDone('my-route'); + // expect(expectInput).not.toBeDone(); + // expect(expectInput).not.toBeDone('my-route'); // }); it('matches with just url', () => { - expect(fetch).toBeDone('route1'); + expect(expectInput).toBeDone('route1'); }); it("doesn't match if too few calls", () => { - expect(fetch).not.toBeDone('route2'); + expect(expectInput).not.toBeDone('route2'); + }); + + it('should not be any', () => { + expectTypeOf(expect(expectInput).toBeDone).not.toBeAny(); + expectTypeOf(expect(expectInput).not.toBeDone).not.toBeAny(); + }); + }); +}); + +describe('expect extensions: bad inputs', () => { + humanVerbToMethods.forEach(({ humanVerb }) => { + it(`${humanVerb} - throws an error if we the input is not patched with fetchMock`, () => { + expect(() => { + // This simulates a "fetch" implementation that doesn't have fetchMock + expect({})[`toHave${humanVerb}`]('http://example.com/path'); + }).toThrow( + 'Unable to get fetchMock instance! Please make sure you passed a patched fetch or fetchMock!', + ); }); }); }); diff --git a/packages/jest/src/index.ts b/packages/jest/src/index.ts index 81b1e257..5f695a9e 100644 --- a/packages/jest/src/index.ts +++ b/packages/jest/src/index.ts @@ -5,6 +5,8 @@ import { } from 'fetch-mock'; import './jest-extensions.js'; import type { Jest } from '@jest/environment'; +import type { FetchMockMatchers } from './types.js'; +export { FetchMockMatchers } from './types.js'; type MockResetOptions = { includeSticky: boolean; @@ -55,3 +57,27 @@ const fetchMockJest = new FetchMockJest({ }); export default fetchMockJest; + +/* eslint-disable @typescript-eslint/no-namespace */ +/** + * Export types on the expect object + */ +declare global { + namespace jest { + // Type-narrow expect for FetchMock + interface Expect { + (actual: FetchMock): FetchMockMatchers & { + not: FetchMockMatchers; + }; + (actual: typeof fetch): FetchMockMatchers & { + not: FetchMockMatchers; + }; + } + } +} +/* eslint-enable @typescript-eslint/no-namespace */ + +declare module '@jest/expect' { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface Matchers extends FetchMockMatchers {} +} diff --git a/packages/jest/src/jest-extensions.ts b/packages/jest/src/jest-extensions.ts index 09b927be..ca1d12fc 100644 --- a/packages/jest/src/jest-extensions.ts +++ b/packages/jest/src/jest-extensions.ts @@ -1,17 +1,40 @@ import { expect } from '@jest/globals'; import type { SyncExpectationResult } from 'expect'; -import type { +import { FetchMock, RouteName, CallHistoryFilter, UserRouteConfig, } from 'fetch-mock'; -const methodlessExtensions = { +import { + HumanVerbMethodNames, + HumanVerbs, + PatchedFetch, + RawFetchMockMatchers, +} from './types.js'; + +function getFetchMockFromInput(input: PatchedFetch | FetchMock) { + const fetchMock = (input as PatchedFetch)['fetchMock'] + ? (input as PatchedFetch).fetchMock + : input; + if (!fetchMock || !(fetchMock instanceof FetchMock)) { + throw new Error( + 'Unable to get fetchMock instance! Please make sure you passed a patched fetch or fetchMock!', + ); + } + return fetchMock; +} + +const methodlessExtensions: Pick< + RawFetchMockMatchers, + HumanVerbMethodNames<'Fetched'> +> = { toHaveFetched: ( - { fetchMock }: { fetchMock: FetchMock }, + input: PatchedFetch | FetchMock, filter: CallHistoryFilter, options: UserRouteConfig, ): SyncExpectationResult => { + const fetchMock = getFetchMockFromInput(input); if (fetchMock.callHistory.called(filter, options)) { return { pass: true, message: () => 'fetch was called as expected' }; } @@ -22,10 +45,11 @@ const methodlessExtensions = { }; }, toHaveLastFetched: ( - { fetchMock }: { fetchMock: FetchMock }, + input: PatchedFetch | FetchMock, filter: CallHistoryFilter, options: UserRouteConfig, ): SyncExpectationResult => { + const fetchMock = getFetchMockFromInput(input); const allCalls = fetchMock.callHistory.calls(); if (!allCalls.length) { return { @@ -48,11 +72,12 @@ const methodlessExtensions = { }, toHaveNthFetched: ( - { fetchMock }: { fetchMock: FetchMock }, + input: PatchedFetch | FetchMock, n: number, filter: CallHistoryFilter, options: UserRouteConfig, ): SyncExpectationResult => { + const fetchMock = getFetchMockFromInput(input); const nthCall = fetchMock.callHistory.calls()[n - 1]; const matchingCalls = fetchMock.callHistory.calls(filter, options); if (matchingCalls.some((call) => call === nthCall)) { @@ -69,11 +94,12 @@ const methodlessExtensions = { }, toHaveFetchedTimes: ( - { fetchMock }: { fetchMock: FetchMock }, + input: PatchedFetch | FetchMock, times: number, filter: CallHistoryFilter, options: UserRouteConfig, ): SyncExpectationResult => { + const fetchMock = getFetchMockFromInput(input); const calls = fetchMock.callHistory.calls(filter, options); if (calls.length === times) { return { @@ -93,9 +119,10 @@ expect.extend(methodlessExtensions); expect.extend({ toBeDone: ( - { fetchMock }: { fetchMock: FetchMock }, + input: PatchedFetch | FetchMock, routes: RouteName | RouteName[], ): SyncExpectationResult => { + const fetchMock = getFetchMockFromInput(input); const done = fetchMock.callHistory.done(routes); if (done) { return { pass: true, message: () => '' }; @@ -128,25 +155,24 @@ function scopeExpectationNameToMethod(name: string, humanVerb: string): string { return name.replace('Fetched', humanVerb); } -[ - 'Got:get', - 'Posted:post', - 'Put:put', - 'Deleted:delete', - 'FetchedHead:head', - 'Patched:patch', -].forEach((verbs) => { - const [humanVerb, method] = verbs.split(':'); +const expectMethodNameToMethodMap: { + [humanVerb in Exclude]: string; +} = { + Got: 'get', + Posted: 'post', + Put: 'put', + Deleted: 'delete', + FetchedHead: 'head', + Patched: 'patch', +}; - const extensions: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [key: string]: (...args: any[]) => SyncExpectationResult; - } = Object.fromEntries( +Object.entries(expectMethodNameToMethodMap).forEach(([humanVerb, method]) => { + const extensions = Object.fromEntries( Object.entries(methodlessExtensions).map(([name, func]) => [ scopeExpectationNameToMethod(name, humanVerb), scopeExpectationFunctionToMethod(func, method), ]), - ); + ) as Omit | 'toBeDone'>; expect.extend(extensions); }); diff --git a/packages/jest/src/types.ts b/packages/jest/src/types.ts new file mode 100644 index 00000000..1e9d1fdf --- /dev/null +++ b/packages/jest/src/types.ts @@ -0,0 +1,111 @@ +import type { + CallHistoryFilter, + FetchMock, + RouteName, + UserRouteConfig, +} from 'fetch-mock'; +import type { SyncExpectationResult } from 'expect'; + +export type HumanVerbs = + | 'Got' + | 'Posted' + | 'Put' + | 'Deleted' + | 'FetchedHead' + | 'Patched' + | 'Fetched'; + +/** + * Verify that a particular call for the HTTP method implied in the function name + * has occurred + */ +export type ToHaveFunc = ( + filter?: CallHistoryFilter, + options?: UserRouteConfig, +) => R; + +/** + * Verify that a particular Nth call for the HTTP method implied in the function name + * has occurred + */ +export type ToHaveNthFunc = ( + n: number, + filter?: CallHistoryFilter, + options?: UserRouteConfig, +) => R; + +/** + * Verify that a particular call for the HTTP method implied in the function name + * has been made N times + */ +export type ToHaveTimesFunc = ( + times: number, + filter?: CallHistoryFilter, + options?: UserRouteConfig, +) => R; + +/** + * Verify that a particular route names(s) has been called + */ +export type ToBeDoneFunc = (routes?: RouteName | RouteName[]) => R; + +export type FetchMockMatchers = { + toHaveFetched: ToHaveFunc; + toHaveLastFetched: ToHaveFunc; + toHaveFetchedTimes: ToHaveTimesFunc; + toHaveNthFetched: ToHaveNthFunc; + toHaveGot: ToHaveFunc; + toHaveLastGot: ToHaveFunc; + toHaveGotTimes: ToHaveTimesFunc; + toHaveNthGot: ToHaveNthFunc; + toHavePosted: ToHaveFunc; + toHaveLastPosted: ToHaveFunc; + toHavePostedTimes: ToHaveTimesFunc; + toHaveNthPosted: ToHaveNthFunc; + toHavePut: ToHaveFunc; + toHaveLastPut: ToHaveFunc; + toHavePutTimes: ToHaveTimesFunc; + toHaveNthPut: ToHaveNthFunc; + toHaveDeleted: ToHaveFunc; + toHaveLastDeleted: ToHaveFunc; + toHaveDeletedTimes: ToHaveTimesFunc; + toHaveNthDeleted: ToHaveNthFunc; + toHaveFetchedHead: ToHaveFunc; + toHaveLastFetchedHead: ToHaveFunc; + toHaveFetchedHeadTimes: ToHaveTimesFunc; + toHaveNthFetchedHead: ToHaveNthFunc; + toHavePatched: ToHaveFunc; + toHaveLastPatched: ToHaveFunc; + toHavePatchedTimes: ToHaveTimesFunc; + toHaveNthPatched: ToHaveNthFunc; + toBeDone: ToBeDoneFunc; +}; + +// types for use doing some intermediate type checking in extensions to make sure things don't get out of sync +/** + * This reflects the Object.assign that FetchMock does on the fetch function + */ +export type PatchedFetch = { + fetchMock: FetchMock; +}; + +/** + * This type allows us to take the Matcher type and creat another one + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type RawMatcher any> = ( + input: PatchedFetch | FetchMock, + ...args: Parameters +) => ReturnType; + +export type RawFetchMockMatchers = { + [k in keyof FetchMockMatchers]: RawMatcher< + FetchMockMatchers[k] + >; +}; + +export type HumanVerbMethodNames = + | `toHave${M}` + | `toHaveLast${M}` + | `toHave${M}Times` + | `toHaveNth${M}`; diff --git a/packages/vitest/CHANGELOG.md b/packages/vitest/CHANGELOG.md index f8735b66..878ececb 100644 --- a/packages/vitest/CHANGELOG.md +++ b/packages/vitest/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [0.2.7](https://github.com/wheresrhys/fetch-mock/compare/vitest-v0.2.6...vitest-v0.2.7) (2025-01-28) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * fetch-mock bumped from ^12.2.0 to ^12.2.1 + ## [0.2.6](https://github.com/wheresrhys/fetch-mock/compare/vitest-v0.2.5...vitest-v0.2.6) (2024-11-15) diff --git a/packages/vitest/package.json b/packages/vitest/package.json index 4e315517..04a67ebf 100644 --- a/packages/vitest/package.json +++ b/packages/vitest/package.json @@ -1,7 +1,7 @@ { "name": "@fetch-mock/vitest", "description": "Vitest wrapper for fetch-mock", - "version": "0.2.6", + "version": "0.2.7", "exports": { "browser": "./dist/esm/index.js", "import": { @@ -21,7 +21,7 @@ "node": ">=18.11.0" }, "dependencies": { - "fetch-mock": "^12.2.0" + "fetch-mock": "^12.2.1" }, "peerDependencies": { "vitest": "*"