Get started
Reference

Testing

Testing

Three packages publish a /testing entry point, each opt-in and off every other barrel. None names a test runner: every spy is a factory you pass in, so vi.fn and jest.fn both work. Install the packages you draw with, then import the subpath.

Pick an entry point

You are testingReach forFrom
Anything taking a StudioToolContextfakeViewer, fakeStudioCache, studioToolBus@mountaya/studio/testing
A hook driving a MapClientcreateMapClientStub, createMapClientSpy@mountaya/elements/testing
The MountayaMap facadecreateMountayaMapDouble@mountaya/elements/testing
A surface rendering wire recordsmakeItinerary, makeLineSegment, makeCollection@mountaya/studio-react/testing
A surface reading the viewer or host registryWithViewer, WithStudioHostRegistry@mountaya/studio-react/testing
A surface that reads the APIWithMountayaClient@mountaya/studio-react/testing

Fake wires live elsewhere: @mountaya/elements/collab and @mountaya/elements/agent publish scripted transports beside their protocols.

Double the map client

The only real MapClient comes from a mounted MapLibre map, which a suite with no WebGL context cannot have. createMapClientStub is a full one: no-op commands, neutral-empty readers.

import { createMapClientStub } from "@mountaya/elements/testing";

const map = createMapClientStub({
  getViewport: () => ({ center: [6.87, 45.83], zoom: 13 }),
});

createMapClientSpy wraps every method with a factory you supply, so a suite can assert which commands ran.

import { createMapClientSpy } from "@mountaya/elements/testing";
import { expect, test, vi } from "vitest";

test("frames the itinerary", () => {
  const map = createMapClientSpy(vi.fn);

  map.fitBounds([5.9, 45.8, 7.1, 46.5]);

  expect(map.fitBounds).toHaveBeenCalledWith([5.9, 45.8, 7.1, 46.5]);
});

Both are full object literals over MapClient. A method added to the interface fails to compile rather than vanishing at runtime.

Arrange a tool context

A viewer is a session, a cache is a host's query layer, and a bus is the thing under test. One double each.

import { fakeStudioCache, fakeViewer, studioToolBus } from "@mountaya/studio/testing";

const bus = studioToolBus();
const cache = fakeStudioCache();
const viewer = fakeViewer();

viewer.setEntitlements("video-export");

fakeStudioCache records rather than caches. Read cache.invalidated to see which resources a dispatch made stale.

fakeViewer starts where an unhydrated session starts: no user, no entitlement, no ceiling, no preferences. That is the branch a handler takes for an anonymous embed. setEntitlements replaces the held set, so calling it with nothing revokes everything.

studioToolBus() arrives with every published handler registered. emptyStudioToolBus() is the same bus with none.

Build records shaped like the wire

The builders return the generated record in full. A partial fixture compiles today and rots the day the contract gains a field.

import { makeItinerary, makeItineraryFixture, makeLineSegment } from "@mountaya/studio-react/testing";

// One record, with only what this test cares about named.
const itinerary = makeItinerary({ id: "itn-1", name: "Tour du Mont-Blanc" });

// One segment, for a suite that renders exactly one row.
const segment = makeLineSegment({
  color: "#e8590c",
  coordinates: [
    [6.86, 45.83, 1035],
    [6.88, 45.85, 1850],
  ],
  id: "seg-a",
  name: "The Climb",
});

// A whole itinerary, segments and waypoints on it.
const detailed = makeItineraryFixture();

makeLineSegment and makePoiWaypoint take the members a suite overrides by name, as makeItinerary does, and derive the rest. Reach for them when you render one row. Reading segments[0] off a whole itinerary couples the suite to a fixture.

Render a surface without a studio

useStudioViewer and useStudioHostRegistry throw outside a host, so a component test supplies the context directly.

import { viewerWith, WithViewer } from "@mountaya/studio-react/testing";
import { render } from "@testing-library/react";

render(
  <WithViewer viewer={viewerWith({ entitlements: ["video-export"] })}>
    <YourSurface />
  </WithViewer>,
);

viewerWith states both halves of a viewer: the features they hold, and the ceilings that apply. Both default to nothing, so a suite whose gate turns on a cap names that number. unsettledViewer is the third state, and the one a render most often gets wrong.

WithStudioHostRegistry does the same for the host registry, with fakeStudioHostRegistry supplying the slots a surface looks up.

Answer a surface's reads

A surface that reads (a collections picker, a zone's inherited context) throws outside a MountayaProvider. WithMountayaClient stands it up over a client whose every request is answered from a table you hand, under a query cache of its own, so the suite's reads stay on the contract and no request leaves the machine.

import { WithMountayaClient } from "@mountaya/studio-react/testing";
import { render } from "@testing-library/react";

render(
  <WithMountayaClient
    answers={{
      "GET /v1/collections": {
        data: [],
        extensions: { metadata: { pagination: { limit: 50, next_cursor: null } } },
      },
      "PATCH /v1/collections/col-1": { body: { message: "Not yours." }, status: 403 },
    }}
    userId="user-1"
  >
    <YourSurface />
  </WithMountayaClient>,
);

userId names the user the suite's session token was minted for, whom every write beneath the wrapper acts as. null is a token minted for no user, under which the client refuses every write that acts as a member before it is sent, and leaving it out sends each write for the table to answer.

A key is a request's method in capitals, a space and its path without the query string. A value is the JSON body answered under a 200, or { status, body } to answer under another status, which reaches the surface as the client's own error for it. A request the table does not answer is answered 404. The client and the cache are built once per mount and never retry, so a refused read settles at once.

Next steps