diff --git a/__tests__/push.spec.ts b/__tests__/push.spec.ts index ca8fa13..d74d3c5 100644 --- a/__tests__/push.spec.ts +++ b/__tests__/push.spec.ts @@ -1,5 +1,12 @@ +import { _InferSpyType } from '../src/autoSpy' import { getRouter, createRouterMock } from '../src' +declare module '../src' { + interface RouterMockSpy { + // spy: jest.Mock, Parameters> + } +} + describe('router.push mock', () => { it('still calls push for non valid routes', async () => { const router = getRouter() diff --git a/package.json b/package.json index e928480..8db4010 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "vue-router-mock", "version": "0.1.3", "description": "Easily test your components by mocking the router", - "main": "dist/index.cjs", + "main": "dist/index.mjs", "module": "dist/index.mjs", "types": "dist/index.d.ts", "sideEffects": false, diff --git a/src/autoSpy.ts b/src/autoSpy.ts new file mode 100644 index 0000000..fac1a85 --- /dev/null +++ b/src/autoSpy.ts @@ -0,0 +1,87 @@ +import { getJestGlobal } from './testers/jest' +import { getSinonGlobal } from './testers/sinon' +import { getVitestGlobal } from './testers/vitest' + +/** + * Creates a spy on a function + * + * @param fn function to spy on + * @returns [spy, mockClear] + */ +export function createSpy any>( + fn: Fn, + spyFactory?: RouterMockSpyOptions +): [_InferSpyType, () => void] { + if (spyFactory) { + const spy = spyFactory.create(fn) + return [spy, () => spyFactory.reset(spy)] + } + + const sinon = getSinonGlobal() + if (sinon) { + const spy = sinon.spy(fn) + return [spy as unknown as _InferSpyType, () => spy.resetHistory()] + } + + const jest = getVitestGlobal() || getJestGlobal() + if (jest) { + const spy = jest.fn(fn) + return [spy as unknown as _InferSpyType, () => spy.mockClear()] + } + + console.error( + `Couldn't detect a global spy (tried jest and sinon). Make sure to provide a "spy.create" option when creating the router mock.` + ) + throw new Error('No Spy Available') +} + +/** + * Options passed to the `spy` option of the `createRouterMock` function + */ +export interface RouterMockSpyOptions { + /** + * Creates a spy (for example, `create: fn => vi.fn(fn)` with vitest) + */ + create: (...args: any[]) => any + + /** + * Resets a spy but keeps it active. + */ + reset: (spy: _InferSpyType) => void + + /** + * Restores the original function given to the spy. + */ + restore: (spy: _InferSpyType) => void +} + +/** + * Define your own Spy to adapt to your testing framework (jest, peeky, sinon, vitest, etc) + * @beta: still trying out, could change in the future + * + * @example + * ```ts + * import 'vue-router-mock' // Only needed on external d.ts files + * + * declare module 'vue-router-mock' { + * export interface RouterMockSpy { + * spy: Sinon.Spy, ReturnType> + * } + * } + * ``` + */ +export interface RouterMockSpy< + Fn extends (...args: any[]) => any = (...args: any[]) => any +> { + // cannot be added or it wouldn't be extensible + // spy: any +} + +/** + * @internal + */ +export type _InferSpyType< + Fn extends (...args: any[]) => any = (...args: any[]) => any + // @ts-ignore: the version with Record<'spy', any> doesn't work... +> = keyof RouterMockSpy extends 'spy' ? RouterMockSpy['spy'] : Fn +// > = RouterMockSpy extends Record<'spy', any> ? RouterMockSpy['spy'] : Fn diff --git a/src/index.ts b/src/index.ts index 8387b86..024599f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,3 +2,4 @@ export { injectRouterMock, createProvide } from './injections' export { createRouterMock, EmptyView } from './router' export type { RouterMock, RouterMockOptions } from './router' export { plugin as VueRouterMock, getRouter } from './plugin' +export type { RouterMockSpy, RouterMockSpyOptions } from './autoSpy' diff --git a/src/router.ts b/src/router.ts index 55ea1f4..48d9a3d 100644 --- a/src/router.ts +++ b/src/router.ts @@ -11,49 +11,13 @@ import { RouterOptions, START_LOCATION, } from 'vue-router' -import type { SinonStatic } from 'sinon' +import { createSpy, RouterMockSpyOptions, _InferSpyType } from './autoSpy' export const EmptyView = defineComponent({ name: 'RouterMockEmptyView', render: () => null, }) -declare const sinon: SinonStatic | undefined -function getSinonGlobal() { - return typeof sinon !== 'undefined' && sinon -} - -function getJestGlobal() { - return typeof jest !== 'undefined' && jest -} - -/** - * Creates a spy on a function and allows clearing the mock. - * - * @param fn function to spy on - * @returns [spy, mockClear()] - */ -function createSpy any>( - fn: Fn -): [Fn, () => void] { - const sinon = getSinonGlobal() - if (sinon) { - const spy = sinon.spy(fn) - return [spy as unknown as Fn, () => spy.resetHistory()] - } - - const jest = getJestGlobal() - if (jest) { - const spy = jest.fn(fn) - return [spy as unknown as Fn, () => spy.mockClear()] - } - - console.error( - `Couldn't detect a global spy (tried jest and sinon). Make sure to provide a "createSpy" option when creating the router mock.` - ) - throw new Error('No Spy Available') -} - /** * Router Mock instance */ @@ -110,11 +74,12 @@ export interface RouterMock extends Router { * to reset the router state before each test. */ reset(): void -} -/** - * TODO: Allow passing a custom spy and detect common global ones like jest and cypress. - */ + push: _InferSpyType + replace: _InferSpyType + // FIXME: it doesn't seem to work for overloads + // addRoute: _InferSpyType +} /** * Options passed to `createRouterMock()`. @@ -155,6 +120,22 @@ export interface RouterMockOptions extends Partial { * disable that behavior and throw when `router.push()` fails. */ noUndeclaredRoutes?: boolean + + /** + * By default the mock will use sinon or jest support to create and restore spies. + * This option allows to use a different testing framework, + * by providing a method to create spies, and one to restore them. + * For example, with vitest: + * ``` + * const router = createRouterMock({ + * spy: { + * create: fn => vi.fn(fn), + * restore: spy => () => spy.restore() + * } + * }); + * ``` + */ + spy?: RouterMockSpyOptions } /** @@ -183,6 +164,7 @@ export function createRouterMock(options: RouterMockOptions = {}): RouterMock { runInComponentGuards, useRealNavigation, noUndeclaredRoutes, + spy, } = options const initialLocation = options.initialLocation || START_LOCATION @@ -203,16 +185,17 @@ export function createRouterMock(options: RouterMockOptions = {}): RouterMock { // @ts-ignore: this should be valid return addRoute(parentRecordName, record) - } + }, + spy ) const [pushMock, pushMockClear] = createSpy((to: RouteLocationRaw) => { return consumeNextReturn(to) - }) + }, spy) const [replaceMock, replaceMockClear] = createSpy((to: RouteLocationRaw) => { return consumeNextReturn(to, { replace: true }) - }) + }, spy) router.push = pushMock router.replace = replaceMock @@ -332,6 +315,9 @@ export function createRouterMock(options: RouterMockOptions = {}): RouterMock { return { ...router, + push: pushMock, + replace: replaceMock, + addRoute: addRouteMock, depth, setNextGuardReturn, getPendingNavigation, diff --git a/src/testers/jest.ts b/src/testers/jest.ts new file mode 100644 index 0000000..a07f9e8 --- /dev/null +++ b/src/testers/jest.ts @@ -0,0 +1,3 @@ +export function getJestGlobal() { + return typeof jest !== 'undefined' && jest +} diff --git a/src/testers/sinon.ts b/src/testers/sinon.ts new file mode 100644 index 0000000..d854181 --- /dev/null +++ b/src/testers/sinon.ts @@ -0,0 +1,7 @@ +import type { SinonStatic } from 'sinon' + +declare const sinon: SinonStatic | undefined + +export function getSinonGlobal() { + return typeof sinon !== 'undefined' && sinon +} diff --git a/src/testers/vitest.ts b/src/testers/vitest.ts new file mode 100644 index 0000000..49fb60b --- /dev/null +++ b/src/testers/vitest.ts @@ -0,0 +1,6 @@ +// cannot import the actual typ +declare const vi: typeof jest + +export function getVitestGlobal() { + return typeof vi !== 'undefined' && vi +}