Spectator for Angular: Reducing TestBed Boilerplate with createComponent and createService
Spectator is an Angular testing library that wraps TestBed with a simpler, less verbose API. Instead of 20 lines of configureTestingModule, compileComponents, createComponent, and detectChanges, you get a one-liner factory that handles all of it. This guide covers the full Spectator API: createComponent, createService, SpyObject mocks, and event testing.
Key Takeaways
Spectator is a wrapper, not a replacement. It uses TestBed internally. You can mix Spectator and raw TestBed calls. The Angular knowledge transfers — Spectator just removes the boilerplate.
createComponentFactory() defines the test setup once; the factory creates a fresh instance per test. Call the factory in beforeEach, not inside it blocks.
spectator.query() is fixture.debugElement.query() with a cleaner API. spectator.query('.btn') returns the native DOM element directly, not a DebugElement.
SpyObject<T> creates fully typed jasmine spy objects. Every method is a spy. Access them with spectator.inject(ServiceClass) and set return values with .and.returnValue().
spectator.detectChanges() is required after input changes. Spectator doesn't run change detection automatically. Call it explicitly after modifying component state.
Why Spectator?
A minimal Angular component test without Spectator:
describe('ButtonComponent', () => {
let component: ButtonComponent;
let fixture: ComponentFixture<ButtonComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ButtonComponent],
}).compileComponents();
fixture = TestBed.createComponent(ButtonComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('renders the label', () => {
component.label = 'Submit';
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('button').textContent.trim()).toBe('Submit');
});
});The same test with Spectator:
describe('ButtonComponent', () => {
const createComponent = createComponentFactory(ButtonComponent);
let spectator: Spectator<ButtonComponent>;
beforeEach(() => {
spectator = createComponent({ props: { label: 'Submit' } });
});
it('renders the label', () => {
expect(spectator.query('button')).toHaveText('Submit');
});
});15 lines become 7. The ratio gets better with more complex setups.
Installation
npm install --save-dev @ngneat/spectatorFor Jest:
npm install --save-dev @ngneat/spectator/jestcreateComponentFactory
The core API for testing components:
import { createComponentFactory, Spectator } from '@ngneat/spectator';
import { UserCardComponent } from './user-card.component';
import { UserService } from '../services/user.service';
describe('UserCardComponent', () => {
const createComponent = createComponentFactory({
component: UserCardComponent,
declarations: [BadgeComponent], // child components to include
imports: [CommonModule], // Angular modules
providers: [UserService], // real services
mocks: [NotificationService], // auto-mocked services
detectChanges: false, // control when CD runs
});
let spectator: Spectator<UserCardComponent>;
beforeEach(() => {
spectator = createComponent({
props: {
user: { id: '1', name: 'Alice', email: 'alice@example.com' },
},
});
});
it('displays the user name', () => {
spectator.detectChanges();
expect(spectator.query('[data-testid="user-name"]')).toHaveText('Alice');
});
it('emits delete event', () => {
let deletedId: string | undefined;
spectator.component.delete.subscribe((id: string) => (deletedId = id));
spectator.click('[data-testid="delete-btn"]');
expect(deletedId).toBe('1');
});
});Querying the DOM
Spectator's query API returns native elements (not DebugElements):
// Returns HTMLElement | null
spectator.query('button')
spectator.query('.user-name')
spectator.query('[data-testid="submit-btn"]')
spectator.query(ButtonComponent) // by component type
// Returns HTMLElement[]
spectator.queryAll('.card')
spectator.queryAll(UserCardComponent)
// With custom root
spectator.query('.item', { root: spectator.query('.list') })Combined with jasmine matchers from @ngneat/spectator:
expect(spectator.query('h1')).toHaveText('Alice');
expect(spectator.query('.error')).toExist();
expect(spectator.query('.loading')).not.toExist();
expect(spectator.query('input')).toHaveClass('invalid');
expect(spectator.query('input')).toHaveValue('alice@example.com');
expect(spectator.query('a')).toHaveAttribute('href', '/users/1');SpyObject for Mocking Services
Spectator's SpyObject<T> creates a jasmine spy for every method:
describe('UserListComponent with mocked service', () => {
const createComponent = createComponentFactory({
component: UserListComponent,
mocks: [UserService], // UserService is auto-mocked
});
it('calls getUsers on init', () => {
const spectator = createComponent();
const userService = spectator.inject(UserService); // SpyObject<UserService>
userService.getUsers.and.returnValue(of([]));
spectator.detectChanges();
expect(userService.getUsers).toHaveBeenCalledOnce();
});
it('renders users from the service', () => {
const users = [
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' },
];
const spectator = createComponent();
const userService = spectator.inject(UserService);
userService.getUsers.and.returnValue(of(users));
spectator.detectChanges();
const cards = spectator.queryAll('[data-testid="user-card"]');
expect(cards).toHaveLength(2);
});
});createServiceFactory
Testing services with Spectator:
import { createServiceFactory, SpectatorService } from '@ngneat/spectator';
import { AuthService } from './auth.service';
import { UserService } from './user.service';
import { of } from 'rxjs';
describe('AuthService', () => {
const createService = createServiceFactory({
service: AuthService,
mocks: [UserService, HttpClient], // mock dependencies
});
let spectator: SpectatorService<AuthService>;
beforeEach(() => {
spectator = createService();
});
it('returns false when no user is stored', () => {
expect(spectator.service.isAuthenticated()).toBe(false);
});
it('returns true after successful login', fakeAsync(async () => {
const userService = spectator.inject(UserService);
userService.login.and.returnValue(of({ id: '1', token: 'abc123' }));
await spectator.service.login('alice@example.com', 'password');
expect(spectator.service.isAuthenticated()).toBe(true);
}));
});Testing HTTP with Spectator
Spectator wraps HttpClientTestingModule automatically when you use createHttpFactory:
import { createHttpFactory, HttpMethod, SpectatorHttp } from '@ngneat/spectator';
import { ProductService } from './product.service';
describe('ProductService', () => {
const createHttp = createHttpFactory(ProductService);
let spectator: SpectatorHttp<ProductService>;
beforeEach(() => {
spectator = createHttp();
});
it('gets products from the API', () => {
const mockProducts = [{ id: '1', name: 'Widget' }];
spectator.service.getProducts().subscribe(products => {
expect(products).toEqual(mockProducts);
});
const req = spectator.expectOne('/api/products', HttpMethod.GET);
req.flush(mockProducts);
});
it('deletes a product', () => {
spectator.service.deleteProduct('1').subscribe();
spectator.expectOne('/api/products/1', HttpMethod.DELETE).flush(null);
});
});Testing Directives
import { createDirectiveFactory, SpectatorDirective } from '@ngneat/spectator';
import { HighlightDirective } from './highlight.directive';
describe('HighlightDirective', () => {
const createDirective = createDirectiveFactory(HighlightDirective);
let spectator: SpectatorDirective<HighlightDirective>;
beforeEach(() => {
spectator = createDirective(`<div appHighlight color="yellow">Highlighted text</div>`);
});
it('sets the background color', () => {
expect(spectator.element.style.backgroundColor).toBe('yellow');
});
it('removes highlight on mouseout', () => {
spectator.dispatchMouseEvent(spectator.element, 'mouseout');
expect(spectator.element.style.backgroundColor).toBe('');
});
});Testing Pipes
import { createPipeFactory, SpectatorPipe } from '@ngneat/spectator';
import { TruncatePipe } from './truncate.pipe';
describe('TruncatePipe', () => {
const createPipe = createPipeFactory(TruncatePipe);
let spectator: SpectatorPipe<TruncatePipe>;
it('truncates long strings', () => {
spectator = createPipe(`<div>{{ 'This is a very long string that should be truncated' | truncate: 20 }}</div>`);
expect(spectator.element).toHaveText('This is a very long...');
});
it('does not truncate short strings', () => {
spectator = createPipe(`<div>{{ 'Short' | truncate: 20 }}</div>`);
expect(spectator.element).toHaveText('Short');
});
});When NOT to Use Spectator
Spectator doesn't help when:
- You need very fine-grained control over TestBed configuration
- You're testing complex DI hierarchies that don't fit the factory pattern
- You're already deep into a raw TestBed codebase and mixing styles would cause confusion
In these cases, raw TestBed is fine. Spectator is a tool for reducing boilerplate, not a framework requirement.
Running Tests
Spectator works with the standard Angular test runner:
ng test <span class="hljs-comment"># Karma (default)
ng <span class="hljs-built_in">test --no-watch <span class="hljs-comment"># single runFor Jest, configure it in jest.config.js and run:
npx jest # or yarn jestProduction Monitoring
Service and component tests verify isolated logic. Real Angular applications have auth flows, WebSockets, error boundaries, and user sessions that no unit test exercises.
HelpMeTest tests your live Angular application end-to-end, continuously. The free tier covers 10 tests with 5-minute check intervals — start without a credit card.