Skip to main content

Testing Actual Rendering

A mixin's unit tests — the kind shown throughout Library Development — typically run against a mocked @geajs/core:

vi.mock('@geajs/core', () => {
class Store { dispose?(): void }
class Component { dispose() {} }
return { Store, Component }
})

This is the right tool for testing a mixin's own logic in isolation, and it's fast. But it proves nothing about how the mixin behaves once real JSX gets compiled by @geajs/vite-plugin and actually rendered into a DOM. Some bugs only exist at that layer — they never show up in a mocked unit test, no matter how thorough it is. This page covers what it actually takes to test against the real pipeline, and the specific pitfalls that make it harder than it looks.

Why This Category of Bug Exists​

The framework distinguishes between a raw class instance and a reactive Proxy wrapping it (see Proxy and Raw Instance Identity). A WeakMap-keyed registry that mixes the two ends up with lookups that silently fail — but only once something is actually constructed and rendered through the real compiler. A mocked Component/Store pair, built by hand in a test file, has no Proxy at all, so a test running against the mock can pass cleanly while the exact same code fails the moment it runs for real. This is the class of bug real-rendering tests exist to catch.

Setting Up: A Second Vitest Project for JSX​

A single vitest.workspace.ts project configured for .test.ts files does not transform JSX — @geajs/vite-plugin's geaPlugin() needs to be wired in as a Vite plugin, and only for files that actually contain components. The practical setup is two separate projects side by side:

// vitest.workspace.ts
import { defineConfig } from 'vitest/config'
import { geaPlugin } from '@geajs/vite-plugin'

export default [
defineConfig({
test: {
name: 'unit',
include: ['**/*.test.ts'],
environment: 'happy-dom',
},
}),
defineConfig({
plugins: [geaPlugin()],
optimizeDeps: { exclude: ['@geajs/core'] },
test: {
name: 'rendering',
include: ['**/*.test.tsx'],
environment: 'happy-dom',
},
}),
]

The optimizeDeps.exclude line isn't optional. Without it, Vite's dependency pre-bundling replaces @geajs/core's real file path with a generated cache file under node_modules/.vite/deps/, and the plugin's internal logic for locating its compiler runtime (which works by pattern-matching the real package's file path) fails outright — the build breaks before any test even runs, with an error about the plugin being unable to resolve its own compiler runtime. This applies to any Vite-based setup that imports @geajs/core, not just Vitest specifically — the same fix is needed in an actual application's vite.config.ts.

One Component (or Store) Per File, With a Default Export​

The compiler resolves whether a JSX tag refers to a class component by resolving the imported module it comes from — it checks ImportDeclaration nodes and asks whether the resolved file is a known component module. A class declared and used in the same file never goes through that import-resolution path, so it's never recognized as a component tag at all, no matter how correctly it's written. This means a diagnostic component and the parent that renders it can't live in the same file — write each as its own file with a default export:

// fixtures/some-test/child-probe.tsx
import { Component } from '@geajs/core'

export default class ChildProbe extends Component {
template() {
return <div class="child-probe" />
}
}
// fixtures/some-test/parent-probe.tsx
import { Component } from '@geajs/core'
import ChildProbe from './child-probe'

export default class ParentProbe extends Component {
childRef: ChildProbe | undefined = undefined

template() {
return (
<div class="parent-probe">
<ChildProbe ref={this.childRef} />
</div>
)
}
}

Follow the fixture placement convention from Creating Tests: fixtures used by a single test file go under fixtures/<test-file-name>/, matching this example's fixtures/some-test/.

Writing the Test​

Render into a document.body-attached container — an element created but never attached won't reliably reflect insertion — and assert on the result:

// some-test.test.tsx
import { describe, it, expect } from 'vitest'
import ParentProbe from './fixtures/some-test/parent-probe'

describe('rendering', () => {
it('mounts the child and exposes it via ref', () => {
const container = document.createElement('div')
document.body.appendChild(container)

const parent = new ParentProbe()
parent.render(container)

expect(parent.childRef).toBeDefined()
expect(container.querySelector('.child-probe')).not.toBeNull()
})
})

When This Level of Testing Is Worth the Setup Cost​

Mocked unit tests remain the right default for testing a mixin's own logic — the setup above is real overhead, and reaching for it on every test would slow the suite down for little benefit. It earns its cost specifically when what you're testing depends on the actual compiled output: parent/child relationships, anything involving GEA_PARENT_COMPONENT or other compiler-generated wiring, or a WeakMap/Map registry keyed by component or store identity. If a bug wouldn't be visible without a real Proxy and a real compiled template, a mocked test — however thorough — won't catch it, and this is the setup that will.