Skip to content
Mercury
English
Design Book
Menu

Reflect.8. Testing the boundary

A working click is only half an isolation test. Check what changed, what stayed unchanged, and whether the test would fail if the boundary were broken.

Reflect 4.1.1 · Interactive exercise

Open the exercise on its own page

Make one observable promise

The promise is precise: after two left increments and one right increment, resetting the left panel leaves counts 0 and 1, and global state remains 0. The browser must observe both sides; a test of the left side alone would miss a reset that accidentally cleared every scope.

Open the testing experiment and reproduce that sequence manually first. This exercise uses the same two scoped editors as the scopes lesson. It offers observations, not a completion checkbox.

The regression below is a minimal fixture to adapt in a configured Mercury workspace. It uses the public @fiberfx/testing renderer and queries with native button clicks inside act. It is a recipe, not a report that your local copy has passed.

Express the interaction as a test

import {test, expect} from "vitest";
import {createElement as h} from "@fiberfx/hyperscript";
import {event, store, fork, dispose} from "@fiberfx/hypergraph";
import {Provider} from "@fiberfx/hypergraph/hyperscript";
import {reflect} from "@fiberfx/reflect";
import {act, render, cleanup} from "@fiberfx/testing";

test("reset changes only the selected workspace", async () => {
  const added = event<void>();
  const reset = event<void>();
  const $count = store(0).on(added, n => n + 1).reset(reset);
  const Counter = reflect({
    view: (p: {name: string; count: number; add: () => void; clear: () => void}) =>
      h("section", null,
        h("p", null, `${p.name}: ${p.count}`),
        h("button", {onClick: p.add}, `${p.name} add`),
        h("button", {onClick: p.clear}, `${p.name} reset`)),
    bind: {count: $count, add: () => added(), clear: () => reset()},
  });
  const left = fork();
  const right = fork();
  try {
    const ui = render(h("div", null,
      h(Provider, {value: left}, h(Counter, {name: "Left"})),
      h(Provider, {value: right}, h(Counter, {name: "Right"})),
    ));
    const click = (name: string) => act(() => ui.getByRole("button", {name}).click());
    await click("Left add");
    await click("Left add");
    await click("Right add");
    expect(ui.getByText("Left: 2")).toBeTruthy();
    expect(ui.getByText("Right: 1")).toBeTruthy();
    await click("Left reset");
    expect(ui.getByText("Left: 0")).toBeTruthy();
    expect(ui.getByText("Right: 1")).toBeTruthy();
    expect($count.getState()).toBe(0);
  } finally {
    cleanup();
    dispose(left);
    dispose(right);
  }
});

Save a fixture such as reflect-isolation.test.ts inside an application/module whose mx dependencies resolve Hyperscript, Hypergraph, Reflect and Testing at compatible versions. In native mx test, the vitest import is served by mx's test runtime; it does not mean this example requires installing a separate Vitest runner.

Run from that configured module:

mx test

The runner needs an available Chromium browser. Setup is covered by mx tools. A missing browser, an unresolved import, zero tests or a lost result is not a passing run.

Read the assertions as evidence

The first pair proves both panels were driven. The second pair proves the reset's target and its non-target. The final read checks the global model outside any scope. Cleanup unmounts the views before scope disposal so a hook can still use its live scope during teardown.

Record the actual test name and result from your run. Keep three claims separate: a source example expresses an expectation; a passing browser test measures that fixture; your application's acceptance also depends on its actual integration. Do not turn a successful typecheck into browser evidence.

Make the test fail for the right reason

In an isolated copy of the fixture, deliberately render both panels under the same scope. The count expectations should fail. Restore the separate scopes and rerun. This checks that the test can detect the ownership error it claims to prevent.

Add a separate runtime negative test for duplicate list keys or a bind/mapProps overlap. Keep type-negative cases separate: wrong event payload, missing required row prop, or conflicting union discriminator. The package's test/types.ts contains examples; its TypeScript check is independent of mx test. Do not remove an expectation just to make a run green.

Leave no state for the next case

Use the lab's whole-exercise restart for another manual attempt. In tests, give each case fresh scopes and clean up the mounted tree even after an assertion fails, as the finally block does. Release any explicit observers or resources your fixture adds.

Repeat with the right panel as the reset target. The symmetry makes accidental special treatment of the first panel visible.

Protect the rest of the interface

Extend one boundary at a time:

  • Override a mapped prop, including explicit undefined, and assert the overridden projection is not called.
  • Reorder keyed rows and assert both local state and DOM node identity; remove one row and observe its cleanup.
  • Replace a selected child constructor and assert deliberate local-state reset; preserve a stable callback ref through reorder.
  • Render two scopes to HTML and assert different output with no lifecycle hook calls.

Use the existing Reflect tests as concrete reference cases, not as evidence that a changed application already passed. A release also needs built-package checks; source-workspace tests alone do not establish the contents of a published artifact.

You now have a complete path from model to visible behavior: bind, derive, isolate, compose, observe and test. Apply it to one real screen before adding more abstraction.

Previous · Course · Next