Testing

View as Markdown

Three questions get asked of a style, and each has its own answer. What did it compile to. Does the component that uses it behave. Does the page it renders actually look right.

What a style compiles to

The transform is a function. Give it a source string and it hands back the rewritten code and the stylesheet, with no component, no DOM and no bundler in between — the same thing you would otherwise read off a console.log, in a form you can assert on.

compile.test.js
const { unpluginFactory } = require('@plumeria/unplugin/factory');
const plugin = unpluginFactory();

const compile = async (source) => {
  const { code } = await plugin.transform(source, `${__dirname}/fixture.tsx`);
  const cssId = code.match(/import "(.+\.zero\.css)"/)[1];
  return { code, css: plugin.load(cssId) };
};

test('two properties become two atoms', async () => {
  const { code, css } = await compile(`
    import * as css from '@plumeria/core';
    const styles = css.create({ box: { padding: 16, color: 'red' } });
    export const A = () => <div classStyle={styles.box} />;
  `);

  expect(code).toMatch(/className=\{"\S+ \S+"\}/);
  expect(css).toContain('padding: 16px');
  expect(css).toContain('color: red');
});

fixture.tsx does not have to exist; the path only tells the compiler where the module would sit.

The example is written for Jest. Vitest wants import { test, expect } from 'vitest' and ESM in place of require and __dirname; node:test takes test from node:test and asserts through node:assert, having no expect. What is being called stays the same in all three.

@plumeria/unplugin/factory and the default export of @plumeria/turbopack-loader are supported entry points for exactly this. The factory's hooks and the four properties the loader requires of its context are part of the published surface, and will not be changed under you.

Next.js

In a Next.js project the loader is what you test. @plumeria/turbopack-loader is what withPlumeria installs, on Turbopack and webpack alike, so a test that calls it compiles through the same thing the build does. It takes a webpack loader context rather than plugin hooks, which is six lines to stand up:

loader.test.js
const loader = require('@plumeria/turbopack-loader');
const fn = loader.default ?? loader;

const compile = (source) =>
  new Promise((resolve, reject) => {
    const ctx = {
      resourcePath: `${__dirname}/fixture.tsx`,
      async: () => (err, content) => (err ? reject(err) : resolve(content)),
      addDependency: () => {},
      clearDependencies: () => {},
    };
    fn.call(ctx, source);
  });

test('the styling prop becomes a className', async () => {
  const code = await compile(`
    import * as css from '@plumeria/core';
    const styles = css.create({ box: { padding: 16, color: 'red' } });
    export const A = () => <div classStyle={styles.box} />;
  `);

  expect(code).toMatch(/className=\{"\S+ \S+"\}/);
  expect(code).not.toMatch(/classStyle/);
});

Add @plumeria/turbopack-loader to your dev dependencies rather than reaching it through the plugin.

The loader returns the rewritten code, and writes its stylesheet only when NODE_ENV is development or production. A test run is neither, so it leaves that file alone and sees the class names but no CSS. Set NODE_ENV=development if the stylesheet is what you came for — writes are taken under a lock, so parallel workers do not lose rules — or read it from the factory's load() above, which keeps it in memory and touches nothing.

Components

Vitest runs on Vite, so it takes the plugin directly:

vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import plumeria from '@plumeria/unplugin';

export default defineConfig({
  plugins: [react(), plumeria.vite()],
  test: {
    environment: 'jsdom',
  },
});

The component is compiled by the plugin, and the styling prop is gone by the time it reaches the DOM:

<h1 class="xvdv6o3r xggb8uiu xvazecna">Vite + React</h1>

This is the one layer that needs the transform in the module pipeline. A runner that resolves @plumeria/core itself finds a package of types with no runtime behind it, and stops there.

Never assert a generated class name

expect(heading.className).toBe('xvdv6o3r xggb8uiu xvazecna'); // don't

An atomic class name is one property–value pair, hashed. Add a property to the style and every test that spelled the old list out fails, having found nothing wrong with the component. Assert what the component does — the text it renders, the state it holds, the branch it took — and leave the names to the compile test above, which reads them out of the output instead of hard-coding them.

What jsdom does not give you

The class names are real, but no stylesheet is loaded into the document — document.styleSheets is empty, and getComputedStyle returns initial values for every property the styles set. A test can see that the right classes were attached; it cannot see what they do.

Anything that turns on the cascade — specificity between two styles, a @media branch, a marker and its extended styles — needs a real browser. Run those end to end, against the build, where the emitted stylesheet is actually applied.

Types

Because the package is types-only, the API surface can be tested with nothing but tsc. Write the calls you expect to hold, mark the ones you expect to fail, and type-check the file:

styles.test-d.ts
import * as css from '@plumeria/core';

const styles = css.create({
  text: { fontSize: '12px', color: 'red' },
  variant: (size: number) => ({ width: `${size}px` }),
});

export const atomic: string = styles.text.color;
export const fromVariant: string = styles.variant(8).width;

// @ts-expect-error atomic class names are branded per property
export const crossed: css.AtomicClassNameFor<'color', 'red'> = styles.text.fontSize;

// @ts-expect-error the key was never defined
export const missing: string = styles.nope.color;
Terminal
tsc --noEmit

An @ts-expect-error that stops being an error is itself reported, as TS2578: Unused '@ts-expect-error' directive, so the file fails when a guarantee quietly disappears.

The work is already divided by the time a test runs. TypeScript offers the property names and values as you write them, and the lint rules check what you wrote — spelling, values, pseudos, selectors, order-dependent overlap. What neither of them states is the type that comes back out, and that is what this file pins: the branding that keeps one property's class name from being passed off as another's, and keys that do not exist.

On this page