Skip to content

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.

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:

csszyx-jest-transformer.cjs
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)
);
},
};

Then point Jest at it:

jest.config.js
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.

Two sources answer, in order.

  1. The build’s transform cache, .csszyx/cache/transform. It holds the output the bundler produced, which is the only output that resolves an sz object or an szv factory 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 (or csszyx next prebuild on 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 an sz it imports from another module resolves at run time there, with the same classes. Before any table is written both classes stay; pass mergeCoveredClasses: false to the transformer to keep them always.
  2. A per-file compile, for everything else. This is the complete answer for an inline sz and for one built from a const in the same file. A shape it cannot resolve keeps the runtime path, exactly as the plugin would: the @csszyx/runtime helper 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.

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.

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,
});

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.