Angular Standalone Components Testing: No NgModule Required
Angular standalone components (stable since Angular 15, default since Angular 17) remove NgModule from the component authoring model. Testing standalone components is simpler: you import the component directly into TestBed.configureTestingModule({ imports: [] }) instead of declaring it in a module. Dependencies go in the same imports array.
Key Takeaways
Import standalone components directly. Use imports: [MyComponent] in TestBed.configureTestingModule, not declarations. Declarations are for NgModule-based components only.
Transitive dependencies go in imports too. If your standalone component imports CommonModule or RouterModule, those are bundled with the component — you don't need to re-import them in TestBed.
Stub child components. Use NO_ERRORS_SCHEMA to ignore unknown elements, or provide stub components in imports to avoid pulling in the full dependency tree.
TestBed.overrideComponent() still works. You can swap providers or template sections in standalone components the same way as NgModule-based ones.
Standalone pipes and directives work the same way. Import them directly in the component that uses them, and they're automatically available in tests.
The Shift to Standalone
Before Angular 15, every component had to belong to an NgModule. Testing required declaring the component in a test module and importing every dependency module — even for simple tests.
// OLD: NgModule-based test
TestBed.configureTestingModule({
declarations: [MyComponent],
imports: [CommonModule, HttpClientModule, RouterModule.forRoot([])]
});Standalone components flip this. The component declares its own imports at the component level, so tests just import the component:
// NEW: Standalone test
TestBed.configureTestingModule({
imports: [MyComponent]
});Writing a Testable Standalone Component
// user-card.component.ts
import { Component, input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-user-card',
standalone: true,
imports: [CommonModule, RouterLink],
template: `
<div class="card">
<h2>{{ user().name }}</h2>
<p>{{ user().email }}</p>
<a [routerLink]="['/users', user().id]">View Profile</a>
@if (user().isAdmin) {
<span class="badge">Admin</span>
}
</div>
`
})
export class UserCardComponent {
user = input.required<{ id: number; name: string; email: string; isAdmin: boolean }>();
}Basic Standalone Component Test
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { UserCardComponent } from './user-card.component';
describe('UserCardComponent', () => {
let fixture: ComponentFixture<UserCardComponent>;
const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com', isAdmin: false };
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UserCardComponent],
providers: [provideRouter([])] // RouterLink needs a router
}).compileComponents();
fixture = TestBed.createComponent(UserCardComponent);
fixture.componentRef.setInput('user', mockUser);
fixture.detectChanges();
});
it('renders user name and email', () => {
expect(fixture.nativeElement.querySelector('h2').textContent).toBe('Alice');
expect(fixture.nativeElement.querySelector('p').textContent).toBe('alice@example.com');
});
it('hides admin badge for regular users', () => {
expect(fixture.nativeElement.querySelector('.badge')).toBeNull();
});
it('shows admin badge for admin users', () => {
fixture.componentRef.setInput('user', { ...mockUser, isAdmin: true });
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.badge')).toBeTruthy();
expect(fixture.nativeElement.querySelector('.badge').textContent.trim()).toBe('Admin');
});
it('renders a link to the user profile', () => {
const link = fixture.nativeElement.querySelector('a');
expect(link.getAttribute('href')).toBe('/users/1');
});
});Testing Standalone Components with Services
// search.component.ts
import { Component, signal } from '@angular/core';
import { SearchService } from './search.service';
@Component({
selector: 'app-search',
standalone: true,
template: `
<input (input)="onSearch($event)" placeholder="Search..." />
<ul>
@for (result of results(); track result.id) {
<li>{{ result.title }}</li>
}
</ul>
@if (loading()) {
<p>Loading...</p>
}
`
})
export class SearchComponent {
results = signal<Array<{ id: number; title: string }>>([]);
loading = signal(false);
constructor(private searchService: SearchService) {}
async onSearch(event: Event) {
const query = (event.target as HTMLInputElement).value;
if (!query) return;
this.loading.set(true);
this.results.set(await this.searchService.search(query));
this.loading.set(false);
}
}import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { SearchComponent } from './search.component';
import { SearchService } from './search.service';
describe('SearchComponent', () => {
let fixture: ComponentFixture<SearchComponent>;
let searchService: jasmine.SpyObj<SearchService>;
beforeEach(async () => {
searchService = jasmine.createSpyObj('SearchService', ['search']);
await TestBed.configureTestingModule({
imports: [SearchComponent],
providers: [
{ provide: SearchService, useValue: searchService }
]
}).compileComponents();
fixture = TestBed.createComponent(SearchComponent);
fixture.detectChanges();
});
it('shows results after search', fakeAsync(async () => {
const mockResults = [{ id: 1, title: 'Angular Guide' }];
searchService.search.and.returnValue(Promise.resolve(mockResults));
const input = fixture.nativeElement.querySelector('input');
input.value = 'angular';
input.dispatchEvent(new Event('input'));
await fixture.whenStable();
fixture.detectChanges();
const items = fixture.nativeElement.querySelectorAll('li');
expect(items.length).toBe(1);
expect(items[0].textContent).toBe('Angular Guide');
}));
it('shows loading state while searching', fakeAsync(async () => {
let resolveSearch!: (v: any) => void;
const pending = new Promise(r => { resolveSearch = r; });
searchService.search.and.returnValue(pending);
const input = fixture.nativeElement.querySelector('input');
input.value = 'test';
input.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('p').textContent).toBe('Loading...');
resolveSearch([]);
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('p')).toBeNull();
}));
});Stubbing Child Standalone Components
When your component renders child standalone components you don't want to test in depth:
// dashboard.component.ts
import { Component } from '@angular/core';
import { UserCardComponent } from './user-card.component';
import { StatsWidgetComponent } from './stats-widget.component';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [UserCardComponent, StatsWidgetComponent],
template: `
<app-user-card [user]="currentUser" />
<app-stats-widget />
`
})
export class DashboardComponent {
currentUser = { id: 1, name: 'Alice', email: 'alice@example.com', isAdmin: false };
}Option 1: Override with stub components
import { Component, input } from '@angular/core';
@Component({ selector: 'app-user-card', standalone: true, template: '' })
class StubUserCardComponent {
user = input<any>();
}
@Component({ selector: 'app-stats-widget', standalone: true, template: '' })
class StubStatsWidgetComponent {}
describe('DashboardComponent (stubbed children)', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DashboardComponent]
})
.overrideComponent(DashboardComponent, {
remove: { imports: [UserCardComponent, StatsWidgetComponent] },
add: { imports: [StubUserCardComponent, StubStatsWidgetComponent] }
})
.compileComponents();
});
it('renders without errors', () => {
const fixture = TestBed.createComponent(DashboardComponent);
expect(() => fixture.detectChanges()).not.toThrow();
});
});Option 2: NO_ERRORS_SCHEMA (fast, less accurate)
import { NO_ERRORS_SCHEMA } from '@angular/core';
TestBed.configureTestingModule({
imports: [DashboardComponent],
schemas: [NO_ERRORS_SCHEMA] // ignores unknown elements
});Use NO_ERRORS_SCHEMA for shallow rendering where you only care about the parent component's logic.
Testing Standalone Directives
// highlight.directive.ts
import { Directive, ElementRef, HostListener, input } from '@angular/core';
@Directive({
selector: '[appHighlight]',
standalone: true
})
export class HighlightDirective {
color = input('yellow');
constructor(private el: ElementRef) {}
@HostListener('mouseenter')
onEnter() {
this.el.nativeElement.style.backgroundColor = this.color();
}
@HostListener('mouseleave')
onLeave() {
this.el.nativeElement.style.backgroundColor = '';
}
}import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HighlightDirective } from './highlight.directive';
@Component({
standalone: true,
imports: [HighlightDirective],
template: `<p appHighlight [color]="'red'">Hover me</p>`
})
class TestHostComponent {}
describe('HighlightDirective', () => {
let fixture: ComponentFixture<TestHostComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TestHostComponent]
}).compileComponents();
fixture = TestBed.createComponent(TestHostComponent);
fixture.detectChanges();
});
it('highlights on mouseenter', () => {
const p = fixture.nativeElement.querySelector('p');
p.dispatchEvent(new MouseEvent('mouseenter'));
expect(p.style.backgroundColor).toBe('red');
});
it('removes highlight on mouseleave', () => {
const p = fixture.nativeElement.querySelector('p');
p.dispatchEvent(new MouseEvent('mouseenter'));
p.dispatchEvent(new MouseEvent('mouseleave'));
expect(p.style.backgroundColor).toBe('');
});
});Testing Standalone Pipes
// truncate.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({ name: 'truncate', standalone: true })
export class TruncatePipe implements PipeTransform {
transform(value: string, limit = 50): string {
return value.length > limit ? value.slice(0, limit) + '…' : value;
}
}import { TruncatePipe } from './truncate.pipe';
describe('TruncatePipe', () => {
let pipe: TruncatePipe;
beforeEach(() => {
pipe = new TruncatePipe(); // standalone pipes can be instantiated directly
});
it('returns the original string when under limit', () => {
expect(pipe.transform('Hello', 50)).toBe('Hello');
});
it('truncates and appends ellipsis when over limit', () => {
expect(pipe.transform('Hello World', 5)).toBe('Hello…');
});
it('uses default limit of 50', () => {
const long = 'a'.repeat(60);
expect(pipe.transform(long)).toBe('a'.repeat(50) + '…');
});
});Standalone pipes with no constructor dependencies can be new-ed directly — no TestBed needed.Migrating NgModule-Based Tests
When converting a declarations-based test to standalone:
| Before (NgModule) | After (Standalone) |
|---|---|
declarations: [MyComponent] |
imports: [MyComponent] |
imports: [HttpClientModule] |
providers: [provideHttpClient()] |
imports: [RouterModule.forRoot([])] |
providers: [provideRouter([])] |
imports: [ReactiveFormsModule] |
Already in component's imports |
Integration with HelpMeTest
Standalone components are self-contained — their dependencies are declared at the component level, not in a module. This makes them ideal candidates for HelpMeTest's browser-based testing, which doesn't need to know about NgModules at all.
Write your test scenarios describing user behavior:
Given the user card shows Alice's profile
When the user is flagged as admin
Then the admin badge is visible
And clicking "View Profile" navigates to /users/1HelpMeTest runs these against your deployed app, catching routing, rendering, and state bugs that unit tests miss.
Summary
Standalone components simplify Angular testing:
- Use
imports: [MyComponent](notdeclarations) - Service dependencies go in
providers - Stub child components with
overrideComponent()orNO_ERRORS_SCHEMA - Signal inputs use
fixture.componentRef.setInput() - Standalone pipes can be instantiated directly without TestBed
compileComponents()is still required for external templates