Skip to main content

Testing Symbol-Keyed Internal State

The previous page exported the tracking symbol under an /** @internal */ tag specifically so it stays inspectable — mainly for the mixin's own test suite. This page covers how to actually use it from a test.

The Problem: TypeScript Doesn't Know About the Symbol Key​

Once a property is keyed by a Symbol instead of a string, you can't just write instance._managedForms anymore — there is no such property. You also can't write instance[managedForms] and expect it to type-check cleanly, because the mixin's public type (WithFormMixin, in the withForm case) only declares the methods it's meant to expose (createForm, dispose) — not the internal tracking array, which was never part of that public interface in the first place.

The fix is a small test-local type that describes just the internal shape you need to assert against:

import { managedForms, type GeaForm } from '../src/index'

interface ManageProperty {
[managedForms]: GeaForm[]
}

This type exists only in the test file. It isn't exported from the library, and it isn't meant to be — it's a narrow, test-specific window into internal state, not a public contract.

Applying It to a Test Instance​

Intersect the component instance's inferred type with ManageProperty to get a type-safe handle on the internal array, without touching the production type definitions at all:

class MyComponent extends withForm(Component) {}
const instance = new MyComponent()

type TestInstance = typeof instance & ManageProperty
const testInstance = instance as TestInstance

const form = testInstance.createForm<LoginValues>(makeLoginSchema())

expect(testInstance[managedForms]).toHaveLength(1)
expect(testInstance[managedForms][0]).toBe(form)

testInstance[managedForms] is now fully typed as GeaForm[], with autocomplete and type-checking, while nothing about the mixin's actual public surface (what a real consumer would see) had to change to make this possible.

Verifying Disposal​

The main reason to reach into this internal state in a test at all is to confirm cleanup actually happens — the exact thing a black-box test (only calling public methods) can't directly observe:

it('destroys all managed forms and clears the registry on dispose', () => {
class MyComponent extends withForm(Component) {}
const instance = new MyComponent()
const testInstance = instance as typeof instance & ManageProperty

const form = testInstance.createForm<LoginValues>(makeLoginSchema())
const destroySpy = vi.spyOn(form, 'destroy')

testInstance.dispose()

expect(destroySpy).toHaveBeenCalledTimes(1)
expect(testInstance[managedForms]).toHaveLength(0)
})

Without access to the symbol, this test could only check that dispose() didn't throw — it couldn't confirm that every tracked GeaForm actually got torn down, which is the entire point of the mixin existing.

Why Not Just Make It Public?​

It might seem simpler to skip all of this and just expose managedForms as a normal, documented public property. The reason not to: doing so invites consumers to read or mutate it directly, which locks the mixin into that exact internal representation forever — any future refactor (switching from an array to a Map, say) becomes a breaking change instead of an implementation detail. Keeping it @internal, symbol-keyed, and only reachable through a deliberate test-local type preserves the freedom to change the internals later without it counting as a breaking change for real consumers.