Angular Component Tests: setInput, outputs, whenStable in Zoneless Apps, @defer and Host Components
In Angular 22, old tests that assign through componentInstance and call detectChanges fail, because default OnPush and the zoneless fixture only process changes Angular is notified about; pass values with setInput and read the DOM after whenStable. With Bookstore's BookCard, QuantityInput, CartSummary and CatalogPage components we cover outputs, model(), content projection, @defer and getLastFixture, all with real runs.
Version note: The code in this post was built and run with Angular 22.2 (TypeScript 6.0), Vitest 5.1 and jsdom 30 on Node.js 24.
Guess First
Think of a component and its test left over from the Angular 12 days. The old UI of Bookstore, the online bookstore used as the shared example across this site's series, had a small badge showing the number of items in the cart:
@Component({
selector: 'app-cart-badge',
template: `<span>Cart ({{ count }})</span>`,
})
export class CartBadge {
@Input() count = 0;
}
Its test follows the most common pattern of that era: first render, then a value through componentInstance, then detectChanges again:
it('shows the number of items', () => {
const fixture = TestBed.createComponent(CartBadge);
fixture.detectChanges();
fixture.componentInstance.count = 2;
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toBe('Cart (2)');
});
You copied this code into a project created with ng new on Angular 22.2. Does the test pass? If it fails, what is the error message? Bonus question: does the result change if you add changeDetection: ChangeDetectionStrategy.Eager to the component?
Answer: In Zoneless Apps detectChanges Does Not Refresh a View That Was Never Notified
The test failed:
FAIL bookstore-web src/app/legacy/cart-badge.spec.ts > CartBadge > shows the number of items
AssertionError: expected 'Cart (0)' to be 'Cart (2)' // Object.is equality
The copy with Eager failed too, with a different error:
Error: NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: '0'. Current value: '2'. Expression location: _CartBadge component.
Two causes stack up. First, OnPush is the default in Angular 22: a component with no changeDetection set is refreshed only when an input changes through Angular, when an event fires inside it, or when a signal it reads changes. componentInstance.count = 2 is none of these; it is a plain property assignment Angular knows nothing about. Second, the app is zoneless. The source of ComponentFixture.detectChanges() shows that in the zoneless branch it only calls ApplicationRef.tick(). That tick refreshes views that have notified Angular of a change. The Eager component was not refreshed either because it never notified; but in development mode the extra check that runs after the tick read every binding again, found the difference and threw NG0100. In Angular 12, Zone.js checked the whole tree on every detectChanges, which is why this test used to pass.
The fix is to write the test so that it notifies Angular. Passing the input with componentRef.setInput() marks the OnPush component as a view that needs refreshing:
it('shows the number of items', async () => {
const fixture = TestBed.createComponent(CartBadge);
await fixture.whenStable();
fixture.componentRef.setInput('count', 2);
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toBe('Cart (2)');
});
This version passed, and so did the variant where I used detectChanges instead of whenStable. The difference is in the test: it now gives the component its value through an input, the way a parent template would. In this post we test Bookstore's components with that approach: inputs with setInput, events by subscribing to the output(), model() and content projection with a host component, and @defer with getDeferBlocks().
How Does a Zoneless Fixture Work?
A project created with ng new in Angular 22 has no Zone.js; the app is zoneless. In tests this means the fixture behaves like the app. In the source of ComponentFixture, autoDetect defaults to true when zoneless: when a signal changes or an event in the template runs, Angular schedules change detection itself. A small experiment showed that the scheduling is asynchronous. In a component whose template shows a count() signal, I clicked a button and read the DOM three times:
["Cart (0)add","Cart (0)add","Cart (1)add"]
The first value is before the click, the second right after click(), the third after await fixture.whenStable(). The click changed the signal immediately, but the DOM was updated on the next change detection. Hence the rule: await fixture.whenStable() before reading the DOM. whenStable() waits for Angular to finish the work it scheduled and the pending tasks, such as a loading resource. fixture.detectChanges() still exists and is synchronous; it works after a signal change or setInput. But it does not wait for all the scheduled work, and as shown above it does not catch a change that never notified Angular. Bookstore's tests use whenStable by default.
Inputs and Outputs: BookCard
Every book in the catalog is shown with a BookCard. Its input is the signal-based input.required(), its output is output():
@Component({
selector: 'app-book-card',
imports: [PricePipe, RatingSummary],
template: `
<article>
<h3>{{ book().title }}</h3>
<p class="author">{{ book().author }}</p>
<p class="price">{{ book().price | price }}</p>
@if (book().stock > 0) {
<button type="button" (click)="addToCart.emit(book())">Add to cart</button>
} @else {
<p class="stock">Out of stock</p>
}
@defer (on viewport) {
<app-rating-summary [isbn]="book().isbn" />
} @placeholder {
<p class="rating-placeholder">Ratings</p>
}
</article>
`,
})
export class BookCard {
readonly book = input.required<Book>();
readonly addToCart = output<Book>();
}
The shared setup of the tests lives in beforeEach. Instead of carrying the fixture in a variable, we use TestBed.getLastFixture(), new in Angular 22; it returns the most recently created fixture:
describe('BookCard', () => {
beforeEach(async () => {
TestBed.configureTestingModule({
providers: [provideHttpClientTesting()],
deferBlockBehavior: DeferBlockBehavior.Manual,
});
const fixture = TestBed.createComponent(BookCard);
fixture.componentRef.setInput('book', dune);
await fixture.whenStable();
});
function element(): HTMLElement {
return TestBed.getLastFixture<BookCard>().nativeElement;
}
it('shows the title, the author and the price', () => {
expect(element().querySelector('h3')?.textContent).toBe('Dune');
expect(element().querySelector('.author')?.textContent).toBe('Frank Herbert');
expect(element().querySelector('.price')?.textContent).toBe('₺450,00');
});
it('emits the book when "Add to cart" is clicked', () => {
const fixture: ComponentFixture<BookCard> = TestBed.getLastFixture();
const added = vi.fn();
fixture.componentInstance.addToCart.subscribe(added);
element().querySelector('button')!.click();
expect(added).toHaveBeenCalledExactlyOnceWith(dune);
});
it('replaces the button with "Out of stock" when there is no stock', async () => {
const fixture = TestBed.getLastFixture<BookCard>();
fixture.componentRef.setInput('book', emma);
await fixture.whenStable();
expect(element().querySelector('button')).toBeNull();
expect(element().querySelector('.stock')?.textContent).toBe('Out of stock');
});
});
dune and emma are test data from src/testing/books.ts; the CatalogService test from post 2 now takes them from there too. The price showing as "₺450,00" shows that the locale we provided with providersFile in post 1 and the PricePipe from post 2 work together in the template.
output() returns an OutputEmitterRef that has a subscribe method; to listen to it in a test, subscribing a vi.fn() is enough. toHaveBeenCalledExactlyOnceWith checks both that it was called once and the argument. We trigger the event here by clicking the button rather than calling componentInstance.addToCart.emit(...). The latter only tests that emit works, not that the (click) binding in the template is right.
There is no whenStable after the click in these tests, because the assertion looks at the output, not the DOM; the event handler runs synchronously. If you expect a change in the DOM, an await fixture.whenStable() has to come in between.
model() and Two-Way Binding: Testing with a Host Component
QuantityInput, which changes the quantity in the cart, uses model(); the parent binds it with [(quantity)]:
@Component({
selector: 'app-quantity-input',
template: `
<button type="button" aria-label="Decrease quantity" [disabled]="quantity() <= 1" (click)="quantity.set(quantity() - 1)">
−
</button>
<span class="quantity">{{ quantity() }}</span>
<button type="button" aria-label="Increase quantity" [disabled]="quantity() >= max()" (click)="quantity.set(quantity() + 1)">
+
</button>
`,
})
export class QuantityInput {
readonly quantity = model(1);
readonly max = input(99);
}
The contract of model() goes both ways: the value from the parent flows down to the component, and a change in the component flows back up to the parent. You can test it alone with setInput, but the clearest way to see both directions is to write a small host component inside the test:
@Component({
imports: [QuantityInput],
template: `<app-quantity-input [(quantity)]="quantity" [max]="3" />`,
})
class QuantityHost {
readonly quantity = signal(2);
}
describe('QuantityInput', () => {
function button(label: string): HTMLButtonElement {
return TestBed.getLastFixture().nativeElement.querySelector(`button[aria-label="${label}"]`);
}
it('writes the new quantity back to the parent', async () => {
const fixture = TestBed.createComponent(QuantityHost);
await fixture.whenStable();
button('Increase quantity').click();
await fixture.whenStable();
expect(fixture.componentInstance.quantity()).toBe(3);
expect(fixture.nativeElement.querySelector('.quantity').textContent).toBe('3');
});
it('shows a quantity changed by the parent', async () => {
const fixture = TestBed.createComponent(QuantityHost);
await fixture.whenStable();
fixture.componentInstance.quantity.set(1);
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('.quantity').textContent).toBe('1');
expect(button('Decrease quantity').disabled).toBe(true);
});
});
Touching the host's componentInstance is fine here: the host is part of the test, it plays the parent's role, and because its state is a signal Angular is notified of the change. Writing state into the componentInstance of the component under test, on the other hand, leads back to the bug at the start of this post.
Content Projection: CartSummary
CartSummary shows the cart lines and the total; it does not add the "Checkout" button itself, the page using it projects an element with the cartActions attribute into it. The relevant part of the template:
@if (cart.lines().length === 0) {
<p class="empty">Your cart is empty.</p>
} @else {
<ul>
@for (line of cart.lines(); track line.book.isbn) {
<li>
<span class="title">{{ line.book.title }}</span>
<app-quantity-input
[quantity]="line.quantity"
(quantityChange)="cart.setQuantity(line.book.isbn, $event)"
[max]="line.book.stock"
/>
<span class="line-total">{{ line.book.price * line.quantity | price }}</span>
</li>
}
</ul>
<p class="total">Total: {{ cart.total() | price }}</p>
<ng-content select="[cartActions]" />
}
The only way to test content projection is a parent that provides the content to project; again, a host component:
@Component({
imports: [CartSummary],
template: `
<app-cart-summary>
<button cartActions type="button">Checkout</button>
</app-cart-summary>
`,
})
class CartSummaryHost {}
describe('CartSummary', () => {
function text(selector: string): string | undefined {
return TestBed.getLastFixture().nativeElement.querySelector(selector)?.textContent;
}
it('says the cart is empty and hides the projected actions', async () => {
const fixture = TestBed.createComponent(CartSummaryHost);
await fixture.whenStable();
expect(text('.empty')).toBe('Your cart is empty.');
expect(text('button')).toBeUndefined();
});
it('lists the lines with the total and shows the projected actions', async () => {
const cart = TestBed.inject(CartStore);
cart.add(dune);
cart.add(dune);
cart.add(hobbit);
const fixture = TestBed.createComponent(CartSummaryHost);
await fixture.whenStable();
const titles = [...fixture.nativeElement.querySelectorAll('.title')].map((title) => title.textContent);
expect(titles).toEqual(['Dune', 'The Hobbit']);
expect(text('.total')).toBe('Total: ₺1.280,00');
expect(text('button[cartActions]')).toBe('Checkout');
});
it('updates the total when a quantity changes', async () => {
TestBed.inject(CartStore).add(dune);
const fixture = TestBed.createComponent(CartSummaryHost);
await fixture.whenStable();
fixture.nativeElement.querySelector('button[aria-label="Increase quantity"]').click();
await fixture.whenStable();
expect(text('.line-total')).toBe('₺900,00');
expect(text('.total')).toBe('Total: ₺900,00');
});
});
The first test checks that the projection is conditional: because ng-content sits in the @else branch, the "Checkout" button never enters the DOM when the cart is empty. The last test works with the real child component instead of faking QuantityInput: clicking "+" triggers quantityChange, CartStore is updated and the total changes. We will discuss the cost of faking a child or not in post 5; here the child is small and has no dependencies, so using the real one is both simpler and tests more.
Getting CartStore with TestBed.inject and filling it means using the component's dependency with the real service. The service has its own tests in a separate file; here we only check that the component displays it correctly.
Testing @defer Blocks
The rating summary in BookCard is deferred with @defer (on viewport): RatingSummary is loaded when the card enters the viewport and sends its own HTTP request. We saw in post 1 that jsdom has no IntersectionObserver, so in a test the card never "enters the viewport". In the first attempt the test did not fail and the block stayed on its placeholder: the card's text was "DuneFrank Herbert₺450,00Add to cartRatings". But when I ran the suite with --reporters verbose, an error written to the console by Angular's ErrorHandler showed up under every test that rendered the card:
stderr | src/app/catalog/book-card.spec.ts > BookCard > shows the title, the author and the price
ERROR ReferenceError: IntersectionObserver is not defined
at createIntersectionObserver (.../@angular/core/fesm2022/_debug_node-chunk.mjs:2180:3)
at onViewport (.../@angular/core/fesm2022/_debug_node-chunk.mjs:2193:17)
Because the default reporter does not show the console output of passing tests, these lines stay hidden in a normal run. The cause is TestBed's @defer mode. DeferBlockBehavior.Playthrough runs blocks according to their triggers as a browser would, and according to its type definition it is the default in test environments; setting up the on viewport trigger looks for IntersectionObserver and does not find it. Manual sets up no triggers and keeps blocks waiting until you render them. That is why the BookCard and CatalogPage tests use deferBlockBehavior: DeferBlockBehavior.Manual; the error lines went away. You reach the blocks with fixture.getDeferBlocks() and render the state you want:
it('keeps the ratings deferred until the card is rendered completely', async () => {
const fixture = TestBed.getLastFixture<BookCard>();
expect(element().querySelector('.rating-placeholder')?.textContent).toBe('Ratings');
const [ratings] = await fixture.getDeferBlocks();
await ratings.render(DeferBlockState.Complete);
TestBed.tick();
TestBed.inject(HttpTestingController)
.expectOne('/api/books/9780441013593/rating')
.flush({ average: 4.5, count: 120 });
await fixture.whenStable();
expect(element().querySelector('.rating')?.textContent).toBe('4.5 / 5 (120 ratings)');
});
The test checks both the placeholder and the complete state. DeferBlockState also has Placeholder, Loading and Error values; if you wrote a @loading or @error block, you can render and inspect those the same way. The behavior "load when the card enters the viewport" itself, the trigger, cannot be tested in jsdom. We will test it in a real browser with Vitest browser mode in post 7.
At Page Level: CatalogPage and the Error State
In post 2 we saw that httpResource throws when value() is read in the error state. That is why the catalog page checks status() first in its template:
@switch (catalog.books.status()) {
@case ('error') {
<p role="alert">Could not load the catalog.</p>
<button type="button" (click)="catalog.books.reload()">Try again</button>
}
@case ('loading') {
<p>Loading books…</p>
}
@default {
<ul aria-label="Books">
@for (book of catalog.inStock(); track book.isbn) {
<li><app-book-card [book]="book" (addToCart)="cart.add($event)" /></li>
} @empty {
<li>No books found.</li>
}
</ul>
}
}
When I first wrote this page's test, out of habit, I put await fixture.whenStable() right after creating the component. All three tests failed with this error:
× shows the books that are in stock 5015ms
Error: Test timed out in 5000ms.
whenStable() waits until the pending tasks are done, and a resource that is loading is a pending task. The code that would answer the request sits behind the await and never gets to run. The order has to change: first TestBed.tick() for the first render and the request, then flush, and whenStable last:
it('shows an error and loads again when "Try again" is clicked', async () => {
const fixture = TestBed.createComponent(CatalogPage);
TestBed.tick();
http.expectOne('/api/books').flush(null, { status: 503, statusText: 'Service Unavailable' });
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toBe('Could not load the catalog.');
fixture.nativeElement.querySelector('button').click();
TestBed.tick();
http.expectOne('/api/books').flush([dune]);
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('[role="alert"]')).toBeNull();
expect(fixture.nativeElement.querySelector('h3').textContent).toBe('Dune');
});
The page test renders the real BookCard; in a second test we click the "Add to cart" button on the card and check that CartStore.count() is 1. In the test map from post 1, this test sits between component and routed feature: the page, its child components and the services are real; only HTTP is faked.
In Production
At the end of this post the suite has 10 files and 39 tests; three runs took 778, 828 and 985 ms. Each component test takes between 4 and 40 ms. The slowest are the first test of each file; the one-time warm-up of the first render in a file lands on the first test. Duration is not a problem yet, but a component test that starts taking seconds usually means whenStable is waiting because of a real timer or an unanswered request.
Choose selectors carefully. The tests in this post query the DOM with classes such as .price and .total. When classes change for styling, the tests fail; likewise, looking up button by tag name finds the wrong button once a second one is added to the card. In the next post we find elements by the role and name the user sees with Testing Library, and most of these problems go away.
When moving old tests to Angular 22, do not treat the ChangeDetectionStrategy.Eager that ng update adds to components as a permanent fix. In the experiment above, Eager did not save a test that assigns through componentInstance on a zoneless fixture. Tests need to be rewritten with setInput, signals and whenStable; the details of migrating from Karma are in post 9.
Common Pitfalls
Writing state through componentInstance and bypassing the template. fixture.componentInstance.count = 2 does not work with zoneless and does not test how a parent actually uses the component either. Use setInput for inputs and a host component for two-way binding.
Testing private or protected fields. CartSummary's cart field is protected; in TypeScript you can still reach it with fixture.componentInstance['cart']. But then the test depends on the component having a CartStore field. Reading the total the user sees from the DOM tests the same behavior without tying itself to the implementation.
Reading the DOM without whenStable. After a click or setInput, the DOM stays as it was until the next change detection. If the assertion right after looks at the DOM, there has to be an await fixture.whenStable() in between.
Waiting on whenStable while a request is unanswered. The result is a five-second timeout, and the error message mentions the duration, not the request. First TestBed.tick() and flush, then whenStable.
Counting the component inside a @defer as tested without ever seeing it. In jsdom an on viewport block stays on its placeholder without failing the test, and the error it writes to the console does not show up with the default reporter. Use Manual mode and render the deferred content with getDeferBlocks(); otherwise that content runs in no test at all.
A Small Experiment
★☆☆ Rewrite CartBadge with signals: count = input(0). Rewrite the test from the start of this post with setInput and whenStable, and see both the detectChanges and the whenStable variants pass.
★★☆ Add a "only 2 left in stock" warning to BookCard (stock <= 2). Write two tests, one where the warning is shown and one where it is not; change the book with setInput and check that the warning disappears in the same fixture.
★★★ Add a @loading block to BookCard's defer block and write a test that renders the placeholder, loading and complete states in turn. Switch deferBlockBehavior to Playthrough and run the test with --reporters verbose; what changes in the console?
The tests in this post queried the DOM by class and tag names. In the next post we look at the same components with Angular Testing Library: render(), role and label queries, realistic typing and clicking with user-event, and the error messages of the checkout form built with Signal Forms.