Skip to main content

guides/features/testing.md

# JavaScript and TypeScript Testing

Volt can register JavaScript and TypeScript test files as ordinary ExUnit tests. You still run one command:

```bash
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`:

```elixir
ExUnit.start(exclude: [:integration])

Volt.Test.ExUnit.install()
```

By default, Volt looks under `assets/` for:

```text
**/*.{test,spec}.{js,ts,jsx,tsx}
```

Configure discovery with `config :volt, :test`:

```elixir
config :volt, :test,
  root: "assets",
  include: ["**/*.{test,spec}.{js,ts,jsx,tsx}"],
  exclude: ["vendor/**", "node_modules/**"],
  timeout: 30_000
```

Test 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`:

```elixir
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:

```elixir
Volt.Test.ExUnit.install(
  root: "test/volt/test/fixtures",
  include: ["core_api.test.ts"]
)
```

Profile-specific configuration follows the rest of Volt:

```elixir
config :volt, :my_app_web,
  test: [root: "apps/my_app_web/assets", include: ["**/*.test.ts"]]
```

Then install that profile:

```elixir
Volt.Test.ExUnit.install(profile: :my_app_web)
```

## Writing tests

Import the Vitest-like API from `volt:test`:

```javascript
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:

```javascript
test('loads data', async () => {
  const value = await Promise.resolve(42)
  expect(value).toBe(42)
})
```

Relative imports are bundled before execution:

```javascript
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:

```elixir
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
end
```

`Volt.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:

```elixir
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
end
```

Inline 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:

```elixir
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:

```elixir
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
end
```

Use 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:

```javascript
test.skip('not ready yet', () => {
  throw new Error('will not run')
})

test.todo('add coverage for browser behavior')
```

Suites support the same modifiers:

```javascript
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:

```javascript
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:

```javascript
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:

```javascript
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:

```javascript
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`:

```javascript
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:

```elixir
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.

```javascript
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:

```bash
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:

```javascript
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`:

```elixir
import Volt.Test.Sigils

source = ~TS"""
export const answer: number = 42
"""
```

Use the `v` modifier to parse-check a snippet with OXC:

```elixir
source = ~TS"export const value: string = 'ok'"v
```

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