diff --git a/README.md b/README.md index acabde7e..9354209a 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,30 @@ When you run your tests within `vitest-environment-nuxt`, they will be running i This means you should be take particular care not to mutate the global state in your tests (or, if you have, to reset it afterwards). +## 🛠️ Helpers + +`vitest-environment-nuxt` provides a number of helpers to make testing Nuxt apps easier. + +### `mountSuspended` + +// TODO: + +### `mockNuxtImport` + +`mockNuxtImport` allows you to mock Nuxt's auto import functionality. For example, to mock `useStorage`, you can do so like this: + +```ts +import { mockNuxtImport } from 'vitest-environment-nuxt/utils' + +mockNuxtImport('useStorage', () => { + return () => { + return { value: 'mocked storage' } + } +}) + +// your tests here +``` + ## 💻 Development - Clone this repository diff --git a/package.json b/package.json index d6415ad6..0876806d 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,8 @@ "h3": "^1.0.2", "happy-dom": "^8.1.0", "ofetch": "^1.0.0", + "magic-string": "^0.27.0", + "estree-walker": "^3.0.1", "unenv": "^1.0.0" }, "devDependencies": { diff --git a/playground/composables/auto-import-mock.ts b/playground/composables/auto-import-mock.ts new file mode 100644 index 00000000..5c662b5e --- /dev/null +++ b/playground/composables/auto-import-mock.ts @@ -0,0 +1,7 @@ +export function useAutoImportedTarget() { + return 'the original' +} + +export function useAutoImportedNonTarget() { + return 'the original' +} diff --git a/playground/modules/custom.ts b/playground/modules/custom.ts new file mode 100644 index 00000000..dd3e273b --- /dev/null +++ b/playground/modules/custom.ts @@ -0,0 +1,10 @@ +import { defineNuxtModule } from "@nuxt/kit" + +export default defineNuxtModule({ + meta: { + name: 'custom', + }, + setup(_, nuxt) { + console.log('From custom module!') + } +}) diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 0ad5d70d..36425ee4 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -1,4 +1,6 @@ // https://v3.nuxtjs.org/api/configuration/nuxt.config export default defineNuxtConfig({ - + modules: [ + '~/modules/custom' + ] }) diff --git a/playground/tests/auto-import-mock.spec.ts b/playground/tests/auto-import-mock.spec.ts new file mode 100644 index 00000000..afa76e6f --- /dev/null +++ b/playground/tests/auto-import-mock.spec.ts @@ -0,0 +1,11 @@ +import { expect, it } from 'vitest' +import { mockNuxtImport } from 'vitest-environment-nuxt/utils' + +mockNuxtImport('useAutoImportedTarget', () => { + return () => 'mocked!' +}) + +it('should mock', () => { + expect(useAutoImportedTarget()).toMatchInlineSnapshot('"mocked!"') + expect(useAutoImportedNonTarget()).toMatchInlineSnapshot('"the original"') +}) diff --git a/playground/tests/auto-import.spec.ts b/playground/tests/auto-import.spec.ts new file mode 100644 index 00000000..d916aa36 --- /dev/null +++ b/playground/tests/auto-import.spec.ts @@ -0,0 +1,6 @@ +import { expect, it } from 'vitest' + +it('should not mock', () => { + expect(useAutoImportedTarget()).toMatchInlineSnapshot('"the original"') + expect(useAutoImportedNonTarget()).toMatchInlineSnapshot('"the original"') +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a22e8e26..ad8e2532 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,10 +16,12 @@ importers: eslint: latest eslint-config-prettier: latest eslint-plugin-prettier: latest + estree-walker: ^3.0.1 h3: ^1.0.2 happy-dom: ^8.1.0 husky: latest lint-staged: latest + magic-string: ^0.27.0 nuxt: 3.0.0 ofetch: ^1.0.0 pinst: latest @@ -32,8 +34,10 @@ importers: dependencies: '@nuxt/kit': 3.0.0 '@vue/test-utils': 2.2.6_vue@3.2.45 + estree-walker: 3.0.1 h3: 1.0.2 happy-dom: 8.1.0 + magic-string: 0.27.0 ofetch: 1.0.0 unenv: 1.0.0 devDependencies: @@ -2255,7 +2259,7 @@ packages: dev: true /concat-map/0.0.1: - resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} dev: true /concat-stream/2.0.0: @@ -5144,7 +5148,6 @@ packages: engines: {node: '>=12'} dependencies: '@jridgewell/sourcemap-codec': 1.4.14 - dev: true /make-dir/3.1.0: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} diff --git a/src/config.ts b/src/config.ts index 80388c08..16ee5253 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,12 +1,14 @@ import { loadNuxt, buildNuxt } from '@nuxt/kit' import type { InlineConfig as VitestConfig } from 'vitest' import { InlineConfig, mergeConfig, defineConfig } from 'vite' +import autoImportMock from './modules/auto-import-mock' // https://github.com/nuxt/framework/issues/6496 async function getViteConfig(rootDir = process.cwd()) { const nuxt = await loadNuxt({ cwd: rootDir, dev: false, + ready: false, overrides: { ssr: false, app: { @@ -14,6 +16,9 @@ async function getViteConfig(rootDir = process.cwd()) { }, }, }) + nuxt.options.modules.push(autoImportMock) + await nuxt.ready() + return new Promise((resolve, reject) => { nuxt.hook('vite:extendConfig', config => { resolve(config) diff --git a/src/modules/auto-import-mock.ts b/src/modules/auto-import-mock.ts new file mode 100644 index 00000000..94e8f96b --- /dev/null +++ b/src/modules/auto-import-mock.ts @@ -0,0 +1,127 @@ +import type { Import } from 'unimport' +import { addVitePlugin, defineNuxtModule } from '@nuxt/kit' +import { walk } from 'estree-walker' +import type { CallExpression } from 'estree' +import { AcornNode } from 'rollup' +import MagicString from 'magic-string' + +const HELPER_NAME = 'mockNuxtImport' + +export interface MockInfo { + name: string + import: Import + factory: string + start: number + end: number +} + +/** + * This module is a macro that transforms `mockNuxtImport()` to `vi.mock()`, + * which make it possible to mock Nuxt imports. + */ +export default defineNuxtModule({ + meta: { + name: 'vitest:auto-import-mock', + }, + setup(_, nuxt) { + let imports: Import[] = [] + + nuxt.hook('imports:extend', _ => { + imports = _ + }) + + addVitePlugin({ + name: 'nuxt:auto-import-mock', + transform(code, id) { + if (!code.includes(HELPER_NAME)) return + if (id.includes('/node_modules/')) return + + let ast: AcornNode + try { + ast = this.parse(code, { + sourceType: 'module', + ecmaVersion: 'latest', + ranges: true, + }) + } catch (e) { + return + } + + const mocks: MockInfo[] = [] + + walk(ast, { + enter: node => { + if (node.type !== 'CallExpression') return + const call = node as CallExpression + if ( + call.callee.type !== 'Identifier' || + call.callee.name !== HELPER_NAME + ) { + return + } + if (call.arguments.length !== 2) { + return + } + if (call.arguments[0].type !== 'Literal') { + return // TODO: warn + } + const name = call.arguments[0].value as string + const importItem = imports.find(_ => name === (_.as || _.name)) + if (!importItem) { + return this.error(`Cannot find import "${name}" to mock`) + } + mocks.push({ + name, + import: importItem, + factory: code.slice( + call.arguments[1].range![0], + call.arguments[1].range![1] + ), + start: call.range![0], + end: call.range![1], + }) + }, + }) + + if (!mocks.length) return + + const s = new MagicString(code) + + const mockMap = new Map() + for (const mock of mocks) { + s.overwrite(mock.start, mock.end, '') + if (!mockMap.has(mock.import.from)) { + mockMap.set(mock.import.from, []) + } + mockMap.get(mock.import.from)!.push(mock) + } + + const mockCode = [...mockMap.entries()] + .map(([from, mocks]) => { + const lines = [ + `vi.mock(${JSON.stringify(from)}, async () => {`, + ` const mod = { ...await vi.importActual(${JSON.stringify( + from + )}) }`, + ] + for (const mock of mocks) { + lines.push( + ` mod[${JSON.stringify(mock.name)}] = (${mock.factory})()` + ) + } + lines.push(` return mod`) + lines.push(`})`) + return lines.join('\n') + }) + .join('\n') + + s.append('\nimport {vi} from "vitest";\n' + mockCode) + + return { + code: s.toString(), + map: s.generateMap(), + } + }, + }) + }, +}) diff --git a/src/runtime/mock.ts b/src/runtime/mock.ts index 6836ffde..955dbd2f 100644 --- a/src/runtime/mock.ts +++ b/src/runtime/mock.ts @@ -9,3 +9,10 @@ export function registerEndpoint(url: string, handler: EventHandler) { // @ts-expect-error private property window.__registry.add(url) } + +export function mockNuxtImport( + name: string, + factory: () => T | Promise +) { + throw new Error('mockNuxtImport() is a macro and it did not get transpiled') +} diff --git a/src/utils.ts b/src/utils.ts index d9e37d0e..faee86ea 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,2 +1,2 @@ -export { registerEndpoint } from './runtime/mock' +export { registerEndpoint, mockNuxtImport } from './runtime/mock' export { mountSuspended } from './runtime/mount'