→ Three jobs, three tools — and a clear line between what is safe to assert on and what is not.
Testing
"Testing" means three different jobs in a Salty project, and they want different tools because they're asking different questions. An automated suite asks does this component still do what it's meant to do. A browser asks does this look right. A build artifact asks did the compiler emit what I actually wrote. Salty changes the second and third of those a lot more than it changes the first.
The reason is the compiler, and one sentence of it is enough to follow this page: your .css.ts files are executed at build time, and what comes out is a static stylesheet plus some class names in your HTML. No styling code ships. So by the time anything renders there's nothing Salty-shaped left to test — there's CSS, and a browser applying it.
Which leads to the decision worth making before you write a single test. Salty gives you a public surface and an internal one. The element, its attributes, the variant props, and any class you named yourself are public: yours, stable, safe to assert on. The generated hashes and the saltygen/ folder are internal: read them freely while you're working, never build a test on them.
Three sections below, one per job — and only one of them involves you looking at a screenshot of a button at 3am wondering if it moved two pixels.
Automated tests: decide what you're asserting on
What a test runner can see, and what it can't
Render a styled component in a test runner and this is the entire footprint:
<button class="TzHVd intent-primary" data-component-name="Button">Save</button>Class names, and the attributes you put there. The rules those classes point at live in saltygen/index.css, which the runner isn't loading — and the DOM emulators most runners use don't implement the cascade to the depth Salty's output leans on. Cascade layers in jsdom have been a moving target for years: parsing landed, full precedence in getComputedStyle has been patchier. So a unit test is not going to tell you your padding resolved to 16px, and a test that claims to is really testing the emulator.
That isn't a gap to engineer around. It's the line: unit tests are for behaviour and structure, browsers are for appearance. The rest of this section is about choosing assertions so the first half stays true when someone changes a colour.
Don't assert on the hash
The hash is content-addressed — derived from the style object itself, not from the export name. Three consequences, each of which breaks a different test:
- Edit a style value and the hash changes. Nudge a padding, get a new class, fail a test that had nothing to do with padding.
- Rename the export and it doesn't change. So it isn't a component identifier either.
- Two components with structurally identical styles share one hash, because they deduplicate into a single rule. It isn't even unique to a component.
Snapshots inherit all three. A snapshot containing a hash churns on every style edit, and a diff that churns is a diff nobody reads — at which point the test has stopped doing its job while still costing you a review.
You can derive the hash yourself and assert against it; nothing stops you. Worth being clear about what that test checks, though: that Salty hashed a style object the way Salty hashes style objects. That's a test for the compiler, and the compiler already has its own.
How you define a stable hook
The className option appends a class of your choosing next to the generated hash. You picked it, so it changes when you change it and not before:
import { styled } from "@salty-css/astro/styled";
export const ProductCard = styled("article", {
className: "product-card", // stable, alongside the hash
base: { padding: "{spacing.large}", borderRadius: "8px" },
});defaultProps is the other half, and the more valuable one. It binds real HTML attributes to the component, so the role and accessible name your tests query by live in the definition rather than in forty call sites' collective memory:
export const Dialog = styled("div", {
defaultProps: { role: "dialog", "aria-modal": true },
base: { position: "fixed", inset: 0 },
});How you use it
Both land on the element, and neither depends on anything the compiler computed:
<article class="pQvNr product-card">…</article>
<div class="dGmXt" role="dialog" aria-modal="true">…</div>So a query by role finds the dialog, and it keeps finding it after a redesign. This is the quiet argument for doing the accessibility work first: the attributes that make a component usable are the same ones that make it addressable, and you only have to bind them once.
Variants are worth a note of their own. Each active variant renders its own readable class — intent="primary" becomes intent-primary next to the hash — derived from names you chose rather than from style content, so it survives a colour change that the hash doesn't. That makes it a fair thing to assert on when the question is specifically which branch rendered:
render(<Button intent="primary">Save</Button>);
// element carries: TzHVd intent-primaryCalibrate that one, though: it's how the compiler emits variants today, not a contract I've written down and promised to keep. Where a test would be just as good asserting on something you own outright — visible text, a role, a bound attribute, your own class — prefer that.
Getting .css.ts files through your runner
One practical thing sits between you and all of the above: your runner has to be able to import a .css.ts file at all.
If the runner shares your bundler config, this mostly handles itself — a Vitest setup reading a vite.config.ts that already has saltyPlugin in it is the closest thing to a path that should just work, because it's the same transform your dev server already uses. Runners with their own module pipeline need that transform wired in separately, and I'll be straight: there's no first-party transform package for those today. It's the least-settled corner of Salty's testing story, so treat whatever you build there as your own setup rather than a supported one.
One gotcha that's cheap to avoid while you're in there. Test setups very often stub stylesheets out by mapping CSS imports to an empty module. If that pattern isn't anchored to the end of the filename, it will cheerfully swallow button.css.ts along with button.css — and then your components render with no class names and nothing anywhere says why. Anchor the pattern, or exclude Salty's suffixes from it explicitly.
End-to-end: the one attribute that isn't there in production
In development builds every styled component renders data-component-name, taken from its export name. It's the best thing on this page for reading a tree by hand, and it is exactly the wrong thing to build a suite on, because production builds strip it. A selector that works all through local development and vanishes the first time the suite runs against a real build is a genuinely bad afternoon.
Pick something that survives the mode change:
- Role, label, or visible text. Free if you bound the attributes with
defaultProps, and it fails when the thing actually breaks for a user rather than when a class name moves. - Your
classNamehook. Stable across builds and across extension — a component that wrapsProductCardstill carriesproduct-card. - A
data-testidat the call site. Note call site: a test id indefaultPropsis one id shared by every instance of the atom, which is rarely the thing you wanted.
If you want to confirm what actually ships before committing to a selector, build in production mode and read the output — that's the last section of this page.
Visual regression
Screenshot diffing is the tool that catches the whole class of bug this page keeps handing to a browser, and two things about Salty make it less fiddly than it usually is.
Themes are an attribute. A conditional token group compiles to [data-theme="dark"] { --theme-bg: … }, so a per-theme screenshot matrix is one setAttribute per shot. No provider to re-mount, no second build, no separate stylesheet to load.
Animations are real CSS animations. keyframes compiles to an @keyframes block, which means the browser's reduced-motion emulation switches them off wholesale — and a screenshot caught mid-fade is the classic reason a visual suite goes flaky.
The one that will bite you is fonts. defineFont and defineImport emit real @font-face and @import rules, so a screenshot taken before a webfont arrives is a screenshot of a fallback face, and you'll get a diff on text that nobody touched. document.fonts.ready is the platform's answer and it beats a fixed timeout in every direction.
Reading your output in the browser
This is where styling bugs actually surface, and Salty leaves a fair amount lying around for you to read once you know it's there. None of it needs a tool you don't already have open.
The component name is in the tree
Development builds attach the export name to every styled component:
<section class="vXmLp" data-component-name="Card">…</section>So searching [data-component-name="Card"] in the elements panel takes you straight there — by the same name you use in your imports, rather than by a hash you'd have to go and look up first. displayName overrides the label where the derived name would be unhelpful, which mostly means wrapped or extended components:
export const PrimaryButton = styled(Button, {
displayName: "PrimaryButton",
base: { background: "{colors.brand.main}" },
});And if a component you're inspecting has no hash class at all, that's the signal rather than a mystery: check the filename suffix, check the export. Those two account for most of it, and Troubleshooting has the ordered list for when they don't.
Two classes, not forty
Salty emits one class for the component and one per active variant, and the variant classes are readable:
<button class="TzHVd intent-primary size-large">Save</button>That falls out of treating a component as one rule with branches rather than as a pile of independent utilities, and the payoff shows up precisely here. The Styles pane lists a handful of rules you can read top to bottom, and .TzHVd.intent-primary tells you which branch is winning without you reconstructing it from a class list that runs off the edge of the panel.
Which layer won
Salty's entire override story is @layer, declared once at the top of the generated stylesheet:
@layer imports, reset, global, templates, fonts, l0…l8;Every major browser labels layered rules in its styles pane, and Chrome and Edge add a layer-order view on top of that. Which turns most override questions into something you look at instead of guess: if your rule sits in l0 and something else is winning from l2, that isn't a specificity problem and no amount of extra selector will fix it — you want priority, or to wrap the component. Scoping and composition has the model; the panel just tells you which layer you actually landed in.
Tokens are live custom properties
Tokens compile to real custom properties on :root, named after the paths you picked — colors.brand.main becomes --colors-brand-main. Two useful things follow.
You can edit one in DevTools and watch the whole page move, because every rule that consumed the token reads it through var(). That's a real design loop, not a debugging trick: nudge a brand colour on :root, see every surface depending on it shift at once, then go and write the value you landed on.
You can flip a theme the same way. Set data-theme="dark" on <html> in the elements panel and the browser repaints from the conditional block — no rebuild, no provider, no re-render. It's the same mechanism your app uses; in that moment you're just being the thing that sets the attribute.
The states you can't hover into
This last one is the browser doing you a favour rather than Salty. Much of what Salty pushes you to keep in CSS — :hover, :focus-visible, :invalid, :has() — is exactly what DevTools can force on an element. So you can inspect a hover state without holding the mouse perfectly still, and a focus ring without losing focus to the panel. Chrome's Rendering panel covers the media-query half: prefers-color-scheme, prefers-reduced-motion and forced-colors all emulate, matching the named queries defineMediaQuery gives you.
There's a pattern in that worth naming, because it runs through Interactive State too: the more state you leave to the browser, the more of it the browser's own tools can show you. State you lifted into React is state you now have to reproduce by hand.
Checking the build without running the app
Sometimes the question isn't "does this look right" but "did what I wrote become the CSS I meant." That one needs neither a dev server nor a browser. Build, then read the file.
This is also the fastest verification loop for an AI assistant working in a Salty project, and it's worth saying why plainly: a build produces a named text artifact in seconds, and reading it is an actual check. Starting a dev server and reasoning about what probably rendered is not.
How you build it
npx salty-css build # compile once
npx salty-css build --watch # recompile on change, no dev server
npx salty-css build --mode production # what actually shipsYou rarely type this in normal development because the bundler plugin does it on save. It earns its place the moment you want the output in isolation — in CI, in a prepare script, or right now.
How you read it
Everything lands in saltygen/ at the project root:
| What you'll find | What it is |
|---|---|
index.css | The entry point: the @layer order declaration, then an @import per global file and per layer bundle. |
l_0.css, l_1.css, … | Component rules grouped by priority, each wrapped in @layer l0 { … }. Every component's block is fenced by /*start:<hash>-<filename>*/ and /*end:<hash>*/ comments. |
cl_button-TzHVd.css | The per-component file. The prefix comes from your export name, which is what makes this the useful one. |
salty.config.js | A compiled snapshot of your merged config — tokens, templates, media queries. |
That third row is the whole trick. Edit Button, run the build, and there's a file with button in its name holding exactly the rules that export produced. You read the declarations you just wrote, in the layer they were assigned, without scanning index.css for a hash you'd have to find first — and without the app ever having rendered.
The --mode flag matters more here than it looks. Development builds carry data-component-name; production builds strip it. If you're about to write an end-to-end selector, this is the build to check it against.
What not to do with it
saltygen/ is a build artifact. It's wiped and regenerated from scratch on every build, it's in .gitignore for a reason, and its internal shape — the layer bundles, the per-component filenames, the js/ cache — is an implementation detail. It can change in a minor release, and while Salty is pre-1.0 I'd much rather keep that freedom than freeze a folder layout because someone's CI grew a dependency on it.
So: read it while you work, quote it in a bug report, diff it when you want to know what a change did. Don't assert on it.
Which lands the page back where it started. The element, its attributes, the variant props, and the class you named yourself — that's the part that holds still, and that's the part to write tests against.