Testing components
Craft components are tested in two independent halves: the logic factory (plain values, no DOM) and the template (real DOM, explicit locators). You can test one without paying for the other.
Use the logic test for what the factory computes and exposes. Use the template test for what actually renders, and for interaction.
The utilities live in a dedicated submodule:
import {
setupCraftComponentLogicTest,
setupCraftComponentTemplateTest,
setupCraftDirectiveLogicTest,
setupCraftDirectiveTemplateTest,
} from '@craft-ng/component/testing';They complement the existing Angular setup and deliberately separate the factory from rendering. Each utility also exposes a .byRegister(...) form, which makes the services used by the tested code explicit.
The package also re-exports the legacy registry-based setups:
import {
setupCraftServiceTestingByRegister,
setupCraftComponentTestingByRegister,
} from '@craft-ng/component/testing';They remain compatible with the existing test setup and can be used in the same file as the logic/template utilities.
Component logic
The logic test executes only the factory and returns its context together with the installed mocks:
const { context, mocks, destroy } =
await setupCraftComponentLogicTest.byRegister(FullDemoCraft, {
register: {
TodoStore: {
todos: {
status: () => 'resolved',
value: () => [],
},
},
},
});
expect(context.store.todos.value()).toEqual([]);
expect(mocks.TodoStore).toBeDefined();
destroy();Factory arguments can be provided through args when the component declares inputs:
await setupCraftComponentLogicTest.byRegister(StatusComponent, {
args: [statusInput],
register: {},
});Component template
The template test receives an already-built context. The component logic is not executed:
const test = await setupCraftComponentTemplateTest.byRegister(StatusComponent, {
context: { status: () => 'resolved' },
register: {},
});
expect(test.nativeElement.textContent).toContain('Loaded');
test.detectChanges();
test.updateContext({ status: () => 'error' });
expect(test.nativeElement.textContent).toContain('Error');
test.destroy();The result exposes nativeElement, element, mocks, detectChanges, updateContext, 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:
import { button, craftComponent, div } from '@craft-ng/component';
import { setupCraftComponentTemplateTest } from '@craft-ng/component/testing';
const Editor = craftComponent(
'Editor',
{},
() => ({}),
() =>
div([
button(
'save',
{
class: 'save',
'data-testid': 'save',
},
'Save',
),
]),
);
it('finds the save button', async () => {
const test = await setupCraftComponentTemplateTest.byRegister(Editor, {
context: {},
register: {},
});
const saveButton = test.locator('button', {
class: 'save',
'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:
const saveButton = button(
'save', // name: stable local name
{ class: 'save' }, // props: DOM properties and attributes
'Save', // children: rendered content
);The same pattern works with every built-in helper:
import { input } from '@craft-ng/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:
import { signal } from '@angular/core';
import { span, craftComponent } from '@craft-ng/component';
import { markYieldableValue, state } from '@craft-ng/core';
const Status = craftComponent(
'Status',
{},
function* () {
const brandedStatus = yield* state('brandedStatus', 'ready');
return { brandedStatus };
},
({ brandedStatus }) => span(brandedStatus),
);
const test = await setupCraftComponentTemplateTest.byRegister(Status, {
context: {
brandedStatus: markYieldableValue(signal('ready'), 'brandedStatus'),
},
register: {},
});
const brandedStatusElement = test.locator('span', {
content: 'brandedStatus',
});
expect(brandedStatusElement.textContent).toBe('ready');
test.destroy();This template has no ifBlock, each, or defer, 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 ifBlock, each, or defer, 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:
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 updateContext 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 context member, add a contract assertion next to the template test:
import { computed } from '@angular/core';
import { state } from '@craft-ng/core';
import { craftComponent, button } from '@craft-ng/component';
import type {
ComponentTemplateOf,
TemplateRendersStateWhen,
} from '@craft-ng/component';
import type { Equal, Expect } from 'test-type';
const Counter = craftComponent(
'Counter',
{},
function* () {
const counter = yield* state('counter', 0, ({ state, update }) => ({
disabled: computed(() => state() % 2 === 0),
increment: () => update((value) => value + 1),
}));
return { counter };
},
({ counter }) =>
button(
{
disabled: () => counter.disabled(),
*click() {
yield* counter.increment();
},
},
'+',
),
);
it('tests the derived disabled state', async () => {
const { context, destroy } = await setupCraftComponentLogicTest.byRegister(
Counter,
{
register: {},
},
);
try {
expect(context.counter.disabled()).toBe(true);
context.counter.increment();
expect(context.counter()).toBe(1);
expect(context.counter.disabled()).toBe(false);
} finally {
destroy();
}
});
type _DisabledBindingIsCorrect = Expect<
Equal<
TemplateRendersStateWhen<
ReturnType<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.
Context and service dependencies
The context is a factory value and is not a registry dependency. In this example, store is provided directly to the template:
await setupCraftComponentTemplateTest.byRegister(FullDemoCraft, {
context: { store: todoStoreMock },
register: {},
});Conversely, if StatusComponent or a child component uses a FormatterService, the template registry contains FormatterService, never the child component:
register: {
FormatterService: formatterMock,
}The CraftComponentLogicDepsOf<Component> and CraftComponentTemplateDepsOf<Component> projections keep these two graphs separate. A template registry therefore accepts only services; 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:
await setupCraftComponentLogicTest.byRegister(Component, {
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
Directive logic receives its baseLogic and arguments explicitly:
const { context } = await setupCraftDirectiveLogicTest.byRegister(
hasPermissionInput,
{
baseLogic,
args: [userInput, permissionInput],
register: {},
},
);For the template, provide baseTemplate and the final context:
const test = await setupCraftDirectiveTemplateTest.byRegister(whenDirective, {
baseTemplate: (context) => p(context.message()),
context: { when: () => true, message: () => 'ready' },
register: {},
});
test.updateContext({ 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
- Routing setup — where
GenDeps_*comes from