Skip to content

When the Test Reads Its Own Source Code

Adityo Guni Waluyo

Vitest refuses to render components under Vite 8? Make the source files the test data: lock the rating flag invariant with toContain.

TL;DR

Vitest could not transform JSX after Vite 8 moved to rolldown and oxc without a React plugin, breaking the entity card render test. Instead the author added a text tripwire that reads source files and asserts rating_enabled !== false on six surfaces. It checks code shape not runtime, so integration tests verify the flag gates display without leaking rating data.

I opened the Vitest terminal expecting the entity card render test to pass. Instead I got a wall of red. The error wasn't a failed assertion, it was a syntax error complaining about JSX. I was working on KotaPortal, a fictional tourism portal, trying to lock down the rating feature flag. But the Vitest pipeline refused to transform JSX at all: Vite 8 moved to rolldown for dependency optimization and oxc for JavaScript transforms [3], and this pipeline has no React plugin in it.

My first, lazy guess: leave the gate untested for now, the Go integration tests will catch real regressions anyway. That answer didn't survive the night. An untested guard is an illusion of safety; someday the check gets deleted in a refactor, rating badges reappear on surfaces that should be silent, and the suite stays green while it happens. I wanted something that screams in CI the moment that occurs.

Reading the Source as the Test Data

If the component can't be executed, make the file itself the test data. The test reads the real source files as plain text and asserts the invariant text:

import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";

const read = (rel: string) =>
  readFileSync(fileURLToPath(new URL(rel, import.meta.url)), "utf8");

const SURFACES = [
  "entity-card", "entity-list-item", "hero-section",
  "map-point-card", "detail-panel", "map-popup-content",
];

describe("Rating gate", () => {
  it("gates rating on every card surface", () => {
    for (const name of SURFACES) {
      const src = read(`../features/${name}.tsx`);
      expect(src).toContain("rating_enabled !== false");
    }
  });
});

The two primitives are officially documented: `toContain` also checks whether a string is a substring of another string [1], and `toMatch` asserts a string against a regular expression [1]. Node documents that `readFileSync` accepts a WHATWG URL object as its path [2], which makes `new URL(rel, import.meta.url)` resolve relative to the test file itself.

A Tripwire, Its Honest Limit, and the Backend Pair

It works like a tripwire. The moment someone deletes `rating_enabled !== false` from any one of the six surfaces (entity card, list item, home hero slide, map point card, map popup, detail panel), CI goes red. I also locked the data path: one regex via `toMatch` proves the conditional spread that keeps rating out of favorite items stays in place.

Without that spread, `avg_rating` and `review_count` can quietly leak into favorite items while the badges are already hidden.

The small details matter. The variable names differ per file: `entity.` on cards, `slide.` on the hero, `point.` on map panels. The regex stays loose about the prefix and strict about the spread idiom, so a rename refactor doesn't cause false alarms. The pattern rhymes with the fake-implementation lesson from implementasi-palsu-wajib-gagal-en: a guard that can never fail is just decoration.

The catch is real: this proves code shape, not runtime behavior. Reading text never executes the component. So the backend pair exists: an integration test proves `rating_enabled` really travels on card payloads, while stored rating aggregates stay untouched. The flag gates display, not data. A detail that made me pause: the types aren't uniform, the card payload carries a plain bool while map points carry a pointer to bool to distinguish not-set from explicitly-off. My first probe went looking for the pointer on the card payload; it only exists on the map side.

I prefer maintaining this pair over spending days fighting build tool config just to render one component; a stable pipeline beats coverage that looks good in reports but cracks easily. The render test isn't discarded forever: once the React plugin lands in the test pipeline, render coverage comes back. Until then, the text invariant keeps watch.

The Vite 8 shift to rolldown and oxc [3] is a footnote in the migration guide, but it left me with a reminder: when the test tooling can't run your code, one testing surface is always left, the text of the code itself.

Sources

3. is
1. Vitest expect API
2. Node.js fs docs
3. Vite 8 migration guide