Guides
Testing
Set the locale per test, assert through the message functions, build the tree before Vitest or Jest runs.
// @vitest-environment jsdomimport { render, screen } from '@testing-library/react'import { setLocale } from 'loclizr'import { beforeEach, expect, test } from 'vitest'import { Cart } from '../src/Cart'import * as m from '../src/loclizr/messages'
beforeEach(() => { setLocale('de', { persist: false })})
test('renders German', () => { render(<Cart count={3} />) expect(screen.getByText(m.cart_items({ count: 3 }, { locale: 'de' }), { exact: false })).toBeTruthy()})
test('one call can override the locale', () => { expect(m.cart_items({ count: 3 }, { locale: 'en' })).toBe('3 items in your cart')})A message is a plain function: no provider to wrap, no module to mock, no fixture catalog. The
Vitest blocks here ran under vitest 5 with jsdom 30 against a two-message catalog (cart.greeting,
cart.items, in en and de). Jest needs one config line first: see Jest.
Set up
Section titled “Set up”-
Build the tree before vitest
Section titled “Build the tree before vitest”package.json "pretest": "loclizr build","test": "vitest run"Tests import
src/loclizr/messages, whichbuildwrites and the default.gitignoreexcludes, so a fresh clone has nothing to import. A team that commits the tree (delete the.gitignoreonce) needs no script. -
Set the locale in
Section titled “Set the locale in beforeEach”beforeEachsetLocale(locale, { persist: false })sets the store and skips the cookie. A single assertion can pass{ locale }per call instead.
pretest runs |
On a catalog error |
|---|---|
loclizr build |
the test run fails |
loclizr build --no-fail |
reported; exits 0 once output was written, so tests run |
In CI, run check before test, since pretest rewrites the record check compares
(Continuous integration).
Quiet the <html lang> warning
Section titled “Quiet the <html lang> warning”jsdom’s <html> has no lang, so a file that renders a useLocale() component before any
setLocale prints one warning:
stderr | test/greeting.test.tsx > renders the source localeloclizr: <html> carries no lang attribute, so a client with no cookie has nothing to read. Render lang from the Content-Language header withLocale sets.setLocale writes lang, so a file that sets the locale in beforeEach stays quiet. For the rest,
set lang to your source locale in a setup file:
if (typeof document !== 'undefined') document.documentElement.lang = 'en'import { defineConfig } from 'vitest/config'
export default defineConfig({ test: { setupFiles: ['./test/setup.ts'] },})Vitest reads a vitest.config.ts in place of vite.config.ts, so an app that already has a
vite.config.ts adds the test line there instead.
The guard skips files on Node’s environment, such as the server test below. Detection reads lang
only when no cookie exists, and there it finds the source locale it would fall back to anyway, so
no test renders differently. Jest takes the same file under setupFiles.
Detection latches on the first read
Section titled “Detection latches on the first read”// @vitest-environment jsdomimport { getLocale, setLocale } from 'loclizr'import { expect, test } from 'vitest'import * as m from '../src/loclizr/messages'
test('detection latches on the first read', () => { expect(getLocale()).toBe('en') document.cookie = 'locale=de; path=/' expect(getLocale()).toBe('en') expect(m.cart_greeting({ name: 'Ada' })).toBe('Hi Ada, your cart is ready') setLocale('de', { persist: false }) expect(m.cart_greeting({ name: 'Ada' })).toBe('Hallo Ada, dein Warenkorb ist bereit')})Set the locale, not the cookie, before the first render: a cookie written after the first
getLocale() read is never consulted. A cookie-detection test writes document.cookie first, so
that file’s first read is the one that latches.
Node and server rendering
Section titled “Node and server rendering”import { renderToString } from 'react-dom/server'import { runWithLocale } from 'loclizr/server'import { expect, test } from 'vitest'import { Cart } from '../src/Cart'import * as m from '../src/loclizr/messages'
test('renders inside a request scope', () => { const html = runWithLocale('de', () => renderToString(<Cart count={3} />)) expect(html).toContain(m.cart_greeting({ name: 'Ada' }, { locale: 'de' })) expect(html).toContain('3 Artikel im Warenkorb')})runWithLocale opens the scope withLocale would: the production path. setLocale throws inside
it; pass { locale } per call for another language.
Where loclizr/server is loaded, a message or getLocale() call outside a scope renders the source
locale and warns once outside production, which is the sign a render escaped its scope. A call that
passes { locale } never warns.
Assert through the message function
Section titled “Assert through the message function”expect(html).toContain(m.cart_greeting({ name: 'Ada' }, { locale: 'de' }))A literal is a second copy of the catalog, so a copy edit breaks a test that was never about copy. Keep a literal only where the text itself is the subject.
loclizr ships ESM only: no entry of the package has a require build, and the generated
src/loclizr/ tree is ESM too. Jest compiles your files to CommonJS and skips
node_modules, so a CommonJS project fails before the first test runs:
FAIL test/cart.test.tsx ● Test suite failed to run
Must use import to load ES Module: .../node_modules/loclizr/dist/index.js
The file contains ESM syntax (import/export) that could not be executed as CommonJS. Either: - Configure a transform (e.g. babel-jest) that compiles this file to CommonJS (see https://jestjs.io/docs/code-transformation) - If the file is in "node_modules", allow it to be transformed by adjusting "transformIgnorePatterns" (see https://jestjs.io/docs/configuration#transformignorepatterns-arraystring) - Use Node v24.9+ where Jest supports require(esm) natively (see https://jestjs.io/docs/ecmascript-modules#require-of-esm)
1 | /** @jest-environment jsdom */ 2 | import { render, screen } from '@testing-library/react' > 3 | import { setLocale } from 'loclizr' | ^ 4 | import { Cart } from '../src/Cart' 5 | import * as m from '../src/loclizr/messages' 6 |Let babel-jest compile loclizr
Section titled “Let babel-jest compile loclizr”module.exports = { transformIgnorePatterns: ['/node_modules/(?!(\\.pnpm/)?loclizr[@/])'],}module.exports = { presets: [ ['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript', ['@babel/preset-react', { runtime: 'automatic' }], ],}babel-jest now compiles loclizr along with your own files. The generated tree sits under src/,
outside node_modules, so it was never skipped. The (\\.pnpm/)? part is for pnpm, which keeps the
package at a path such as node_modules/.pnpm/loclizr@0.1.2_react@19.3.0/node_modules/loclizr; the shorter
'/node_modules/(?!loclizr/)' still skips it there.
/** @jest-environment jsdom */import { render, screen } from '@testing-library/react'import { setLocale } from 'loclizr'import { Cart } from '../src/Cart'import * as m from '../src/loclizr/messages'
beforeEach(() => { setLocale('de', { persist: false })})
test('renders German', () => { render(<Cart count={3} />) expect(screen.getByText(m.cart_items({ count: 3 }, { locale: 'de' }), { exact: false })).toBeTruthy()})
test('one call can override the locale', () => { expect(m.cart_items({ count: 3 }, { locale: 'en' })).toBe('3 items in your cart')})The same cart test, with Jest’s docblock and globals in place of the Vitest comment and import. The
latch and server tests port the same way; the server test needs no docblock, since Jest’s default
environment is Node. pretest stays loclizr build, with "test": "jest":
> pretest> loclizr build
2 messages, 2 locales (source en), 0 errors, 0 warnings
> test> jest
PASS test/ssr.test.tsxPASS test/cart.test.tsxPASS test/latch.test.ts
Test Suites: 3 passed, 3 totalTests: 4 passed, 4 totalSnapshots: 0 totalTime: 0.505 s, estimated 1 sRan all test suites.With ts-jest
Section titled “With ts-jest”module.exports = { preset: 'ts-jest/presets/js-with-ts', transformIgnorePatterns: ['/node_modules/(?!(\\.pnpm/)?loclizr[@/])'],}"compilerOptions": { "allowJs": true}The plain ts-jest preset compiles only .ts and .tsx, so with the same pattern the suite still
fails on loclizr’s .js files. js-with-ts hands .js files to ts-jest as well, and allowJs lets
TypeScript compile them; without it, ts-jest warns and the generated messages.js stays ESM.
ts-jest 29.4.14 declares a TypeScript peer below 7, so this path runs on TypeScript 6.
Or run Jest as native ESM
Section titled “Or run Jest as native ESM”"type": "module","scripts": { "pretest": "loclizr build", "test": "NODE_OPTIONS=--experimental-vm-modules jest"}export default { extensionsToTreatAsEsm: ['.ts', '.tsx'],}The Babel config keeps the same presets behind export default. Nothing here names loclizr.
"type": "module" is the line that matters: without it, Jest treats the generated
src/loclizr/messages.js as CommonJS and the suite fails at its first export.
| Jest setup | Run with | Result |
|---|---|---|
| no config | jest |
fails on Node 22, 24 and 25 |
transformIgnorePatterns: ['/node_modules/(?!loclizr/)'] |
jest |
passes with npm, fails with pnpm |
transformIgnorePatterns: ['/node_modules/(?!(\\.pnpm/)?loclizr[@/])'] |
jest |
passes with npm and pnpm on Node 22, 24 and 25 |
preset: 'ts-jest' and that pattern |
jest |
fails with npm and pnpm on Node 22, 24 and 25 |
preset: 'ts-jest/presets/js-with-ts', that pattern and allowJs |
jest |
passes with npm and pnpm on Node 22, 24 and 25 |
| native ESM as above | NODE_OPTIONS=--experimental-vm-modules jest |
passes on Node 22, 24 and 25 |
| no config | NODE_OPTIONS=--experimental-vm-modules jest |
passes on Node 24 and 25, fails on Node 22 |
Measured with Jest 30.5.2, jest-environment-jsdom 30.5.2 and Babel 8 on Node 22.22.2, 24.18.0 and 25.8.2, against the cart, latch and server tests above. The ts-jest rows used ts-jest 29.4.14 on TypeScript 6.0.3.