Skip to content
loclizr
test/cart.test.tsx
// @vitest-environment jsdom
import { 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.

  1. package.json
    "pretest": "loclizr build",
    "test": "vitest run"

    Tests import src/loclizr/messages, which build writes and the default .gitignore excludes, so a fresh clone has nothing to import. A team that commits the tree (delete the .gitignore once) needs no script.

  2. setLocale(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).

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 locale
loclizr: <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:

test/setup.ts
if (typeof document !== 'undefined') document.documentElement.lang = 'en'
vitest.config.ts
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.

test/latch.test.ts
// @vitest-environment jsdom
import { 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.

test/ssr.test.tsx
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.

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 |
jest.config.js
module.exports = {
transformIgnorePatterns: ['/node_modules/(?!(\\.pnpm/)?loclizr[@/])'],
}
babel.config.js
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.

test/cart.test.tsx
/** @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.tsx
PASS test/cart.test.tsx
PASS test/latch.test.ts
Test Suites: 3 passed, 3 total
Tests: 4 passed, 4 total
Snapshots: 0 total
Time: 0.505 s, estimated 1 s
Ran all test suites.
jest.config.js
module.exports = {
preset: 'ts-jest/presets/js-with-ts',
transformIgnorePatterns: ['/node_modules/(?!(\\.pnpm/)?loclizr[@/])'],
}
tsconfig.json
"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.

package.json
"type": "module",
"scripts": {
"pretest": "loclizr build",
"test": "NODE_OPTIONS=--experimental-vm-modules jest"
}
jest.config.js
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.