DraftThis article is currently in draft mode

Testing View Models: The Key Pattern

November 11, 2025

Testing View Models: The Key Pattern#

When testing view model interfaces, we face a design tension: tests need to navigate lists and verify state changes, but view models intentionally hide business logic details like IDs. This article describes how the key property solves this problem while maintaining proper abstraction boundaries.

The Problem#

View models expose UI-ready data and actions but deliberately hide business logic concepts:

// ❌ This breaks the abstraction
interface ProposalVM {
  proposalId: ProposalID; // Business logic leaking into view layer
  title: string;
  onClick: () => void;
}

// ✅ View model interface stays clean
interface ProposalVM {
  key: string; // Used by React, also useful for testing
  title: string;
  onClick: () => void;
}

Why avoid exposing IDs?

  • Prevents tests from reaching into business logic layers
  • Enforces testing at the UI layer (what users actually interact with)
  • Makes tests resilient to ID format changes
  • Discourages anti-patterns like manual queries using IDs

Examples#

Anti-Pattern: Exposing Business IDs#

// @ts-nocheck
import type { ProposalID } from "#src/livestore/schema.ts";
import type { Workspace } from "#src/obj-wip/workspace/workspace-types.ts";

// ❌ Anti-pattern: exposing business logic IDs
export interface ProposalHandle {
  proposalId: ProposalID; // Business logic leaking into view layer
  vm: Workspace.SelectedProposalVM;
}

export function openProposal(titleText: PhosphorText): ProposalHandle {
  // ... create proposal logic ...

  const selectedProposals = jotaiStore.get(scope.vm.historyRightSidebar.selectedProposals.selectedProposalsAtom);
  const newProposal = selectedProposals[selectedProposals.length - 1];

  invariant(newProposal, "Proposal was not created", { selectedProposals });

  return {
    proposalId: (newProposal._dev as any).id as ProposalID, // Exposing internal ID
    vm: newProposal,
  };
}

// Test has to know ID format and use awkward .toString() conversion
const handle = openProposal(PhosphorText.fromString("My Feature"));
const mergedProposals = ctx.jotaiStore.get(mergedProposalsAtom);

// ❌ Awkward: converting ID to string and using includes
const proposal = mergedProposals.find((p) => p.key.includes(handle.proposalId.toString()));

Preferred: Using Keys#

// @ts-nocheck
import type { Workspace } from "#src/obj-wip/workspace/workspace-types.ts";

// ✅ View model interface stays clean
export interface ProposalHandle {
  key: string; // UI-layer handle (same key React uses)
  vm: Workspace.SelectedProposalVM;
}

export function openProposal(titleText: PhosphorText): ProposalHandle {
  // ... create proposal logic ...

  const selectedProposals = jotaiStore.get(scope.vm.historyRightSidebar.selectedProposals.selectedProposalsAtom);
  const newProposal = selectedProposals[selectedProposals.length - 1];

  invariant(newProposal, "Proposal was not created", { selectedProposals });

  return {
    key: newProposal.key, // Using the same key React uses
    vm: newProposal,
  };
}

// Test uses clean UI-layer concepts
const handle = openProposal(PhosphorText.fromString("My Feature"));
const mergedProposals = ctx.jotaiStore.get(mergedProposalsAtom);

// ✅ Clean: using key directly
const proposal = t.findByKey(mergedProposals, handle.key);

The Solution: Test via Keys#

The key property (already required for React rendering) becomes a stable, untyped handle for test navigation:

// Test helper returns key, not ID
interface ProposalHandle {
  key: string;
  vm: ProposalVM;
}

function openProposal(titleText: string): ProposalHandle {
  // ... create proposal ...
  return {
    key: newProposal.key,
    vm: newProposal,
  };
}

// Test uses key to find proposals later
const handle = openProposal("My Feature");
await wait();

// Navigate to merged proposals using the key
const merged = findByKey(mergedProposalsAtom, handle.key);
expect(merged).toBeDefined();
merged.onClick(dev`verify proposal`);

Generic Key Finder#

The pattern generalizes to a reusable helper:

import type { Atom, JotaiStore } from "jotai";

/**
 * Generic helper to find items by key in arrays or atoms.
 *
 * Uses partial matching (includes) to handle keys with additional context
 * like timestamps or prefixes.
 */
export function findByKey<T extends { key: string }>(
  store: JotaiStore,
  valueOrAtom: T[] | Atom<T[]> | undefined | null,
  key: string,
): T | null {
  if (!valueOrAtom) return null;

  const items = Array.isArray(valueOrAtom) ? valueOrAtom : store.get(valueOrAtom);

  return items.find((item) => item.key.includes(key)) ?? null;
}

// Available on all test harnesses
export interface UITestHarnessBase {
  store: JotaiStore;
  wait: () => Promise<void>;
  findByKey<T extends { key: string }>(valueOrAtom: T[] | Atom<T[]> | undefined | null, key: string): T | null;
  // ... other helpers
}

Why includes() instead of exact match?

  • Keys often encode additional context (e.g., proposal-${id}-${timestamp})
  • Partial matching works like CSS selectors or test IDs
  • Still specific enough for test isolation

Real-World Example: Historical Navigation#

// @ts-nocheck
import { PhosphorText, dev, invariant } from "@phosphor/utils";
import { expect, it } from "vitest";
import { WorkspaceObjectType } from "#src/livestore/WorkspaceObjectType.ts";

it("shows historical state when selecting older proposals", async () => {
  const dispatchMergeProposal = (ctx.scope.vm._dev as any).dispatchMergeProposal;

  // Create a counter (starts at 0)
  const counter = await t.createNewObject(WorkspaceObjectType.Counter);
  await t.wait();

  // Proposal 1: Create counter (value = 0)
  const p1 = t.openProposal(PhosphorText.fromString("Create counter"));
  await t.wait();
  dispatchMergeProposal(dev`merge p1`, p1.key); // ✅ Using key, not ID
  await t.wait();

  // Proposal 2: Increment to 5
  counter.dispatchOperation(dev`increment to 5`, { Increment: { amount: 5 } });
  await t.wait();

  const p2 = t.openProposal(PhosphorText.fromString("Increment to 5"));
  await t.wait();
  dispatchMergeProposal(dev`merge p2`, p2.key); // ✅ Using key, not ID
  await t.wait();

  // Proposal 3: Increment to 10
  counter.dispatchOperation(dev`increment to 10`, { Increment: { amount: 5 } });
  await t.wait();

  const p3 = t.openProposal(PhosphorText.fromString("Increment to 10"));
  await t.wait();
  dispatchMergeProposal(dev`merge p3`, p3.key); // ✅ Using key, not ID
  await t.wait();

  // Helper to get counter value
  const getCounterValue = () => {
    const activeObject = ctx.jotaiStore.get(ctx.scope.vm.activeObjectAtom);
    invariant(activeObject?.objectViewModel.name === WorkspaceObjectType.Counter, "Expected counter to be active");
    return ctx.jotaiStore.get((activeObject.objectViewModel as any).valueAtom);
  };

  // Make counter active
  const counterItem = getCounterItem();
  counterItem.onClickObject(dev`select counter`);
  await t.wait();

  // At current state, counter should be 10
  expect(getCounterValue()).toBe(10);

  // Navigate back in time using keys
  const mergedProposals = ctx.jotaiStore.get(ctx.scope.vm.historyRightSidebar.mergedProposals.mergedProposalsAtom);

  // ✅ Clean: find proposal by key
  const proposal2 = t.findByKey(mergedProposals, p2.key);
  invariant(proposal2, "Expected to find proposal 2");

  proposal2.onClick(dev`view proposal 2 state`);
  await t.wait();

  // Counter should show value at that point in history
  expect(getCounterValue()).toBe(5);

  // ✅ Navigate to even earlier state
  const proposal1 = t.findByKey(mergedProposals, p1.key);
  invariant(proposal1, "Expected to find proposal 1");

  proposal1.onClick(dev`view proposal 1 state`);
  await t.wait();

  // Counter should show initial value
  expect(getCounterValue()).toBe(0);
});

Before and After Comparison#

Before (exposing IDs):

// ❌ Anti-pattern: exposing business logic
interface ProposalHandle {
  proposalId: ProposalID;
  vm: ProposalVM;
}

// Test has to know ID format
const proposal2 = mergedProposals.find((p) => p.key.includes(proposal2Id.toString()));

After (using keys):

// ✅ UI-layer testing
interface ProposalHandle {
  key: string;
  vm: ProposalVM;
}

// Test uses UI-layer concepts
const proposal2 = t.findByKey(mergedProposals, handle.key);

Benefits#

  1. Abstraction Integrity: Tests operate purely at the UI layer
  2. Cleaner Tests: findByKey(items, handle.key) vs find(p => p.key.includes(id.toString()))
  3. Type Safety: If something has a key, it's UI-visible
  4. Resilience: Key encoding can change without breaking tests
  5. Discoverability: Test harness exposes findByKey alongside other UI helpers

Guidelines#

  • Always expose key in handles, never business IDs
  • Use findByKey for list navigation in tests
  • Ensure keys are stable within a test run (deterministic)
  • Consider adding semantic prefixes to keys for debugging (e.g., proposal-${id} vs just ${id})

Relationship to View Model Principles#

This pattern reinforces core view model principles:

  • Framework-agnostic reactivity: Keys work with any rendering layer
  • UI-ready outputs: Keys are what React needs anyway
  • Opaque business logic: IDs stay internal
  • Testable without DOM: Navigate VMs using the same keys React uses

By treating key as the primary handle for test navigation, we maintain clean abstraction boundaries while building practical, maintainable tests.