Writing and Testing Custom Vite Plugins: Transforms, HMR, and Virtual Modules

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.ts

Design 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.

Read more