Volt can register JavaScript and TypeScript test files as ordinary ExUnit tests. You still run one command:
mix test
The JS/TS runner is Volt-native: test files are bundled with OXC, executed in QuickBEAM, and reported through ExUnit. There is no separate mix volt.test command and no Vitest/Node process to manage.
Setup
Add the Volt test installer to test/test_helper.exs after ExUnit.start/1:
ExUnit.start(exclude: [:integration])
Volt.Test.ExUnit.install()By default, Volt looks under assets/ for:
**/*.{test,spec}.{js,ts,jsx,tsx}Configure discovery with config :volt, :test:
config :volt, :test,
root: "assets",
include: ["**/*.{test,spec}.{js,ts,jsx,tsx}"],
exclude: ["vendor/**", "node_modules/**"],
timeout: 30_000Test files are bundled with Volt.Builder.bundle/1, so the bundle: key accepts normal Volt build graph options such as :plugins, :aliases, :node_modules, :resolve_dirs, :loaders, and :define:
config :volt, :test,
root: "assets",
bundle: [
plugins: [Volt.Plugin.React],
aliases: %{"@" => "assets/js"}
]Use bundle: for source-graph concerns and the top-level test options for discovery/execution concerns.
You can also pass options directly from test/test_helper.exs, which is useful for package dogfood tests or custom fixture roots:
Volt.Test.ExUnit.install(
root: "test/volt/test/fixtures",
include: ["core_api.test.ts"]
)Profile-specific configuration follows the rest of Volt:
config :volt, :my_app_web,
test: [root: "apps/my_app_web/assets", include: ["**/*.test.ts"]]Then install that profile:
Volt.Test.ExUnit.install(profile: :my_app_web)Writing tests
Import the Vitest-like API from volt:test:
import { describe, test, expect, beforeEach, afterEach } from 'volt:test'
import { add } from './math'
describe('add', () => {
beforeEach(() => {
// setup
})
test('adds numbers', () => {
expect(add(1, 2)).toBe(3)
})
})Async tests can return promises or use async functions:
test('loads data', async () => {
const value = await Promise.resolve(42)
expect(value).toBe(42)
})Relative imports are bundled before execution:
import { frameworkName } from './support'
test('uses helper module', () => {
expect(frameworkName).toContain('volt')
})Inline ExUnit assertions
For small checks that belong next to ordinary Elixir tests, use Volt.Test.Case and assert against a JavaScript-like sigil:
defmodule MyApp.FrontendTest do
use Volt.Test.Case, async: false
test "formats a label" do
assert ~TS"""
const label: string = "hello volt"
expect(label.toUpperCase()).toBe("HELLO VOLT")
"""
end
endVolt.Test.Case keeps normal ExUnit assertions working and adds inline support for ~JS, ~TS, ~JSX, and ~TSX. The snippet is wrapped as one Volt JavaScript test. Top-level imports stay top-level, so snippets can import application code:
defmodule MyApp.CounterTest do
use Volt.Test.Case, async: false
test "uses app code" do
assert ~TS"""
import { label } from "app/counter"
expect(label()).toBe("Count")
"""
end
endInline assertions use the same global config :volt, :test settings as file-based tests. Put aliases and framework plugins in config/test.exs when they are shared:
config :volt, :test,
bundle: [
aliases: %{"app" => "assets/js"},
plugins: [Volt.Plugin.React]
]Module-level options are useful when a group of inline assertions needs a different environment:
defmodule MyApp.BrowserSnippetTest do
use Volt.Test.Case, async: false, browser: true
test "updates the DOM" do
assert ~TS"""
document.body.innerHTML = "<button>Save</button>"
expect(document.querySelector("button")?.textContent).toBe("Save")
"""
end
endUse file-based tests for larger JS suites, repeated setup, or behavior that is primarily owned by browser code. Use inline assertions for compact checks that read better inside an ExUnit module.
Test and suite modifiers
Skip or mark tests as TODO:
test.skip('not ready yet', () => {
throw new Error('will not run')
})
test.todo('add coverage for browser behavior')Suites support the same modifiers:
describe.skip('external service', () => {
test('calls service', () => {
// skipped
})
})
describe.todo('future behavior', () => {
test('documents planned coverage')
})You can also skip dynamically from the test context:
test('platform-specific behavior', ({ skip }) => {
skip(!navigator.userAgent.includes('Firefox'), 'Firefox only')
expect(true).toBe(true)
})Parameterized tests
Use test.each for table-driven tests:
test.each([
[1, 2, 3],
[2, 3, 5]
])('adds %d + %d = %d', (left, right, total) => {
expect(Number(left) + Number(right)).toBe(total)
})Use describe.each to repeat a suite for multiple values:
describe.each(['en', 'fr'])('locale %s', (locale) => {
test('has a locale code', () => {
expect(String(locale)).toHaveLength(2)
})
})Supported placeholders in each-test names are %s, %d, %i, %f, %j, and %o.
Matchers
Volt includes a small core matcher set:
expect(value).toBe(expected)
expect(value).toEqual(expected)
expect(value).toContain(expected)
expect(value).toMatch(/pattern/)
expect(value).toHaveLength(3)
expect(value).toHaveProperty('nested.value', 42)
expect(value).toBeDefined()
expect(value).toBeUndefined()
expect(value).toBeTruthy()
expect(value).toBeFalsy()
expect(value).toBeNull()
expect(value).toBeNaN()
expect(value).toBeCloseTo(0.3, 5)
expect(value).toBeGreaterThan(1)
expect(value).toBeGreaterThanOrEqual(1)
expect(value).toBeLessThan(10)
expect(value).toBeLessThanOrEqual(10)
expect(fn).toThrow('message')All matchers support .not:
expect('volt').not.toContain('vite')
expect(1 + 1).not.toBe(3)Browser tests
Use browser: true when tests need real browser globals such as window, document, layout APIs, or DOM events:
Volt.Test.ExUnit.install(
root: "test/browser",
include: ["**/*.test.ts"],
browser: true
)Browser tests still use the same volt:test API and still register one ExUnit test per JS test(...). They are best suited for browser-owned runtime behavior such as DOM helpers, client preload logic, and other code that needs real browser globals. Keep Elixir-owned behavior such as manifests, Plug responses, cache state, and build output in ordinary ExUnit tests.
import { test, expect } from 'volt:test'
test('updates the DOM', () => {
document.body.innerHTML = '<button id="save">Save</button>'
expect(document.querySelector('#save')?.textContent).toBe('Save')
})Volt runs these tests through PlaywrightEx. Install Playwright for local browser execution:
npm install --save-dev playwright
npx playwright install chromium
By default Volt uses node_modules/.bin/playwright when present, otherwise playwright from PATH. Pass PlaywrightEx supervisor options with playwright: [...] if your executable lives elsewhere.
ExUnit integration
Each collected JS/TS test(...) becomes an ExUnit test. That means normal ExUnit filtering, formatters, failures, and CI behavior continue to work.
Volt adds useful tags:
:js— JS/TS test generated by Volt:volt_file— source test file path:volt_test_id— collected test id inside the file:volt_tags— tags passed from JS test options
For example:
test('slow calculation', { tags: ['slow'] }, () => {
expect(1 + 1).toBe(2)
})Skipped and TODO tests are registered as skipped ExUnit tests, so they are visible in normal ExUnit output.
ExUnit fixture sigils
When an Elixir test needs readable JS/TS input, import Volt.Test.Sigils:
import Volt.Test.Sigils
source = ~TS"""
export const answer: number = 42
"""Use the v modifier to parse-check a snippet with OXC:
source = ~TS"export const value: string = 'ok'"vAvailable sigils: ~JS, ~TS, ~JSX, ~TSX, ~CSS, and ~HTML.
Current scope
The QuickBEAM runner is intended for fast JS/TS unit tests. The browser runner is intended for tests that genuinely need a browser environment. Higher-level Phoenix end-to-end flows can still use PlaywrightEx or Phoenix-oriented browser-test helpers alongside Volt's JS test runner.