Angular Deferrable Views Testing: @defer Block Strategies
Angular 17 introduced deferrable views — the @defer block that lazily loads components based on triggers like viewport intersection, idle time, user interaction, or explicit programmatic control. Testing deferrable views requires Angular's DeferBlockBehavior and getDeferBlocks() API to control when and how deferred blocks load.
Key Takeaways
Use DeferBlockBehavior.Manual in tests. The default behavior (Playthrough) fires triggers immediately, which can make tests flaky. Manual gives you explicit control over when each defer block loads.
TestBed.getDeferBlocks() returns all defer blocks in the fixture. Each block can be rendered in a specific state: DeferBlockState.Placeholder, DeferBlockState.Loading, DeferBlockState.Complete, or DeferBlockState.Error.
Order matters. getDeferBlocks() returns blocks in document order (top to bottom, outermost first). Keep track of which index is which block.
@loading and @placeholder blocks are independent. You can test them separately from the @complete content — no need to wait for the full load cycle.
Nested defer blocks load independently. A parent block completing doesn't automatically load nested blocks in Manual mode.
Anatomy of a Deferrable View
@defer (on viewport; prefetch on idle) {
<app-heavy-chart [data]="chartData" />
} @loading (minimum 300ms) {
<app-skeleton />
} @placeholder (minimum 500ms) {
<div class="placeholder">Chart area</div>
} @error {
<p>Failed to load chart.</p>
}@defer— the main content, lazily loaded@placeholder— shown before the defer trigger fires@loading— shown while the lazy chunk is downloading@error— shown if the lazy load throws
Testing Setup
# No extra packages needed — DeferBlockBehavior is in @angular/core/testingimport {
TestBed,
ComponentFixture,
DeferBlockBehavior,
DeferBlockState
} from '@angular/core/testing';Manual Defer Block Control
// dashboard.component.ts
import { Component } from '@angular/core';
import { HeavyChartComponent } from './heavy-chart.component';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [HeavyChartComponent],
template: `
<h1>Dashboard</h1>
@defer (on viewport) {
<app-heavy-chart />
} @placeholder {
<div data-testid="placeholder">Scroll to load chart</div>
} @loading {
<div data-testid="loading">Loading chart...</div>
} @error {
<div data-testid="error">Chart failed to load</div>
}
`
})
export class DashboardComponent {}import { TestBed, ComponentFixture, DeferBlockBehavior, DeferBlockState } from '@angular/core/testing';
import { DashboardComponent } from './dashboard.component';
describe('DashboardComponent defer blocks', () => {
let fixture: ComponentFixture<DashboardComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DashboardComponent],
deferBlockBehavior: DeferBlockBehavior.Manual // take control
}).compileComponents();
fixture = TestBed.createComponent(DashboardComponent);
fixture.detectChanges();
});
it('shows placeholder before defer triggers', async () => {
expect(fixture.nativeElement.querySelector('[data-testid="placeholder"]')).toBeTruthy();
expect(fixture.nativeElement.querySelector('app-heavy-chart')).toBeNull();
});
it('shows loading state during fetch', async () => {
const deferBlocks = await fixture.getDeferBlocks();
await deferBlocks[0].render(DeferBlockState.Loading);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[data-testid="loading"]')).toBeTruthy();
expect(fixture.nativeElement.querySelector('[data-testid="placeholder"]')).toBeNull();
});
it('renders the chart after defer completes', async () => {
const deferBlocks = await fixture.getDeferBlocks();
await deferBlocks[0].render(DeferBlockState.Complete);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-heavy-chart')).toBeTruthy();
expect(fixture.nativeElement.querySelector('[data-testid="placeholder"]')).toBeNull();
});
it('shows error state when defer fails', async () => {
const deferBlocks = await fixture.getDeferBlocks();
await deferBlocks[0].render(DeferBlockState.Error);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[data-testid="error"]')).toBeTruthy();
});
});Testing Multiple Defer Blocks
// page.component.ts
@Component({
selector: 'app-page',
standalone: true,
template: `
@defer (on idle) {
<app-comments />
} @placeholder {
<div data-testid="comments-placeholder">Comments loading...</div>
}
@defer (on interaction) {
<app-related-posts />
} @placeholder {
<div data-testid="related-placeholder">Related posts</div>
}
`
})
export class PageComponent {}describe('PageComponent multiple defer blocks', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PageComponent],
deferBlockBehavior: DeferBlockBehavior.Manual
}).compileComponents();
});
it('controls each block independently', async () => {
const fixture = TestBed.createComponent(PageComponent);
fixture.detectChanges();
const deferBlocks = await fixture.getDeferBlocks();
expect(deferBlocks.length).toBe(2); // index 0 = comments, 1 = related posts
// Only render the first block (comments)
await deferBlocks[0].render(DeferBlockState.Complete);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-comments')).toBeTruthy();
expect(fixture.nativeElement.querySelector('app-related-posts')).toBeNull();
expect(fixture.nativeElement.querySelector('[data-testid="related-placeholder"]')).toBeTruthy();
// Now render the second block
await deferBlocks[1].render(DeferBlockState.Complete);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-related-posts')).toBeTruthy();
});
});Testing Nested Defer Blocks
Nested @defer blocks are independent. The parent completing doesn't automatically trigger children.
// nested.component.ts
@Component({
selector: 'app-nested',
standalone: true,
template: `
@defer (on viewport) {
<div data-testid="outer">
Outer loaded
@defer (on idle) {
<div data-testid="inner">Inner loaded</div>
} @placeholder {
<div data-testid="inner-placeholder">Inner pending</div>
}
</div>
} @placeholder {
<div data-testid="outer-placeholder">Outer pending</div>
}
`
})
export class NestedComponent {}describe('NestedComponent nested defer blocks', () => {
it('loads inner block independently from outer', async () => {
await TestBed.configureTestingModule({
imports: [NestedComponent],
deferBlockBehavior: DeferBlockBehavior.Manual
}).compileComponents();
const fixture = TestBed.createComponent(NestedComponent);
fixture.detectChanges();
const deferBlocks = await fixture.getDeferBlocks();
// deferBlocks[0] = outer, deferBlocks[1] = inner (document order)
// Load outer
await deferBlocks[0].render(DeferBlockState.Complete);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[data-testid="outer"]')).toBeTruthy();
expect(fixture.nativeElement.querySelector('[data-testid="inner-placeholder"]')).toBeTruthy();
expect(fixture.nativeElement.querySelector('[data-testid="inner"]')).toBeNull();
// Load inner independently
await deferBlocks[1].render(DeferBlockState.Complete);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[data-testid="inner"]')).toBeTruthy();
});
});Testing Defer with @when (Condition-Based Triggers)
// feature.component.ts
@Component({
selector: 'app-feature',
standalone: true,
template: `
@defer (when isReady()) {
<app-feature-panel />
} @placeholder {
<div data-testid="not-ready">Not ready yet</div>
}
`
})
export class FeatureComponent {
isReady = signal(false);
makeReady() { this.isReady.set(true); }
}With DeferBlockBehavior.Playthrough (default), the when condition fires automatically when the signal is true:
describe('FeatureComponent @when trigger', () => {
it('loads when condition becomes true (Playthrough mode)', async () => {
await TestBed.configureTestingModule({
imports: [FeatureComponent]
// DeferBlockBehavior.Playthrough is the default
}).compileComponents();
const fixture = TestBed.createComponent(FeatureComponent);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[data-testid="not-ready"]')).toBeTruthy();
fixture.componentInstance.makeReady();
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-feature-panel')).toBeTruthy();
});
});Testing @placeholder Minimum Duration
The minimum parameter on @placeholder and @loading ensures the block shows for at least N milliseconds (to avoid flicker). Testing minimum durations requires fakeAsync:
import { fakeAsync, tick } from '@angular/core/testing';
describe('loading minimum duration', () => {
it('shows loading for at least 300ms', fakeAsync(async () => {
// In Playthrough mode, the defer resolves but @loading minimum holds it visible
await TestBed.configureTestingModule({
imports: [DashboardComponent]
}).compileComponents();
const fixture = TestBed.createComponent(DashboardComponent);
fixture.detectChanges();
// Without tick, loading block may still be showing due to minimum 300ms
tick(299);
fixture.detectChanges();
// Still in loading state
tick(1); // Cross the 300ms threshold
fixture.detectChanges();
// Now complete content is shown
}));
});Note: Minimum durations are primarily a UX concern. For most tests, DeferBlockBehavior.Manual is cleaner — you control the exact state, not the timing.Testing Defer Block Accessibility
Check that placeholder and loaded content don't break accessibility:
it('announces chart load to screen readers', async () => {
await TestBed.configureTestingModule({
imports: [DashboardComponent],
deferBlockBehavior: DeferBlockBehavior.Manual
}).compileComponents();
const fixture = TestBed.createComponent(DashboardComponent);
fixture.detectChanges();
// Placeholder should have aria-label or role
const placeholder = fixture.nativeElement.querySelector('[data-testid="placeholder"]');
expect(placeholder.getAttribute('aria-label') || placeholder.textContent).toBeTruthy();
const deferBlocks = await fixture.getDeferBlocks();
await deferBlocks[0].render(DeferBlockState.Complete);
fixture.detectChanges();
// Loaded content should be accessible
const chart = fixture.nativeElement.querySelector('app-heavy-chart');
expect(chart).toBeTruthy();
});HelpMeTest for Defer Block E2E
Unit tests verify state transitions in isolation, but deferrable views also need E2E coverage for real viewport triggers, network latency, and chunk loading.
HelpMeTest can test defer blocks in a real browser:
When the user scrolls the chart section into view
Then the loading skeleton appears
And within 2 seconds the chart renders with data
And the placeholder is no longer visibleThis catches lazy loading failures, bundle splitting bugs, and performance regressions that unit tests can't reach.
Common Mistakes
Using default DeferBlockBehavior.Playthrough for complex tests Playthrough fires triggers asynchronously and can cause race conditions. Use Manual for predictable tests.
Assuming block index order getDeferBlocks() returns blocks in document order. Add a comment in your test noting which index corresponds to which block, or use data-testid on wrapper elements to verify.
Not calling fixture.detectChanges() after render() render() changes the block state but you still need detectChanges() to flush the view update.
Testing timing with real async in CI Minimum durations and on idle triggers behave differently under test load. Always use DeferBlockBehavior.Manual or fakeAsync to control time.
Summary
Testing Angular deferrable views requires the DeferBlockBehavior.Manual configuration to control state explicitly:
- Configure
deferBlockBehavior: DeferBlockBehavior.ManualinTestBed - Get blocks with
await fixture.getDeferBlocks()(document order) - Render specific states with
await deferBlocks[n].render(DeferBlockState.Complete/Loading/Error/Placeholder) - Call
fixture.detectChanges()after each state change - Use
Playthrough+fakeAsynconly when testing actual@whenconditions - Use nested block indices carefully — parent completing doesn't load children in
Manualmode