How do you test a reusable stateful logic unit that only runs inside a component's reactive scope?
answer
- state belongs to the scope, not the unit
- a throwaway host component, or a scope harness
- settle its updates like a render
- dispose the scope at the end
basics
~20 sGive it a scope. Either mount a throwaway host component that calls the unit and exposes its result, or use a harness that opens a reactive scope directly. Updates it produces are settled exactly like a render, and the scope is disposed at the end.
solid answer
~50 sA unit of reusable stateful logic — state plus effects, subscriptions and derived values, packaged for reuse — usually refuses to run outside a component's reactive scope, because that scope is what owns its state and registers its cleanups. There are two ways to supply one. Mount a throwaway host component whose only job is to call the unit and expose the result the test needs, then drive and settle it like any other tree. Or use a harness that opens a reactive scope directly and hands back the unit's current result, which skips the component but behaves the same way: updates are queued and must be settled, and the scope must be disposed. Logic that holds no reactive state — a reducer, a selector, a derivation, a validator — needs neither, and is tested as a plain function.
go deeper
Remember that this kind of logic borrows its state from the component that calls it, so a test has to provide something that plays that role before the unit will run.
Name both routes — a throwaway host component that exposes the result, or a harness that opens a scope directly — and explain that updates still need settling and the scope still needs disposing.
Show judgment about which logic deserves its own test: shared units with many states, yes; logic reachable through one component, usually through that component. Mention extracting the pure part instead of hosting it.
Set where the boundary sits between pure logic and scope-bound logic in your codebase. Pushing derivations into pure functions shrinks the surface that needs a host at all and makes the remaining tests cheap.
## Why the unit needs a scope at all Frameworks let you package stateful logic for reuse: a unit that holds state, opens a subscription, derives a value, registers cleanup, and hands a result back to whichever component calls it. The state it holds does not belong to the unit; it belongs to the **reactive scope** the calling component provides. The scope is what the framework uses to know which component to re-render when that state changes, and what to clean up when the component goes away. Call such a unit from plain test code and one of three things happens, depending on the runtime: it throws because there is no current scope, it silently creates state nobody tracks so updates never propagate, or it registers cleanups that are never run. None of those is a test. ## Two ways to give it a scope | Approach | What it is | What it buys | | --- | --- | --- | | Throwaway host component | a component written inside the test whose only job is to call the unit and expose its result | uses the ordinary mount path, so lifecycle, updates and teardown behave exactly as in production | | Direct scope harness | a helper that opens a reactive scope, runs the unit in it, and exposes the current result | no component or markup to write; the result is addressed directly | They are the same idea at different heights. The host component route needs no special support from the framework, which is why it works everywhere and is what a harness is doing internally. The harness route removes the ceremony and gives a cleaner failure message, at the cost of one more tool and a scope that is slightly less like a real component's. ## Reading the result out Whatever the route, the test needs the unit's **current** result after each change, not the one from the first pass. Two patterns cover it: - expose the result from the host component in a way the test can read after settling — rendered output, or a mutable holder the component writes on each pass; - have the harness expose the result behind an accessor the test calls again after settling. The shared discipline is the same as for any mounted tree: change something, settle, then read. A value captured before the change does not update itself. ## Not everything needs a host The mistake in the other direction is wrapping pure logic in ceremony it does not need. These need no scope and no host: - a reducer that maps a state value and an action to the next state; - a selector or derivation over a plain input; - a formatter, parser or validator; - a pure helper the reusable unit itself calls. Each is a function of its arguments, so a host adds setup without adding coverage — and the resulting test is slower, harder to read, and fails for more reasons. A good instinct when writing the host feels laborious: check whether the part you actually care about is pure and could be extracted and tested directly, leaving a much smaller unit that genuinely needs a scope. ## Driving and settling A reusable unit's updates obey the same flush contract as a render, because they *are* the same contract: a write inside the unit queues work, and the test must run the framework's settle step before reading. Two consequences: 1. A change pushed into the unit — new arguments, a method it returned, an emission from something it subscribed to — is not visible on the next line. 2. A post-commit effect inside the unit can queue a second update, so the same nested-flush loop applies: settle until nothing is pending. ## Disposing the scope A scope is a lifetime, and the test owns it. What the unit registered — timers, subscriptions, observers, listeners — is unwound when the scope is disposed and not otherwise. If the scope came from a throwaway host component, tearing that root down disposes it; if it came from a harness, the harness exposes the disposal call. Skipping it leaves the unit running: a subscription still pushing values, an interval still firing, and later tests failing for reasons that have nothing to do with them. ## What this cannot tell you Testing a unit in a scope of your own proves the unit's own behaviour. It does not prove that any real component calls it correctly, passes the right arguments, or renders the result sensibly — that is a question for a test that mounts a real component. The reusable-unit test is worth writing when the logic is genuinely shared, has several states worth enumerating, or is awkward to reach through any one component's surface; when only one component uses it, testing it through that component is usually the cheaper and more honest test.
- When does a unit of reusable logic need no host at all?When it holds no reactive state and subscribes to nothing: a reducer, a selector, a derivation, a formatter, a validator. Those are functions of their arguments, so a host adds setup without adding coverage. A host earns its place only for state that lives in a scope and changes over time.
- What must a test do with the scope when it finishes?Dispose it, exactly as a mounted root is torn down. A scope can hold timers, subscriptions, observers and registered cleanups, and leaving it open lets them run into later tests. If the scope came from a throwaway host component, tearing that root down disposes it.
- Why is a throwaway host component not a weaker test than a real page?Because it exercises the same mount path, lifecycle and flush contract the unit meets in production — it simply removes the unrelated markup and collaborators of a real screen. What it does not prove is that any real component uses the unit correctly, which is a separate test on a real component.
saying these in an interview costs you the question
- Calls the unit as a plain function and expects its state to work
- Thinks every reusable unit needs a host, pure functions included
- Reads the first result and never settles after a change
- Leaves the scope open, so its subscriptions outlive the test
- Asserts on the throwaway host component's own markup