Skip to main content
@craft-ts
LearnLearn with EffectAI agentsGuideReference
@craft-ts/core@craft-ts/component@craft-ts/dev-tools@craft-ts/effect@craft-ts/style@craft-ts/style-testing@craft-ts/i18n@craft-ts/i18n-effect@craft-ts/mcp
ExamplesMigrationEffect compatibility & maturityAdopting CraftTS progressivelyPress kitRoadmapBacklog
Beta
  • Learn
  • Learn with Effect
  • AI agents
  • Guide
  • Reference
  • Packages
  • Resources
  • Guide overview
  • Create a project
i.Core concepts
  • The mental model
  • Which primitive should I use?
  • Anatomy of a primitive
  • Generators and yield*
  • Insertions
  • Typed insertion pipes
  • Exceptions as values
ii.Managing state
  • Local state
  • State machines
  • query
  • Mutations
  • queryParams
  • asyncProcess
  • Selecting
  • Reacting to mutations
  • Collections
  • Collection utilities
  • Persistence
  • GlobalPersisterHandler
  • Pagination placeholders
  • Schema validation
iii.Recommended approaches
  • Inject at the point of use
iv.Structuring the app
  • craftService
  • Service scopes
  • Shaping the public API
  • Abstract services
  • App start
  • Lazy services
  • Server functions
  • craftRegisterFor
  • Target wrapper
v.Routing & type-safe DI
  • Setup
  • CLI automation
  • ESLint rules
  • Route providers
  • Route guards
  • Route exception handling
  • Non-blocking navigation
  • Global error component
  • Route load errors
  • Scaling routes
vi.Components & templates
  • Components
  • Activating the style system
  • Typed styles
  • Defining a design system
  • Tokens and variables
  • Global foundation and fonts
  • Pseudo-elements and animations
  • Axes and the matrix
  • Context obligations
  • Testing visual states
  • Text contrast
  • Visual attestation
  • Template obligations
  • Typed CSS variables
  • Fine-grained reactivity
  • Progressive rendering
  • Directives and .pipe(...)
  • settledValue & pendingNode
  • Accessibility
  • Customization
  • Content projection
  • Styling a component
  • Template migrator
vii.Forms
  • Overview
  • Validators
  • Submitting
  • Nested forms
  • Exception handling
  • Complete examples
viii.Testing
  • Testing services
  • Testing components
  • Type-level tests
  • Browser boundaries
  • Page stability
  • Architecture rules
    • Overview
    • Declarative baseline
    • Unique identities
    • HTTP endpoint ownership
    • Computed purity
    • Dependency cycles
    • Mutation reactions
    • Route DI proofs
    • Route component files
    • Path boundaries
    • Exclusive links
    • Persisted identities
    • insertSelect keys
    • craftEffect and network
    • craftEffect and imperative sync
    • Interactive element names
    • Metric thresholds
    • Server-state loaders
    • Primitive loader requirements
  • Extensible architecture graph
  • Graph insights
  • Craft graph vs Nx
ix.Reactivity utilities
  • craftComputed
  • craftEffect
  • craftMethod
  • source$
  • on$
  • fromEventToSource$
  • sourceFromEvent
  • afterRecomputation
x.Internationalisation
  • Type-safe i18n
  • The catalogue
  • Tokens
  • The runtime
  • With Effect
xi.Deployment (experimental)
  • Overview
  • Manifest reference
  • Diagnostics
  • Providers
  • Alchemy provider
xii.Going further
  • SSR and hydration
  • Effect integration
  • Program operators
  • Pattern matching
  • Observability
  • AI agents
  • Temporal runtime
  1. Testing
  2. Testing components

Testing components#

A component is one function, so the seam is no longer inside it: it is the service the component takes. Behaviour lives there and is tested with plain values and no DOM; the component is then rendered against that service, real or mocked.

Use a service test for what the behaviour computes. Use the template test for what actually renders, and for interaction.

The rendering utilities live in a dedicated submodule:

ts
import {
  setupCraftComponentTemplateTest,
  setupCraftDirectiveTemplateTest,
} from '@craft-ts/component/testing';

Each also exposes a .byRegister(...) form, which makes the services used by the rendered code explicit.

The component's service#

setupCraftServiceTestingByRegister runs the service alone and returns what it exposes, together with the installed mocks:

ts
const { sut, mocks } = await setupCraftServiceTestingByRegister(TodoStoreView, {
  todoStoreView: provideTodoStoreView(),
  TodoStore: {
    todos: {
      status: () => 'resolved',
      value: () => [],
    },
  },
});

expect(craftUse(sut.todos.value())).toEqual([]);
expect(mocks.TodoStore).toBeDefined();

A service that takes inputs receives them through bindings; see Testing services.

Component template#

The template test mounts the component with the inputs it declares, and with its services taken from register:

ts
const test = await setupCraftComponentTemplateTest.byRegister(StatusComponent, {
  inputs: { status: () => 'resolved' },
  register: { statusView: provideStatusView() },
});

expect(test.nativeElement.textContent).toContain('Loaded');
test.detectChanges();
test.updateInputs({ status: () => 'error' });
expect(test.nativeElement.textContent).toContain('Error');
test.destroy();

The result exposes nativeElement, element, mocks, detectChanges, updateInputs, locator, the accessible queries (getByRole, getByLabel, …), and destroy. Craft styles, child components, Craft directives, and reactivity are rendered by the normal renderer.

Explicit DOM locators#

Template tests also expose locator(tag, criteria). The tag determines the DOM element type, while class, data-*, and aria-* criteria are matched against the rendered element. Prefer data-* and aria-*: a class comes from a sheet and is a list of atoms, not a name a test should depend on.

ts
import { button, craftComponent, div } from '@craft-ts/component';
import { setupCraftComponentTemplateTest } from '@craft-ts/component/testing';

const Editor = craftComponent(
  'Editor',
  {},
  () =>
    div([
      button(
        'save',
        { type: 'button', 'data-testid': 'save' },
        'Save',
      ),
    ]),
);

it('finds the save button', async () => {
  const test = await setupCraftComponentTemplateTest.byRegister(Editor, {
    inputs: {},
    register: {},
  });

  const saveButton = test.locator('button', { 'data-testid': 'save' });

  expect(saveButton?.textContent).toBe('Save');
  saveButton?.click();
  test.destroy();
});

The notation tag('name', props, children) is generic: tag means the HTML helper for the element you want. There is no separate tag function. For a button, write the three arguments explicitly:

ts
const saveButton = button(
  'save', // name: stable local name
  { type: 'button' }, // props: DOM properties and attributes
  'Save', // children: rendered content
);

The same pattern works with every built-in helper:

ts
import { input } from '@craft-ts/component';

const searchInput = input('search', { 'aria-label': 'Search' }, []);

The name is rendered as data-craft-name="save" and can be used as a complementary named locator when a class is not sufficiently discriminating.

Locating branded content#

When an element directly renders a branded Craft value, use the brand name as the content criterion. The locator does not inspect the rendered value, so this also works for non-text values and remains independent of formatting:

typescript
import { span, craftComponent } from '@craft-ts/component';
import { state } from '@craft-ts/core';

const Status = craftComponent('Status', {}, function* () {
  const brandedStatus = yield* state('brandedStatus', 'ready');
  return span(brandedStatus);
});

const test = await setupCraftComponentTemplateTest.byRegister(Status, {
  inputs: {},
  register: {},
});

const brandedStatusElement = test.locator('span', {
  content: 'brandedStatus',
});
expect(brandedStatusElement.textContent).toBe('ready');
test.destroy();

This template has no ifNode, forNode, or deferNode, so brandedStatusElement is an HTMLSpanElement, never undefined; optional chaining is not needed here.

The brand name is part of the template type. An unknown value such as { content: 'missing' } is rejected by TypeScript. The return type is the inferred DOM type when the element is always rendered. Under ifNode, forNode, or deferNode, it is MaybeDefined<HTMLSpanElement> (equivalent to HTMLSpanElement | undefined), so callers must handle the absent branch.

Use static, discriminating markers for locators. A literal class or attribute declared in the template is a stable proof; a value produced by a binding is not. Attributes declared through attrs are queried using their rendered attribute name:

ts
input({ attrs: { 'aria-label': 'Search' } });
test.locator('input', { 'aria-label': 'Search' });

The locator searches the complete rendered subtree, including Craft child components. A branch that is currently absent returns undefined; a runtime result with more than one matching element throws an explicit cardinality error. Call the locator again after updateInputs and detectChanges when a conditional branch changes.

When a class is not sufficiently discriminating, keep using the existing named locators (tag('name', props, children)) and query their data-craft-name marker. A future collection API will cover repeated targets; the singular locator should remain reserved for one expected element.

To verify that a DOM property is connected to the correct member, add a contract assertion next to the template test:

ts
import { setupCraftComponentTemplateTest } from '@craft-ts/component';

import {
  craftComputed,
  craftService,
  craftUse,
  setupCraftServiceTestingByRegister,
  state,
} from '@craft-ts/core';
import { craftComponent, button } from '@craft-ts/component';
import type {
  ComponentTemplateOf,
  TemplateChildren,
  TemplateRendersStateWhen,
} from '@craft-ts/component';
import type { Equal, Expect } from 'test-type';

const { CounterView, provideCounterView } = craftService(
  { name: 'counterView', providedIn: 'toProvide' },
  function* () {
    yield* state('counter', 0, ({ state, update }) => ({
      disabled: craftUse(craftComputed('disabled', function* () {
        return (yield* state()) % 2 === 0;
      })),
      increment: () => update((value) => value + 1),
    }));

  },
);

const Counter = craftComponent(
  'Counter',
  { providers: [provideCounterView()] },
  function* () {
    const { counter } = yield* CounterView();

    return button(
      'increment',
      {
        type: 'button',
        disabled: counter.disabled,
        click: counter.increment,
      },
      '+',
    );
  },
);

// The service is where the state lives, so that is where the derivation is
// tested — no DOM involved.
it('tests the derived disabled state', async () => {
  const { sut } = await setupCraftServiceTestingByRegister(CounterView, {
    counterView: provideCounterView(),
  });

  expect(craftUse(sut.counter.disabled())).toBe(true);

  craftUse(sut.counter.increment());

  expect(craftUse(sut.counter())).toBe(1);
  expect(craftUse(sut.counter.disabled())).toBe(false);
});

// The template is tested against that service, real or mocked.
it('renders the button the service disables', async () => {
  const template = await setupCraftComponentTemplateTest(Counter, {
    inputs: {},
    register: { counterView: provideCounterView() },
  });

  try {
    expect(
      template.getByRole('button', { name: '+' }).hasAttribute('disabled'),
    ).toBe(true);
  } finally {
    template.destroy();
  }
});

type _DisabledBindingIsCorrect = Expect<
  Equal<
    TemplateRendersStateWhen<
      TemplateChildren<ComponentTemplateOf<typeof Counter>>,
      'counter.disabled'
    >,
    true
  >
>;

TypeScript performs this check. It fails if the branded counter.disabled read is no longer exposed by the rendered template. It does not replace the rendering test; it verifies the template contract without a DOM.

Inputs and service dependencies#

inputs are what the call site passes; everything the component takes goes through register — including its own service:

ts
await setupCraftComponentTemplateTest.byRegister(FullDemoCraft, {
  inputs: {},
  register: { fullDemoView: todoStoreMock },
});

If the component or a child uses a FormatterService, the registry contains FormatterService, never the child component:

ts
register: {
  FormatterService: formatterMock,
}

CraftComponentTemplateDepsOf<Component> is the projection that types the registry: it lists the services the rendered tree reaches. Child components are never entries in register.

Registry values and providers#

Resolution follows the same rules as service tests:

  • an object is a mock and is available in mocks;

  • 'real' keeps the real service;

  • 'notReached' documents a branch removed by a parent mock;

  • 'provided' requests the value provided by the parent injector;

  • a provideX(...) provider explicitly configures a service.

Providers declared in meta.providers are available in the component scope. Upstream providers go in providers:

ts
await setupCraftComponentTemplateTest.byRegister(Component, {
  inputs: {},
  providers: [provideApiService({ baseUrl: '/test' })],
  register: {
    ApiService: 'provided',
  },
});

appStart decisions ('run' or 'ignore') are available in the options when the tested graph contains a service with appStart: true.

Testing a directive#

A directive declares transformations, so each is tested where it acts. A service transform is tested like any service — take the service the directive overrides and read the members it changed. A template transform is mounted with the base template it wraps:

ts
const test = await setupCraftDirectiveTemplateTest.byRegister(whenDirective, {
  baseTemplate: (inputs) => p(inputs.message),
  inputs: { when: () => true, message: 'ready' },
  register: {},
});

test.updateInputs({ when: () => false, message: 'hidden' });
test.destroy();

Structural directives follow the same path and can verify that rendering is replaced with []. Calling destroy() cleans up views, injectors, listeners, and acquired styles.

Type-level tests#

The template's contract can also be checked without rendering anything — that an element only appears under a condition, that a binding is really the one you think, that a list item renders its label. That is its own page: Type-level tests.

See Also#

  • Testing services

  • Browser boundaries

  • Architecture rules — constraints on the whole app graph

  • Routing setup — where GenDeps_* comes from

←Testing servicesType-level tests→

On this page

  • The component's service
  • Component template
  • Explicit DOM locators
  • Locating branded content
  • Inputs and service dependencies
  • Registry values and providers
  • Testing a directive
  • Type-level tests
  • See Also

@craft-ts · MIT licensed

GitHubnpm

Search