Testing Components
A component’s sz prop is compiled by the bundler plugin. A test runner that
does not run the plugin renders the component with sz still on it as a live
prop and no className, so an assertion like
expect(el).toHaveClass('p-4') reads nothing the browser would see — and a
value that compiles to nothing is invisible to the suite.
Vitest
Section titled “Vitest”Nothing to set up. Vitest resolves modules through Vite, so the plugin in
vite.config.ts transforms every component a test imports, exactly as it does
for the app. The rendered element carries the same className the browser
gets, and the same diagnostics reach the terminal.
Jest cannot host a bundler plugin, so @csszyx/unplugin/jest compiles sz
in a transformer. It does one thing — replaces sz with the className the
build would emit — and hands back TSX. Jest applies one transformer per
file, so chain it in front of the one that compiles TSX:
const csszyx = require('@csszyx/unplugin/jest').createTransformer();const babel = require('babel-jest').default.createTransformer();
module.exports = { process(sourceText, sourcePath, options) { const { code } = csszyx.process(sourceText, sourcePath); return babel.process(code, sourcePath, options); }, getCacheKey(sourceText, sourcePath, options) { return ( csszyx.getCacheKey(sourceText, sourcePath, options) + babel.getCacheKey(sourceText, sourcePath, options) ); },};const csszyx = require('@csszyx/unplugin/jest').createTransformer();const ts = require('ts-jest').default.createTransformer();
module.exports = { process(sourceText, sourcePath, options) { const { code } = csszyx.process(sourceText, sourcePath); return ts.process(code, sourcePath, options); }, getCacheKey(sourceText, sourcePath, options) { return ( csszyx.getCacheKey(sourceText, sourcePath, options) + ts.getCacheKey(sourceText, sourcePath, options) ); },};const csszyx = require('@csszyx/unplugin/jest').createTransformer();const swc = require('@swc/jest').createTransformer();
module.exports = { process(sourceText, sourcePath, options) { const { code } = csszyx.process(sourceText, sourcePath); return swc.process(code, sourcePath, options); }, getCacheKey(sourceText, sourcePath, options) { return ( csszyx.getCacheKey(sourceText, sourcePath, options) + swc.getCacheKey(sourceText, sourcePath, options) ); },};Then point Jest at it:
module.exports = { transform: { '\\.[jt]sx?$': '<rootDir>/csszyx-jest-transformer.cjs', },};Both cache keys are combined on purpose: Jest caches transformer output under that key, and csszyx’s half changes when the build’s answer for a file changes — which a change in another module can do without touching the file itself.
Where the classes come from
Section titled “Where the classes come from”Two sources answer, in order.
- The build’s transform cache,
.csszyx/cache/transform. It holds the output the bundler produced, which is the only output that resolves anszobject or anszvfactory imported from another module — those come from the plugin’s project-wide scan, and a compiler handed one file cannot see them. An entry is used only when the file’s contents match what the build saw, and only when this csszyx version wrote it without variable mangling. Run your build (or dev server) before the suite when a component’s styles live in another module. The entry holds the pass before the merge, so jest applies the table the same build settled in.csszyx/merge-table.json(orcsszyx next prebuildon Next.js): where the build dropped a covered class —{ pb: 2, p: 4 }→p-4,className="pb-2" sz={{ p: 4 }}→p-4— the suite sees the same classes. A file whose classes merge is compiled on its own for that, so anszit imports from another module resolves at run time there, with the same classes. Before any table is written both classes stay; passmergeCoveredClasses: falseto the transformer to keep them always. - A per-file compile, for everything else. This is the complete answer
for an inline
szand for one built from aconstin the same file. A shape it cannot resolve keeps the runtime path, exactly as the plugin would: the@csszyx/runtimehelper is imported and the classes are computed when the component renders.
Either way, a dead key or value is printed to the console with the file it was found in — the same lines the build prints, minus the usage nudges that only matter to a bundler.
What szcn merges in a suite
Section titled “What szcn merges in a suite”szcn merges on a table the build settles from your compiled Tailwind CSS
(see How it decides), and a test run
has no bundler to settle one. A build — or csszyx next prebuild on Next.js —
also writes the table to .csszyx/merge-registration.cjs (and an .mjs twin
for a Jest that runs native ES modules); the transformer imports it from every
module that loads the csszyx runtime, and its cache key follows the file, so a
rebuild reaches the suite. The table holds the classes your Tailwind generates
CSS for, so a class a test spells but no source of the app uses has no entry.
Before a build has written it, szcn keeps every class: szcn('p-2', 'p-4')
is 'p-2 p-4', and a development warning names the cause once. Assert with toHaveClass('p-4') rather than on the whole
string, or run the build before the suite, the same as for cross-module styles.
Options
Section titled “Options”require('@csszyx/unplugin/jest').createTransformer({ // Where the plugin wrote its transform cache. Default: `.csszyx/cache/transform` // under `root`; set it when `build.cacheDir` is configured. cacheRoot: '.csszyx/cache/transform', // Which files carry `sz`. Anything else is handed back untouched. extensions: ['.tsx', '.jsx', '.ts', '.js', '.mts', '.mjs'], // Where the project's stylesheets are read from. Default: the `rootDir` of the // Jest project the file belongs to, so each app under `projects` reads its own. root: __dirname, // The stylesheets the app loads, when the project also holds others. tailwindStylesheet: 'src/index.css', // Directories that hold another app, left out of the prefix vote. One glob // per entry: the comma of `csszyx next prebuild --ignore` separates nothing here. ignore: ['legacy/**'], // Drop a class a later one on the same element covers, from the table the // build settled, as `build.mergeCoveredClasses` does. Default: true. mergeCoveredClasses: true,});A Tailwind prefix
Section titled “A Tailwind prefix”With @import "tailwindcss" prefix(tw) the transformer emits tw:p-4, the
class the project serves. It reads the prefix from
.csszyx/cache/stylesheet-facts.json, which the bundler build and
csszyx next prebuild write. When that file is missing or older than the
stylesheets, it compiles them once per Jest worker in a child process and writes
the file itself, so a suite run before any build still gets the prefix. The
prefix is part of the cache key, and build output lowered under a different
prefix is not reused.
Stylesheets that set different prefixes, or a Tailwind entry that does not
compile, fail the transform with the same message the build prints. Name the
stylesheets the app loads in tailwindStylesheet when a fixture or an old copy
is the cause, or leave another app’s directory out with ignore. On Next.js
the transformer follows the --ignore patterns that csszyx next prebuild or
csszyx next watch recorded; a suite that runs before either, as on a fresh CI
checkout, has no record to follow, so give ignore the same patterns.
The option replaces the recorded patterns; it does not add to them. Give it
every pattern the command has: a shorter list lets the stylesheets it drops vote
again, and Jest stops on a prefix the build accepted. A transformer with the
option keeps its own facts under .csszyx/cache/jest/, so a suite cannot change
what next dev reads.