[READ-ONLY] Mirror of https://github.com/danielroe/nuxt-vitest. An vitest environment with support for testing code that needs a Nuxt runtime environment
nuxt nuxt-module testing unit-testing vitest
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: add `mockComponent` utility (#47)

authored by

Anthony Fu and committed by
GitHub
(Jan 26, 2023, 1:39 PM +0100) af33fa04 7b7ffcdd

+447 -138
+4
.eslintrc.cjs
··· 1 1 module.exports = { 2 2 extends: ['@nuxt/eslint-config'], 3 + rules: { 4 + 'vue/require-default-prop': 'off', 5 + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 6 + } 3 7 }
+52
README.md
··· 105 105 // your tests here 106 106 ``` 107 107 108 + 109 + ### `mockComponent` 110 + 111 + `mockComponent` allows you to mock Nuxt's component. 112 + The first argument can be the component name in PascalCase, or the relative path of the component. 113 + The second argument can is a factory function that returns the mocked component. 114 + 115 + For example, to mock `MyComponent`, you can: 116 + 117 + ```ts 118 + import { mockComponent } from 'nuxt-vitest/utils' 119 + 120 + mockComponent('MyComponent', { 121 + props: { 122 + value: String 123 + }, 124 + setup(props) { 125 + // ... 126 + } 127 + }) 128 + 129 + // relative path or alias also works 130 + mockComponent('~/components/my-component.vue', async () => { 131 + // or a factory function 132 + return { 133 + setup(props) { 134 + // ... 135 + } 136 + } 137 + }) 138 + 139 + // or you can use SFC for redirecting to a mock component 140 + mockComponent('MyComponent', () => import('./MockComponent.vue')) 141 + 142 + // your tests here 143 + ``` 144 + 145 + > **Note**: You can't reference to local variables in the factory function since they are hoisted. If you need to access Vue APIs or other variables, you need to import them in your factory function. 146 + 147 + ```ts 148 + mockComponent('MyComponent', async () => { 149 + const { ref, h } = await import('vue') 150 + 151 + return { 152 + setup(props) { 153 + const counter = ref(0) 154 + return () => h('div', null, counter.value) 155 + } 156 + } 157 + }) 158 + ``` 159 + 108 160 ## 💻 Development 109 161 110 162 - Clone this repository
+1 -1
playground/modules/custom.ts
··· 4 4 meta: { 5 5 name: 'custom', 6 6 }, 7 - setup(_, nuxt) { 7 + setup(_, _nuxt) { 8 8 console.log('From custom module!') 9 9 }, 10 10 })
+1 -1
playground/plugins/auth.ts
··· 1 - export default defineNuxtPlugin(nuxtApp => { 1 + export default defineNuxtPlugin(_nuxtApp => { 2 2 return { 3 3 provide: { 4 4 auth: {
+2 -2
packages/vitest-environment-nuxt/src/module.ts
··· 1 1 import { defineNuxtModule, installModule } from '@nuxt/kit' 2 - import autoImportMock from './modules/auto-import-mock' 2 + import mockTransform from './modules/mock' 3 3 4 4 export default defineNuxtModule({ 5 5 meta: { 6 6 name: 'vitest-env', 7 7 }, 8 8 async setup() { 9 - await installModule(autoImportMock) 9 + await installModule(mockTransform) 10 10 }, 11 11 })
+1 -1
packages/vitest-environment-nuxt/src/utils.ts
··· 1 - export { registerEndpoint, mockNuxtImport } from './runtime/mock' 1 + export { registerEndpoint, mockNuxtImport, mockComponent } from './runtime/mock' 2 2 export { mountSuspended } from './runtime/mount'
+21
playground/tests/nuxt/mock-component-1.spec.ts
··· 1 + import { expect, it } from 'vitest' 2 + import { mockComponent, mountSuspended } from 'vitest-environment-nuxt/utils' 3 + import App from '~/app.vue' 4 + 5 + mockComponent('SomeComponent', async () => { 6 + const { h } = await import('vue') 7 + return { 8 + setup() { 9 + return () => h('div', null, 'Mocked') 10 + }, 11 + } 12 + }) 13 + 14 + it('should mock', async () => { 15 + const component = await mountSuspended(App) 16 + expect(component.html()).toMatchInlineSnapshot(` 17 + "<div>Mocked</div> 18 + <!-- TODO: <NuxtPage /> --> 19 + <a href=\\"/test\\">Test link</a>" 20 + `) 21 + })
+14
playground/tests/nuxt/mock-component-2.spec.ts
··· 1 + import { expect, it } from 'vitest' 2 + import { mockComponent, mountSuspended } from 'vitest-environment-nuxt/utils' 3 + import App from '~/app.vue' 4 + 5 + mockComponent('SomeComponent', () => import('./mocks/MockComponent.vue')) 6 + 7 + it('should mock', async () => { 8 + const component = await mountSuspended(App) 9 + expect(component.html()).toMatchInlineSnapshot(` 10 + "<div> Mocked 1 * 2 = 2</div> 11 + <!-- TODO: <NuxtPage /> --> 12 + <a href=\\"/test\\">Test link</a>" 13 + `) 14 + })
-129
packages/vitest-environment-nuxt/src/modules/auto-import-mock.ts
··· 1 - import type { Import } from 'unimport' 2 - import { addVitePlugin, defineNuxtModule } from '@nuxt/kit' 3 - import { walk } from 'estree-walker' 4 - import type { CallExpression } from 'estree' 5 - import { AcornNode } from 'rollup' 6 - import MagicString from 'magic-string' 7 - 8 - const HELPER_NAME = 'mockNuxtImport' 9 - 10 - export interface MockInfo { 11 - name: string 12 - import: Import 13 - factory: string 14 - start: number 15 - end: number 16 - } 17 - 18 - /** 19 - * This module is a macro that transforms `mockNuxtImport()` to `vi.mock()`, 20 - * which make it possible to mock Nuxt imports. 21 - */ 22 - export default defineNuxtModule({ 23 - meta: { 24 - name: 'nuxt:vitest:auto-import-mock', 25 - }, 26 - setup(_, nuxt) { 27 - let imports: Import[] = [] 28 - 29 - nuxt.hook('imports:extend', _ => { 30 - imports = _ 31 - }) 32 - 33 - addVitePlugin({ 34 - name: 'nuxt:vitest:auto-import-mock', 35 - enforce: 'post', 36 - transform: { 37 - order: 'post', 38 - handler(code, id) { 39 - if (!code.includes(HELPER_NAME)) return 40 - if (id.includes('/node_modules/')) return 41 - 42 - let ast: AcornNode 43 - try { 44 - ast = this.parse(code, { 45 - sourceType: 'module', 46 - ecmaVersion: 'latest', 47 - ranges: true, 48 - }) 49 - } catch (e) { 50 - return 51 - } 52 - 53 - const mocks: MockInfo[] = [] 54 - 55 - walk(ast as any, { 56 - enter: node => { 57 - if (node.type !== 'CallExpression') return 58 - const call = node as CallExpression 59 - if ( 60 - call.callee.type !== 'Identifier' || 61 - call.callee.name !== HELPER_NAME 62 - ) { 63 - return 64 - } 65 - if (call.arguments.length !== 2) { 66 - return 67 - } 68 - if (call.arguments[0].type !== 'Literal') { 69 - return // TODO: warn 70 - } 71 - const name = call.arguments[0].value as string 72 - const importItem = imports.find(_ => name === (_.as || _.name)) 73 - if (!importItem) { 74 - return this.error(`Cannot find import "${name}" to mock`) 75 - } 76 - mocks.push({ 77 - name, 78 - import: importItem, 79 - factory: code.slice( 80 - call.arguments[1].range![0], 81 - call.arguments[1].range![1] 82 - ), 83 - start: call.range![0], 84 - end: call.range![1], 85 - }) 86 - }, 87 - }) 88 - 89 - if (!mocks.length) return 90 - 91 - const s = new MagicString(code) 92 - 93 - const mockMap = new Map<string, MockInfo[]>() 94 - for (const mock of mocks) { 95 - s.overwrite(mock.start, mock.end, '') 96 - if (!mockMap.has(mock.import.from)) { 97 - mockMap.set(mock.import.from, []) 98 - } 99 - mockMap.get(mock.import.from)!.push(mock) 100 - } 101 - 102 - const mockCode = [...mockMap.entries()] 103 - .map(([from, mocks]) => { 104 - const lines = [ 105 - `vi.mock(${JSON.stringify(from)}, async (importOriginal) => {`, 106 - ` const mod = { ...await importOriginal() }`, 107 - ] 108 - for (const mock of mocks) { 109 - lines.push( 110 - ` mod[${JSON.stringify(mock.name)}] = (${mock.factory})()` 111 - ) 112 - } 113 - lines.push(` return mod`) 114 - lines.push(`})`) 115 - return lines.join('\n') 116 - }) 117 - .join('\n') 118 - 119 - s.prepend('\nimport {vi} from "vitest";\n' + mockCode + '\n\n') 120 - 121 - return { 122 - code: s.toString(), 123 - map: s.generateMap(), 124 - } 125 - }, 126 - }, 127 - }) 128 - }, 129 - })
+214
packages/vitest-environment-nuxt/src/modules/mock.ts
··· 1 + import type { Import } from 'unimport' 2 + import { addVitePlugin, defineNuxtModule } from '@nuxt/kit' 3 + import { walk } from 'estree-walker' 4 + import type { CallExpression } from 'estree' 5 + import { AcornNode } from 'rollup' 6 + import MagicString from 'magic-string' 7 + import { Component } from '@nuxt/schema' 8 + 9 + const PLUGIN_NAME = 'nuxt:vitest:mock-transform' 10 + 11 + const HELPER_MOCK_IMPORT = 'mockNuxtImport' 12 + const HELPER_MOCK_COMPONENT = 'mockComponent' 13 + 14 + const HELPERS_NAME = [HELPER_MOCK_IMPORT, HELPER_MOCK_COMPONENT] 15 + 16 + export interface MockImportInfo { 17 + name: string 18 + import: Import 19 + factory: string 20 + } 21 + 22 + export interface MockComponentInfo { 23 + path: string 24 + factory: string 25 + } 26 + 27 + /** 28 + * This module is a macro that transforms `mockNuxtImport()` to `vi.mock()`, 29 + * which make it possible to mock Nuxt imports. 30 + */ 31 + export default defineNuxtModule({ 32 + meta: { 33 + name: PLUGIN_NAME, 34 + }, 35 + setup(_, nuxt) { 36 + let imports: Import[] = [] 37 + let components: Component[] = [] 38 + 39 + nuxt.hook('imports:extend', _ => { 40 + imports = _ 41 + }) 42 + nuxt.hook('components:extend', _ => { 43 + components = _ 44 + }) 45 + 46 + addVitePlugin({ 47 + name: PLUGIN_NAME, 48 + enforce: 'post', 49 + transform: { 50 + order: 'post', 51 + handler(code, id) { 52 + if (!HELPERS_NAME.some(n => code.includes(n))) return 53 + if (id.includes('/node_modules/')) return 54 + 55 + let ast: AcornNode 56 + try { 57 + ast = this.parse(code, { 58 + sourceType: 'module', 59 + ecmaVersion: 'latest', 60 + ranges: true, 61 + }) 62 + } catch (e) { 63 + return 64 + } 65 + 66 + const s = new MagicString(code) 67 + const mocksImport: MockImportInfo[] = [] 68 + const mocksComponent: MockComponentInfo[] = [] 69 + 70 + walk(ast as any, { 71 + enter: node => { 72 + if (node.type !== 'CallExpression') return 73 + const call = node as CallExpression 74 + // mockNuxtImport 75 + if ( 76 + call.callee.type === 'Identifier' && 77 + call.callee.name === HELPER_MOCK_IMPORT 78 + ) { 79 + if (call.arguments.length !== 2) { 80 + return this.error( 81 + new Error( 82 + `${HELPER_MOCK_IMPORT}() should have exactly 2 arguments` 83 + ), 84 + call.range![0] 85 + ) 86 + } 87 + if (call.arguments[0].type !== 'Literal') { 88 + return this.error( 89 + new Error( 90 + `The first argument of ${HELPER_MOCK_IMPORT}() must be a string literal` 91 + ), 92 + call.arguments[0].range![0] 93 + ) 94 + } 95 + const name = call.arguments[0].value as string 96 + const importItem = imports.find(_ => name === (_.as || _.name)) 97 + if (!importItem) { 98 + return this.error(`Cannot find import "${name}" to mock`) 99 + } 100 + 101 + s.overwrite(call.range![0], call.range![1], '') 102 + mocksImport.push({ 103 + name, 104 + import: importItem, 105 + factory: code.slice( 106 + call.arguments[1].range![0], 107 + call.arguments[1].range![1] 108 + ), 109 + }) 110 + } 111 + // mockComponent 112 + if ( 113 + call.callee.type === 'Identifier' && 114 + call.callee.name === HELPER_MOCK_COMPONENT 115 + ) { 116 + if (call.arguments.length !== 2) { 117 + return this.error( 118 + new Error( 119 + `${HELPER_MOCK_COMPONENT}() should have exactly 2 arguments` 120 + ), 121 + call.range![0] 122 + ) 123 + } 124 + if (call.arguments[0].type !== 'Literal') { 125 + return this.error( 126 + new Error( 127 + `The first argument of ${HELPER_MOCK_COMPONENT}() must be a string literal` 128 + ), 129 + call.arguments[0].range![0] 130 + ) 131 + } 132 + const pathOrName = call.arguments[0].value as string 133 + const component = components.find( 134 + _ => _.pascalName === pathOrName || _.kebabName === pathOrName 135 + ) 136 + const path = component?.filePath || pathOrName 137 + 138 + s.overwrite(call.range![0], call.range![1], '') 139 + mocksComponent.push({ 140 + path: path, 141 + factory: code.slice( 142 + call.arguments[1].range![0], 143 + call.arguments[1].range![1] 144 + ), 145 + }) 146 + } 147 + }, 148 + }) 149 + 150 + if (mocksImport.length === 0 && mocksComponent.length === 0) return 151 + 152 + const mockLines = [] 153 + 154 + if (mocksImport.length) { 155 + const mockImportMap = new Map<string, MockImportInfo[]>() 156 + for (const mock of mocksImport) { 157 + if (!mockImportMap.has(mock.import.from)) { 158 + mockImportMap.set(mock.import.from, []) 159 + } 160 + mockImportMap.get(mock.import.from)!.push(mock) 161 + } 162 + mockLines.push( 163 + ...Array.from(mockImportMap.entries()).flatMap( 164 + ([from, mocks]) => { 165 + const lines = [ 166 + `vi.mock(${JSON.stringify( 167 + from 168 + )}, async (importOriginal) => {`, 169 + ` const mod = { ...await importOriginal() }`, 170 + ] 171 + for (const mock of mocks) { 172 + lines.push( 173 + ` mod[${JSON.stringify(mock.name)}] = await (${ 174 + mock.factory 175 + })()` 176 + ) 177 + } 178 + lines.push(` return mod`) 179 + lines.push(`})`) 180 + return lines 181 + } 182 + ) 183 + ) 184 + } 185 + 186 + if (mocksComponent.length) { 187 + mockLines.push( 188 + ...mocksComponent.flatMap(mock => { 189 + return [ 190 + `vi.mock(${JSON.stringify(mock.path)}, async () => {`, 191 + ` const factory = (${mock.factory});`, 192 + ` const result = typeof factory === 'function' ? await factory() : await factory`, 193 + ` return 'default' in result ? result : { default: result }`, 194 + '})', 195 + ] 196 + }) 197 + ) 198 + } 199 + 200 + if (!mockLines.length) return 201 + 202 + s.prepend( 203 + '\nimport {vi} from "vitest";\n' + mockLines.join('\n') + '\n\n' 204 + ) 205 + 206 + return { 207 + code: s.toString(), 208 + map: s.generateMap(), 209 + } 210 + }, 211 + }, 212 + }) 213 + }, 214 + })
+127 -4
packages/vitest-environment-nuxt/src/runtime/mock.ts
··· 1 1 import { defineEventHandler } from 'h3' 2 2 import type { EventHandler } from 'h3' 3 + import type { 4 + ComponentInjectOptions, 5 + ComponentOptionsMixin, 6 + ComponentOptionsWithArrayProps, 7 + ComponentOptionsWithObjectProps, 8 + ComponentOptionsWithoutProps, 9 + ComponentPropsOptions, 10 + ComputedOptions, 11 + EmitsOptions, 12 + MethodOptions, 13 + RenderFunction, 14 + SetupContext, 15 + } from 'vue' 16 + 17 + export type Awaitable<T> = T | Promise<T> 18 + export type OptionalFunction<T> = T | (() => Awaitable<T>) 3 19 4 20 export function registerEndpoint(url: string, handler: EventHandler) { 5 21 // @ts-expect-error private property ··· 11 27 } 12 28 13 29 export function mockNuxtImport<T = any>( 14 - name: string, 15 - factory: () => T | Promise<T> 16 - ) { 17 - throw new Error('mockNuxtImport() is a macro and it did not get transpiled') 30 + _name: string, 31 + _factory: () => T | Promise<T> 32 + ): void { 33 + throw new Error( 34 + 'mockNuxtImport() is a macro and it did not get transpiled, this may be an internal bug of nuxt-vitest.' 35 + ) 36 + } 37 + 38 + /** 39 + * Mock a component, the first argument can be the relative path to the component, or the component name in PascalCase. 40 + */ 41 + export function mockComponent<Props, RawBindings = object>( 42 + path: string, 43 + setup: OptionalFunction< 44 + (props: Readonly<Props>, ctx: SetupContext) => RawBindings | RenderFunction 45 + > 46 + ): void 47 + export function mockComponent< 48 + Props = {}, 49 + RawBindings = {}, 50 + D = {}, 51 + C extends ComputedOptions = {}, 52 + M extends MethodOptions = {}, 53 + Mixin extends ComponentOptionsMixin = ComponentOptionsMixin, 54 + Extends extends ComponentOptionsMixin = ComponentOptionsMixin, 55 + E extends EmitsOptions = {}, 56 + EE extends string = string, 57 + I extends ComponentInjectOptions = {}, 58 + II extends string = string 59 + >( 60 + path: string, 61 + options: OptionalFunction< 62 + ComponentOptionsWithoutProps< 63 + Props, 64 + RawBindings, 65 + D, 66 + C, 67 + M, 68 + Mixin, 69 + Extends, 70 + E, 71 + EE, 72 + I, 73 + II 74 + > 75 + > 76 + ): void 77 + export function mockComponent< 78 + PropNames extends string, 79 + RawBindings, 80 + D, 81 + C extends ComputedOptions = {}, 82 + M extends MethodOptions = {}, 83 + Mixin extends ComponentOptionsMixin = ComponentOptionsMixin, 84 + Extends extends ComponentOptionsMixin = ComponentOptionsMixin, 85 + E extends EmitsOptions = {}, 86 + EE extends string = string, 87 + I extends ComponentInjectOptions = {}, 88 + II extends string = string 89 + >( 90 + path: string, 91 + options: OptionalFunction< 92 + ComponentOptionsWithArrayProps< 93 + PropNames, 94 + RawBindings, 95 + D, 96 + C, 97 + M, 98 + Mixin, 99 + Extends, 100 + E, 101 + EE, 102 + I, 103 + II 104 + > 105 + > 106 + ): void 107 + export function mockComponent< 108 + PropsOptions extends Readonly<ComponentPropsOptions>, 109 + RawBindings, 110 + D, 111 + C extends ComputedOptions = {}, 112 + M extends MethodOptions = {}, 113 + Mixin extends ComponentOptionsMixin = ComponentOptionsMixin, 114 + Extends extends ComponentOptionsMixin = ComponentOptionsMixin, 115 + E extends EmitsOptions = {}, 116 + EE extends string = string, 117 + I extends ComponentInjectOptions = {}, 118 + II extends string = string 119 + >( 120 + path: string, 121 + options: OptionalFunction< 122 + ComponentOptionsWithObjectProps< 123 + PropsOptions, 124 + RawBindings, 125 + D, 126 + C, 127 + M, 128 + Mixin, 129 + Extends, 130 + E, 131 + EE, 132 + I, 133 + II 134 + > 135 + > 136 + ): void 137 + export function mockComponent(_path: string, _component: any): void { 138 + throw new Error( 139 + 'mockComponent() is a macro and it did not get transpiled, this may be an internal bug of nuxt-vitest.' 140 + ) 18 141 }
+10
playground/tests/nuxt/mocks/MockComponent.vue
··· 1 + <script setup lang="ts"> 2 + const counter = ref(1) 3 + const doubled = computed(() => counter.value * 2) 4 + </script> 5 + 6 + <template> 7 + <div> 8 + Mocked {{ counter }} * 2 = {{ doubled }} 9 + </div> 10 + </template>