Writing and Testing Custom Vite Plugins: Transforms, HMR, and Virtual Modules
Vite plugins extend the build pipeline with custom transforms, virtual modules, and build hooks. Testing them is harder than testing application code because a plugin's behavior is entangled with Vite's internals — module graphs, HMR events, rollup hooks. This guide covers a practical, reproducible approach to testing custom Vite plugins with Vitest.
Plugin API Fundamentals
A Vite plugin is an object that follows the Rollup plugin interface, extended with Vite-specific hooks:
import type { Plugin } from 'vite'
export function myPlugin(): Plugin {
return {
name: 'vite-plugin-my',
// Rollup hooks
transform(code, id) { /* ... */ },
resolveId(source) { /* ... */ },
load(id) { /* ... */ },
// Vite-specific hooks
configureServer(server) { /* ... */ },
handleHotUpdate(ctx) { /* ... */ },
transformIndexHtml(html) { /* ... */ },
}
}The key insight for testing: most hooks are pure functions of their inputs. transform(code, id) returns transformed code. resolveId(source) returns a resolved ID or null. These can be tested directly without starting a Vite server.
Testing transform Hooks
The transform hook is the most common hook to test. It receives the source code and the file path, and returns transformed code:
// plugins/vite-plugin-env-replace.ts
import type { Plugin } from 'vite'
interface Options {
envMap: Record<string, string>
}
export function envReplacePlugin(options: Options): Plugin {
const { envMap } = options
return {
name: 'vite-plugin-env-replace',
transform(code: string, id: string) {
// Only transform .ts and .js files
if (!/\.[jt]sx?$/.test(id)) return null
let result = code
for (const [placeholder, value] of Object.entries(envMap)) {
result = result.replaceAll(`__${placeholder}__`, value)
}
// Return null if no changes (Vite optimization)
return result === code ? null : { code: result, map: null }
},
}
}Test the transform hook directly:
// plugins/vite-plugin-env-replace.test.ts
import { describe, it, expect } from 'vitest'
import { envReplacePlugin } from './vite-plugin-env-replace'
describe('envReplacePlugin', () => {
const plugin = envReplacePlugin({
envMap: {
API_URL: 'https://api.example.com',
VERSION: '1.0.0',
},
})
// Cast to access the transform hook directly
const transform = plugin.transform as (
code: string,
id: string
) => { code: string; map: null } | null
it('replaces placeholders in JS files', () => {
const code = `const url = "__API_URL__/users"`
const result = transform(code, 'src/api.ts')
expect(result?.code).toBe(`const url = "https://api.example.com/users"`)
})
it('replaces multiple placeholders', () => {
const code = `const config = { url: "__API_URL__", version: "__VERSION__" }`
const result = transform(code, 'src/config.ts')
expect(result?.code).toBe(
`const config = { url: "https://api.example.com", version: "1.0.0" }`
)
})
it('returns null for unchanged code', () => {
const code = `const x = 1`
const result = transform(code, 'src/index.ts')
expect(result).toBeNull()
})
it('skips non-JS files', () => {
const code = `body { background: __API_URL__; }`
const result = transform(code, 'src/style.css')
expect(result).toBeNull()
})
})Testing resolveId and load — Virtual Modules
Virtual modules are a common Vite pattern — they generate code at build time from configuration or file system data:
// plugins/vite-plugin-routes.ts
import type { Plugin } from 'vite'
import { readdirSync, statSync } from 'node:fs'
import { join, relative } from 'node:path'
const VIRTUAL_MODULE_ID = 'virtual:routes'
const RESOLVED_ID = '\0virtual:routes'
export function routesPlugin(pagesDir: string): Plugin {
return {
name: 'vite-plugin-routes',
resolveId(id) {
if (id === VIRTUAL_MODULE_ID) return RESOLVED_ID
return null
},
load(id) {
if (id !== RESOLVED_ID) return null
const routes = scanPages(pagesDir)
const code = generateRoutesCode(routes)
return code
},
}
}
function scanPages(dir: string): string[] {
try {
return readdirSync(dir)
.filter((f) => f.endsWith('.vue') || f.endsWith('.tsx'))
.map((f) => f.replace(/\.(vue|tsx)$/, ''))
} catch {
return []
}
}
function generateRoutesCode(pages: string[]): string {
const routeEntries = pages
.map((page) => ` { path: '/${page === 'index' ? '' : page}', name: '${page}' }`)
.join(',\n')
return `export const routes = [\n${routeEntries}\n]`
}Testing virtual module plugins:
// plugins/vite-plugin-routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { routesPlugin } from './vite-plugin-routes'
// Mock the filesystem
vi.mock('node:fs', () => ({
readdirSync: vi.fn(),
}))
import { readdirSync } from 'node:fs'
describe('routesPlugin', () => {
const plugin = routesPlugin('/app/pages')
const resolveId = plugin.resolveId as (id: string) => string | null
const load = plugin.load as (id: string) => string | null
describe('resolveId', () => {
it('resolves the virtual module id', () => {
expect(resolveId('virtual:routes')).toBe('\0virtual:routes')
})
it('returns null for other ids', () => {
expect(resolveId('src/main.ts')).toBeNull()
expect(resolveId('react')).toBeNull()
})
})
describe('load', () => {
beforeEach(() => {
vi.mocked(readdirSync).mockReturnValue(['index.vue', 'about.vue', 'contact.tsx'] as any)
})
it('generates route code for the virtual module', () => {
const code = load('\0virtual:routes')
expect(code).toContain(`path: '/'`)
expect(code).toContain(`path: '/about'`)
expect(code).toContain(`path: '/contact'`)
})
it('returns null for non-virtual modules', () => {
expect(load('src/main.ts')).toBeNull()
expect(load('\0other-virtual')).toBeNull()
})
it('handles empty pages directory', () => {
vi.mocked(readdirSync).mockReturnValue([])
const code = load('\0virtual:routes')
expect(code).toBe('export const routes = [\n\n]')
})
})
})Testing with Vite's build API
For integration tests that exercise the full build pipeline, use Vite's programmatic build API:
// plugins/vite-plugin-banner.test.ts
import { describe, it, expect } from 'vitest'
import { build } from 'vite'
import { bannerPlugin } from './vite-plugin-banner'
describe('bannerPlugin integration', () => {
it('prepends banner to output chunks', async () => {
const result = await build({
root: '/tmp',
build: {
write: false, // Don't write to disk
rollupOptions: {
input: {
main: 'virtual:entry',
},
},
},
plugins: [
// Provide a virtual entry for testing
{
name: 'test-entry',
resolveId: (id) => id === 'virtual:entry' ? '\0virtual:entry' : null,
load: (id) => id === '\0virtual:entry' ? 'export const x = 1' : null,
},
bannerPlugin({ banner: '/* © 2025 My Company */' }),
],
logLevel: 'silent',
})
const output = Array.isArray(result) ? result[0] : result
const chunk = output.output.find(
(f): f is import('rollup').OutputChunk => f.type === 'chunk' && f.isEntry
)
expect(chunk?.code).toMatch(/^\/\* © 2025 My Company \*\//)
})
})Testing transformIndexHtml
The transformIndexHtml hook modifies index.html before it is written. Test it directly without a build:
// plugins/vite-plugin-inject-meta.ts
import type { Plugin, IndexHtmlTransformResult } from 'vite'
interface MetaTag {
name: string
content: string
}
export function injectMetaPlugin(tags: MetaTag[]): Plugin {
return {
name: 'vite-plugin-inject-meta',
transformIndexHtml(html): IndexHtmlTransformResult {
return {
html,
tags: tags.map((tag) => ({
tag: 'meta',
attrs: { name: tag.name, content: tag.content },
injectTo: 'head',
})),
}
},
}
}// plugins/vite-plugin-inject-meta.test.ts
import { describe, it, expect } from 'vitest'
import { injectMetaPlugin } from './vite-plugin-inject-meta'
describe('injectMetaPlugin', () => {
const plugin = injectMetaPlugin([
{ name: 'description', content: 'My app description' },
{ name: 'author', content: 'My Company' },
])
const transformIndexHtml = plugin.transformIndexHtml as (
html: string
) => { html: string; tags: unknown[] }
it('injects meta tags into head', () => {
const result = transformIndexHtml('<html><head></head><body></body></html>')
expect(result.tags).toHaveLength(2)
expect(result.tags[0]).toMatchObject({
tag: 'meta',
attrs: { name: 'description', content: 'My app description' },
injectTo: 'head',
})
})
it('preserves original HTML', () => {
const html = '<!DOCTYPE html><html><head></head></html>'
const result = transformIndexHtml(html)
expect(result.html).toBe(html)
})
})Testing HMR — handleHotUpdate
HMR testing is the hardest part because it involves Vite's internal module graph. Use a minimal mock of the HMR context:
// plugins/vite-plugin-i18n.ts
import type { Plugin, HmrContext, ModuleNode } from 'vite'
export function i18nPlugin(localesDir: string): Plugin {
return {
name: 'vite-plugin-i18n',
handleHotUpdate(ctx: HmrContext) {
// Invalidate i18n virtual module when any locale file changes
if (ctx.file.startsWith(localesDir) && ctx.file.endsWith('.json')) {
const virtualModule = ctx.server.moduleGraph.getModuleById('\0virtual:i18n')
if (virtualModule) {
ctx.server.moduleGraph.invalidateModule(virtualModule)
return [virtualModule]
}
}
},
}
}// plugins/vite-plugin-i18n.test.ts
import { describe, it, expect, vi } from 'vitest'
import type { HmrContext, ModuleNode } from 'vite'
import { i18nPlugin } from './vite-plugin-i18n'
function createMockHmrContext(file: string, virtualModule?: Partial<ModuleNode>): HmrContext {
const mockModule = virtualModule
? { id: '\0virtual:i18n', ...virtualModule } as ModuleNode
: undefined
return {
file,
timestamp: Date.now(),
modules: [],
read: vi.fn(),
server: {
moduleGraph: {
getModuleById: vi.fn().mockReturnValue(mockModule),
invalidateModule: vi.fn(),
},
} as unknown as HmrContext['server'],
}
}
describe('i18nPlugin HMR', () => {
const plugin = i18nPlugin('/app/locales')
const handleHotUpdate = plugin.handleHotUpdate as (ctx: HmrContext) => ModuleNode[] | undefined
it('invalidates virtual module when locale file changes', () => {
const ctx = createMockHmrContext('/app/locales/en.json', {})
const affected = handleHotUpdate(ctx)
expect(ctx.server.moduleGraph.invalidateModule).toHaveBeenCalled()
expect(affected).toHaveLength(1)
})
it('returns undefined for non-locale files', () => {
const ctx = createMockHmrContext('/app/src/component.vue')
const affected = handleHotUpdate(ctx)
expect(affected).toBeUndefined()
})
it('returns undefined when virtual module is not in graph', () => {
const ctx = createMockHmrContext('/app/locales/fr.json', undefined)
vi.mocked(ctx.server.moduleGraph.getModuleById).mockReturnValue(undefined)
const affected = handleHotUpdate(ctx)
expect(affected).toBeUndefined()
})
})Testing configureServer
The configureServer hook adds custom middleware to Vite's development server:
// plugins/vite-plugin-api-proxy.ts
import type { Plugin, ViteDevServer } from 'vite'
export function apiProxyPlugin(targetUrl: string): Plugin {
return {
name: 'vite-plugin-api-proxy',
configureServer(server: ViteDevServer) {
server.middlewares.use('/api-proxy', async (req, res) => {
const upstream = `${targetUrl}${req.url}`
try {
const response = await fetch(upstream)
res.statusCode = response.status
res.setHeader('Content-Type', response.headers.get('content-type') ?? 'text/plain')
res.end(await response.text())
} catch {
res.statusCode = 502
res.end('Bad Gateway')
}
})
},
}
}// plugins/vite-plugin-api-proxy.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { apiProxyPlugin } from './vite-plugin-api-proxy'
vi.stubGlobal('fetch', vi.fn())
describe('apiProxyPlugin middleware', () => {
let middlewareFn: (req: Partial<IncomingMessage>, res: Partial<ServerResponse>) => void
beforeEach(() => {
const plugin = apiProxyPlugin('https://api.example.com')
const mockServer = {
middlewares: {
use: vi.fn((path: string, fn: typeof middlewareFn) => {
middlewareFn = fn
}),
},
}
plugin.configureServer!(mockServer as any)
})
it('proxies requests to the target URL', async () => {
vi.mocked(fetch).mockResolvedValue({
status: 200,
headers: { get: () => 'application/json' },
text: async () => '{"ok": true}',
} as unknown as Response)
const req = { url: '/users' } as IncomingMessage
const res = { statusCode: 0, setHeader: vi.fn(), end: vi.fn() } as unknown as ServerResponse
await middlewareFn(req, res)
expect(fetch).toHaveBeenCalledWith('https://api.example.com/users')
expect(res.end).toHaveBeenCalledWith('{"ok": true}')
})
it('returns 502 on upstream failure', async () => {
vi.mocked(fetch).mockRejectedValue(new Error('Network error'))
const req = { url: '/fail' } as IncomingMessage
const res = { statusCode: 0, setHeader: vi.fn(), end: vi.fn() } as unknown as ServerResponse
await middlewareFn(req, res)
expect((res as any).statusCode).toBe(502)
expect(res.end).toHaveBeenCalledWith('Bad Gateway')
})
})CI Configuration
# .github/workflows/plugin-tests.yml
name: Vite Plugin Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run plugin unit tests
run: npx vitest run
- name: Run plugin integration tests
run: npx vitest run --config vitest.integration.config.tsDesign Principles for Testable Plugins
Separate hook implementations from side effects. A transform hook that reads from a global cache is hard to test. Extract the pure transformation logic into a standalone function, and call it from the hook. Test the function directly.
Use factories, not singletons. Plugin state (caches, watchers) should live inside the plugin factory function, not at module level. Each test gets a fresh plugin instance with clean state.
Prefer returning null over identity transforms. A transform hook that returns { code, map: null } when nothing changed triggers unnecessary re-processing. Returning null tells Vite to skip the module — and makes tests cleaner (you test that the hook returns null for excluded files).
Don't test Vite internals. If your test verifies that Vite's module graph has a certain structure, you are testing Vite, not your plugin. Test your plugin's observable behavior: what code it returns, which modules it marks as affected, what middleware it registers.
Custom Vite plugins are easier to test than they appear. Most hooks are pure functions of their inputs, and the remaining ones (HMR, server middleware) can be tested with lightweight mocks of the server context.