QA How-To
Cypress Component Test Angular Signals Tutorial (2026)
Learn Cypress component test Angular signals setup with runnable input, computed, effect, output, and zoneless examples for reliable Angular UI tests.
23 min read | 2,503 words
TL;DR
Mount a standalone Angular signal component with cy.mount(), pass signal input values through componentProperties, interact through accessible controls, and assert rendered state plus output spies. Use cypress/angular-zoneless for Angular 21 zoneless applications and Cypress 15.8 or newer.
Key Takeaways
- Use the cypress/angular harness for Angular 18 through 21 signal components.
- Use cypress/angular-zoneless for an Angular 21 zoneless project with Cypress 15.8 or newer.
- Drive signal inputs through componentProperties and assert what the user can observe.
- Invoke component methods through the MountResponse when a test must cause an internal signal transition.
- Pass cy.spy() as an output property to verify emitted business events without a host fixture.
- Test computed values through rendered consequences instead of duplicating their formula in the test.
- Keep one focused integration test for effects because effects are asynchronous and intended for side effects.
A cypress component test angular signals workflow should prove that signal-driven UI state changes correctly in a real browser. Mount the Angular component, provide its signal inputs, operate it as a user would, and assert its DOM and outputs. Cypress 15.8+ supports both the standard cypress/angular harness and the cypress/angular-zoneless harness for Angular 21 projects.
This tutorial builds a standalone quantity picker with input(), signal(), computed(), effect(), and output(). You will test each behavior without reaching into private fields or adding test-only production code. If component testing itself is new to you, read the Cypress component testing guide for the runner model, then return here for the signal-specific implementation.
What You Will Build
You will create a QuantityPickerComponent for a shopping flow. By the end, its Cypress suite will verify:
- A required product label and unit price supplied as signal inputs.
- Increment and decrement transitions stored in a writable signal.
- A computed total derived from quantity and unit price.
- A disabled boundary at the minimum quantity.
- A typed
output()event carrying the selected quantity. - An
effect()that writes an accessible status message. - The same spec under Angular 21's zoneless test harness.
The tests run in Chrome's real DOM with Angular change detection, so button behavior, text interpolation, property binding, and accessibility state are covered together. This is narrower than an end-to-end checkout test and more realistic than testing signal functions in isolation.
Prerequisites
Use these versions for the reproducible path in this tutorial:
| Tool | Version | Reason |
|---|---|---|
| Node.js | 22.22.3 | Supported by Angular 21 and suitable for Cypress 15 |
| npm | 10.9.4 | Ships with the selected Node line |
| Angular CLI | 21.2.0 | Creates a standalone, zoneless Angular 21 workspace |
| Angular | 21.2.0 | Supported by Cypress Component Testing |
| TypeScript | 5.9.x | Angular 21 requires TypeScript 5.9 |
| Cypress | 15.8.0 | Introduces the Angular zoneless harness |
Cypress documentation currently lists Angular 18, 19, 20, and 21 as supported. Angular 22 is not in that compatibility list, so do not silently upgrade this tutorial workspace to Angular 22. For broader configuration choices, the Cypress framework from scratch tutorial explains how configuration files and support commands fit together.
Create the project and pin Cypress:
npx @angular/cli@21.2.0 new signal-shop --standalone --style=css --routing=false --skip-git --package-manager=npm
cd signal-shop
npm install --save-dev cypress@15.8.0
Verify: run node --version, npx ng version, and npx cypress version. Expect Node v22.22.3, Angular CLI 21.2.0, and Cypress package 15.8.0. A newer patch in the same supported line may work, but the pinned versions remove ambiguity while you follow the steps.
Step 1: Configure Cypress Component Test Angular Signals Support
Open Cypress once and choose Component Testing. Select Chrome, accept the detected Angular project, and let the setup wizard create the support files.
npx cypress open
For a deterministic configuration, make cypress.config.ts contain:
import { defineConfig } from 'cypress';
export default defineConfig({
component: {
devServer: {
framework: 'angular',
bundler: 'webpack',
},
specPattern: 'src/**/*.cy.ts',
},
});
Angular 21 creates zoneless applications by default. Cypress 15.8 added cypress/angular-zoneless, which avoids requiring Zone.js merely for the mount harness. Replace the generated contents of cypress/support/component.ts with:
import { mount } from 'cypress/angular-zoneless';
declare global {
namespace Cypress {
interface Chainable {
mount: typeof mount;
}
}
}
Cypress.Commands.add('mount', mount);
Keep the cypress/angular import if your application intentionally uses Zone.js. The component spec below is otherwise identical. The older @cypress/angular-signals package and cypress/angular-signals harness are obsolete because signal support moved into Cypress's main Angular harness in Cypress 14.
Verify: run npx cypress run --component --browser chrome. With no specs yet, Cypress should load the component project rather than report a dev-server configuration error. If TypeScript rejects cy.mount, confirm the declaration is in the support file selected by the wizard.
Step 2: Create the Signal-Driven Angular Component
Generate a standalone component:
npx ng generate component quantity-picker --standalone --skip-tests --inline-template --inline-style
Replace src/app/quantity-picker/quantity-picker.ts with this complete implementation:
import {
Component,
computed,
effect,
input,
output,
signal,
} from '@angular/core';
@Component({
selector: 'app-quantity-picker',
standalone: true,
template: `
<section aria-labelledby="picker-title">
<h2 id="picker-title">{{ productName() }}</h2>
<p data-cy="unit-price">Unit price: {{ unitPrice() | currency }}</p>
<div role="group" aria-label="Quantity controls">
<button
type="button"
aria-label="Decrease quantity"
[disabled]="quantity() === 1"
(click)="decrement()"
>-</button>
<output aria-live="polite" data-cy="quantity">{{ quantity() }}</output>
<button
type="button"
aria-label="Increase quantity"
(click)="increment()"
>+</button>
</div>
<p data-cy="total">Total: {{ total() | currency }}</p>
<p role="status">{{ status() }}</p>
<button type="button" (click)="confirm()">Add to cart</button>
</section>
`,
imports: [],
})
export class QuantityPickerComponent {
readonly productName = input.required<string>();
readonly unitPrice = input(25);
readonly initialQuantity = input(1);
readonly quantityConfirmed = output<number>();
readonly quantity = signal(1);
readonly total = computed(() => this.quantity() * this.unitPrice());
readonly status = signal('Quantity is 1');
constructor() {
effect(() => {
this.status.set(`Quantity is ${this.quantity()}`);
});
}
setInitialQuantity(value: number): void {
this.quantity.set(Math.max(1, value));
}
increment(): void {
this.quantity.update((current) => current + 1);
}
decrement(): void {
this.quantity.update((current) => Math.max(1, current - 1));
}
confirm(): void {
this.quantityConfirmed.emit(this.quantity());
}
}
Add CurrencyPipe to the @angular/common imports and component imports. The final import lines should be:
import { CurrencyPipe } from '@angular/common';
// Keep the @angular/core import shown above.
@Component({
// Keep the metadata and template shown above.
imports: [CurrencyPipe],
})
productName, unitPrice, and initialQuantity are read-only signal handles. quantity is writable internal state. total is derived and memoized. The effect reads quantity, so Angular reruns it after that dependency changes. The public method sets initial state explicitly because copying one input into independent state is an application decision, not automatic signal behavior.
Verify: run npx ng build. The build must finish without an unknown currency pipe error. If it does not, make sure CurrencyPipe is imported by the standalone component, not only by some unrelated application component.
Step 3: Mount Signal Inputs and Assert the Initial Render
Create src/app/quantity-picker/quantity-picker.cy.ts and start with this test:
import { QuantityPickerComponent } from './quantity-picker';
describe('QuantityPickerComponent signals', () => {
it('renders signal input values and computed total', () => {
cy.mount(QuantityPickerComponent, {
componentProperties: {
productName: 'Mechanical Keyboard',
unitPrice: 40,
},
});
cy.findByRole('heading', { name: 'Mechanical Keyboard' }).should('be.visible');
cy.get('[data-cy="unit-price"]').should('contain.text', '$40.00');
cy.get('[data-cy="quantity"]').should('have.text', '1');
cy.get('[data-cy="total"]').should('contain.text', '$40.00');
cy.findByRole('button', { name: 'Decrease quantity' }).should('be.disabled');
});
});
This example uses findByRole, so install Testing Library and import its Cypress commands:
npm install --save-dev @testing-library/cypress@10.0.0
Add this as the first line of cypress/support/component.ts:
import '@testing-library/cypress/add-commands';
Cypress's Angular mount options understand signal inputs. Pass plain values in componentProperties; the harness binds them to InputSignal properties. Do not call productName.set() because an InputSignal is intentionally read-only from the child component's perspective. Use roles for controls and headings. Reserve data-cy for values such as formatted totals where a semantic query would be ambiguous. See the Cypress data-cy selector guide for a maintainable selector policy.
Verify: run npx cypress run --component --spec src/app/quantity-picker/quantity-picker.cy.ts --browser chrome. Expect one passing test and a rendered quantity of 1. A missing required productName input should fail loudly, which is useful because it exposes an invalid component setup.
Step 4: Cypress Component Test Angular Signals State Transitions
Append a behavioral test inside the existing describe block:
it('updates writable and computed signals through user actions', () => {
cy.mount(QuantityPickerComponent, {
componentProperties: {
productName: 'Mechanical Keyboard',
unitPrice: 40,
},
});
cy.findByRole('button', { name: 'Increase quantity' }).click().click();
cy.get('[data-cy="quantity"]').should('have.text', '3');
cy.get('[data-cy="total"]').should('contain.text', '$120.00');
cy.findByRole('status').should('have.text', 'Quantity is 3');
cy.findByRole('button', { name: 'Decrease quantity' })
.should('be.enabled')
.click();
cy.get('[data-cy="quantity"]').should('have.text', '2');
});
One click calls quantity.update(). Angular invalidates total, schedules the effect, and refreshes bindings. Cypress retries each assertion until it passes or times out, so you do not need cy.wait(500) or a manual change-detection call. This is the strongest style of signal test because it checks the public interaction and every visible consequence.
Avoid asserting the computed formula alone. A test that evaluates 3 * 40 merely repeats production logic. The rendered $120.00 assertion additionally catches template wiring, pipe configuration, signal dependency, and browser output regressions. The status assertion confirms that the effect reacted to its tracked dependency.
Verify: rerun the spec command from Step 3. Expect two passing tests. Use the Cypress command log to inspect the DOM snapshot before and after each click if the quantity changes but the total does not.
Step 5: Set Internal State Through the Mount Response
Sometimes a component exposes a public method for a state transition that is not naturally triggered by its template. Use the MountResponse component instance, then return to DOM assertions:
it('normalizes an initial quantity through the public component API', () => {
cy.mount(QuantityPickerComponent, {
componentProperties: {
productName: 'USB Hub',
unitPrice: 15,
},
}).then(({ component }) => {
component.setInitialQuantity(4);
});
cy.get('[data-cy="quantity"]').should('have.text', '4');
cy.get('[data-cy="total"]').should('contain.text', '$60.00');
});
it('enforces the minimum when state arrives from an adapter', () => {
cy.mount(QuantityPickerComponent, {
componentProperties: {
productName: 'USB Hub',
unitPrice: 15,
},
}).then(({ component }) => {
component.setInitialQuantity(-3);
});
cy.get('[data-cy="quantity"]').should('have.text', '1');
cy.findByRole('button', { name: 'Decrease quantity' }).should('be.disabled');
});
Direct component access is appropriate here because setInitialQuantity is public application behavior that an adapter or parent can call. Do not mutate component.quantity directly. That would couple the test to storage instead of the contract and could bypass normalization.
There is an important distinction between an input signal and writable local state. Updating an input models a parent binding change. Calling a public method models an imperative integration. Choose the path that matches production usage rather than whichever needs fewer lines.
Verify: run the single spec. Expect four passing tests. The negative case should display 1, not -3 or 0, and its decrease control must remain disabled.
Step 6: Test Angular Signal Output With a Cypress Spy
Angular's output() returns an OutputEmitterRef. Cypress mount options can wire a callback or spy to that property. Add this test:
it('emits the confirmed quantity exactly once', () => {
cy.mount(QuantityPickerComponent, {
componentProperties: {
productName: 'Webcam',
unitPrice: 80,
quantityConfirmed: cy.spy().as('quantityConfirmed'),
},
});
cy.findByRole('button', { name: 'Increase quantity' }).click().click();
cy.findByRole('button', { name: 'Add to cart' }).click();
cy.get('@quantityConfirmed').should('have.been.calledOnceWith', 3);
});
The test asserts the event payload and call count. It does not need a wrapper host component because componentProperties connects the spy to the output. Keep the event assertion separate from the total assertion: one describes the component's contract with its parent, while the other describes rendering. When either fails, the focused test name identifies which boundary broke.
Do not use cy.stub(component.quantityConfirmed, 'emit') unless you specifically need to observe the emitter object. Supplying the callback through mounting behaves more like a real parent listener and avoids reaching into the component after construction.
Verify: run the spec and expect five passing tests. Temporarily remove the confirm() emit call to see the spy assertion fail with zero calls, then restore it. This controlled mutation proves the assertion observes the intended event rather than an unrelated click.
Step 7: Verify Dynamic Signal Input Changes With a Host
A static mount proves initial input binding. To prove that a parent signal update propagates to unitPrice, define a tiny standalone host inside the spec file:
import { Component, signal } from '@angular/core';
@Component({
standalone: true,
imports: [QuantityPickerComponent],
template: `
<app-quantity-picker
productName="Desk Lamp"
[unitPrice]="price()"
/>
<button type="button" (click)="price.set(35)">Apply sale price</button>
`,
})
class QuantityPickerHostComponent {
readonly price = signal(50);
}
Place those imports at the top of the spec and the host class before describe. Then add:
it('recomputes when a parent changes a signal input', () => {
cy.mount(QuantityPickerHostComponent);
cy.get('[data-cy="total"]').should('contain.text', '$50.00');
cy.findByRole('button', { name: 'Apply sale price' }).click();
cy.get('[data-cy="unit-price"]').should('contain.text', '$35.00');
cy.get('[data-cy="total"]').should('contain.text', '$35.00');
});
This test covers Angular's actual binding path. The parent owns price; the child receives a new unitPrice value; total is invalidated because it reads unitPrice(). A direct assignment to a child input would not model that chain as faithfully.
Use a host only when parent-child reactivity is the behavior under test. Creating one for every input adds noise and makes failures harder to localize. Initial values and output callbacks remain clearer through mount options.
Verify: run the component spec and expect six passing tests. In the Cypress runner, inspect the snapshot after Apply sale price. Both the unit price and total should show $35.00 while quantity stays 1.
Step 8: Run the Cypress Component Test Angular Signals Suite in CI
Add scripts to the existing package.json scripts object:
{
"scripts": {
"cy:component": "cypress run --component --browser chrome",
"cy:component:open": "cypress open --component"
}
}
Run the same headless command locally and in CI:
npm run cy:component
Do not switch to Electron in CI only to save setup time. Chrome in both places reduces browser-specific variation. Cache npm's download directory, but do not cache node_modules across incompatible Node or operating-system images. Preserve screenshots and videos when a retry or failure occurs. For systematic diagnosis, use the Cypress failing test debugging guide.
A component suite should stay below the end-to-end layer in scope. Stub HTTP boundaries when the component owns request presentation, but do not use a component spec to claim that routing, authentication, and the deployed API work together. Those concerns belong in focused integration and end-to-end coverage. The Cypress flaky test guide explains why arbitrary waits hide readiness defects rather than fixing them.
Verify: npm run cy:component should report six passing tests and exit with code 0. Also run npm run build to catch production compilation problems that the component dev server might not expose.
Testing Strategy: What Each Signal Assertion Proves
| Signal feature | Best trigger | Best observation | Avoid |
|---|---|---|---|
input() |
componentProperties |
Rendered label or property | Calling .set() on read-only input |
| Parent input update | Host signal and click | Changed child DOM | Assigning child internals |
signal() |
Accessible user action | DOM and enabled state | Reading private fields |
computed() |
Change one dependency | Derived rendered value | Reimplementing formula only |
effect() |
Change tracked dependency | External or visible side effect | Calling the effect manually |
output() |
User action | Spy payload and count | Stubbing unrelated DOM events |
Signal primitives are implementation tools, but component contracts are observable. Most tests should therefore assert behavior at the template or output boundary. A small number may use the mounted component to invoke a deliberately public API. This balance keeps refactoring possible while still identifying signal wiring defects.
Before adding another assertion, name the production failure it would catch. A quantity click test catches event binding, writable state, derived pricing, and boundary behavior. A separate host test catches parent-to-child invalidation. An output spy catches a broken event contract. If two cases detect the same defect through the same path, keep the clearer one. This coverage model shortens feedback while preserving meaningful risk coverage. It also prevents a signal migration from producing dozens of tests that only confirm Angular's framework implementation. Effects deserve restraint. Angular schedules effects as part of change detection, and they are meant for synchronization with non-reactive systems. Prefer computed() for derived state. If an effect writes browser storage, inject a wrapper service and spy on that service. If it only copies one signal into another, reconsider the component design because the copied state can drift.
Troubleshooting
Problem: Cypress reports that the Angular framework is unsupported -> Check the actual @angular/core major with npm ls @angular/core. Cypress Component Testing supports Angular 18 through 21 in the documented 2026 matrix. Use Angular 21 for this tutorial instead of forcing Angular 22 through an unsupported adapter.
Problem: Cannot find module 'cypress/angular-zoneless' -> Upgrade Cypress to 15.8.0 or newer. If the application uses Zone.js, import mount from cypress/angular instead and keep zone.js plus @angular-devkit/build-angular installed.
Problem: cy.mount is not a function -> Confirm Cypress.Commands.add('mount', mount) runs from cypress/support/component.ts, and confirm Cypress configuration has not overridden supportFile with a different path. Restart the runner after editing support registration.
Problem: findByRole is missing or TypeScript rejects it -> Install @testing-library/cypress, import @testing-library/cypress/add-commands from the component support file, and restart the TypeScript server. Use cy.get temporarily only to isolate whether registration is the issue.
Problem: a computed value stays stale after direct instance mutation -> Stop assigning plain properties or bypassing the signal API. Trigger a click, update the parent binding, or call a public method that uses set() or update(). Cypress retrying cannot repair a transition Angular never observed.
Problem: the effect assertion passes locally but flakes in CI -> Assert its user-visible consequence with Cypress retryability and remove fixed sleeps. Ensure the effect reads the dependency inside its callback. If it performs an async external operation, control that boundary with a stub and assert the completed outcome rather than an intermediate timestamp.
Interview Questions and Answers
The model answers in the interviewQnA section below cover the choices an interviewer usually probes: harness selection, signal input binding, computed assertions, effects, outputs, and parent-driven updates. A strong practical explanation connects each primitive to the component boundary you observe, rather than reciting signal definitions.
Best Practices
- Start with accessible user actions. They exercise Angular bindings and also expose usability regressions.
- Provide required inputs in every mount. Treat a missing value as an invalid fixture, not something the component should quietly tolerate.
- Assert computed state through its rendered business consequence. This catches dependency and template mistakes together.
- Use a host component for changing parent bindings after mount. Keep ordinary initial-value cases on
componentProperties. - Keep effects for external synchronization. Prefer computed signals when one value is derived from another.
- Give each test one behavioral reason to fail. A mount helper may remove repetition, but hidden defaults can make edge cases misleading.
- Never add
cy.wait(number)for signal propagation. Cypress commands and assertions already retry around Angular-rendered state. - Keep selectors intentional. Roles describe interactions;
data-cyidentifies stable, otherwise ambiguous display values.
Where To Go Next
You now have a working signal component suite that covers inputs, local state, computed values, an effect, an output, parent updates, and zoneless execution. Extend it with keyboard behavior, localized currency, and error boundaries that match your production component, but keep each case centered on one contract.
Next, compare this suite with the complete Cypress component testing example. Standardize shared configuration with the build a Cypress framework tutorial, and sharpen locator rules with the data-cy selector examples. For career practice, load a resume in the QAJobFit dashboard or work through hands-on scenarios in the practice area.
Interview Questions and Answers
How would you test an Angular component that uses input signals in Cypress?
I mount the component and supply plain values through componentProperties, including every required input. I assert the initial rendered contract, then use a host component only if I need to prove a later parent binding update. I do not call set() on an InputSignal because the child receives it as read-only state.
What is the difference between testing a writable signal and a computed signal?
For writable state, I trigger the public action that calls set() or update() and verify visible state. For a computed signal, I change one dependency and assert the derived DOM result. The second test should not merely repeat the formula because that misses template and dependency wiring defects.
When would you use the Cypress Angular zoneless harness?
I use cypress/angular-zoneless for an Angular 21 zoneless project on Cypress 15.8 or newer. It matches the application's change-detection model without adding Zone.js only for tests. For a Zone.js application, I retain cypress/angular and its required build dependencies.
How do Cypress retries interact with Angular signal updates?
After the action, Angular invalidates dependent consumers and renders during change detection. Cypress assertions retry against the DOM until the expected result appears. A retry does not fix an update that bypassed signal APIs, so the test must still trigger a legitimate application transition.
How do you verify an Angular output created with output()?
I pass cy.spy() to the output property in componentProperties and alias it. After the user action, I assert both the expected payload and exact call count. This tests the parent-facing contract without stubbing the emitter internals.
What makes an effect test valuable rather than implementation-coupled?
The test should observe the external synchronization or accessible consequence produced by the effect. It changes the tracked dependency through a supported action and lets Cypress retry the outcome. I avoid manually invoking effects or asserting their scheduling details, and I prefer computed signals if the behavior is only derived state.
Why might you create a host component in a Cypress component test?
A host is useful when the contract specifically involves a parent changing a bound input after initial render. Its own signal and template binding exercise Angular's real parent-to-child update path. I avoid hosts for static input values because componentProperties is smaller and easier to diagnose.
Frequently Asked Questions
Can Cypress test Angular signals directly?
Yes. Cypress's Angular mount harness understands Angular signal inputs and mounts the component in a real browser. Prefer driving controls and asserting rendered consequences, with mounted instance access reserved for intentional public component APIs.
Should I import cypress/angular or cypress/angular-zoneless?
Use cypress/angular-zoneless with an Angular 21 zoneless application and Cypress 15.8 or newer. Use cypress/angular when the project uses Zone.js; that harness still requires zone.js and @angular-devkit/build-angular.
How do I pass an Angular signal input in cy.mount?
Set a plain value under componentProperties using the same property name as the input. Cypress binds that value to the component InputSignal, including required signal inputs.
How should I test a computed signal with Cypress?
Change one dependency through a user action or parent binding, then assert the derived value in the DOM. This validates invalidation, calculation, template binding, and formatting without duplicating the implementation in the test.
Do Angular signal tests need cy.wait?
No fixed wait is needed for normal signal updates. Trigger the action and use a Cypress assertion, which retries until Angular renders the expected state or the command times out.
How do I test an Angular output() event in Cypress?
Pass a Cypress spy to the output property through componentProperties, perform the user action, and assert the spy's call count and payload. This models a parent listener without building a host component.
Is @cypress/angular-signals still required?
No. Signal support was merged into the main Cypress Angular harness, and the separate angular-signals harness was deprecated. Current projects should use cypress/angular or, for supported zoneless setups, cypress/angular-zoneless.
Related Guides
- Cypress Component Test Vue Composables Tutorial (2026)
- Appium 3 Test Android Foldable Devices Tutorial (2026)
- Appium 3 Test iOS Live Activities Tutorial (2026)
- Create Cypress Query Commands with TypeScript: Cypress Query Commands TypeScript Tutorial
- Cypress Modern Test Architecture Complete Guide (2026)
- Cypress Tutorial for Beginners (2026)