# Spectator Spectator is a powerful testing library for Angular that simplifies unit testing by eliminating boilerplate code and providing a clean, intuitive API. It wraps Angular's TestBed to provide convenient methods for testing components, directives, services, pipes, and HTTP data services with minimal setup. The library supports Jasmine, Jest, and Vitest test frameworks and offers custom matchers for DOM assertions, event handling utilities, and specialized factories for different testing scenarios. The library provides a comprehensive suite of factory functions that create test harnesses with built-in utilities for querying DOM elements, triggering events, managing change detection, and mocking dependencies. Spectator's design philosophy centers on writing tests that mirror how you write production code, enabling host component testing, routing support, HTTP testing capabilities, and automatic provider mocking. With features like deferrable view testing, global injections, and component provider overrides, Spectator covers all major Angular testing scenarios while maintaining type safety and developer experience. ## API Reference ### createComponentFactory Factory function that creates a component test harness with utilities for querying, event triggering, and change detection management. ```typescript import { Spectator, createComponentFactory } from '@ngneat/spectator'; import { ButtonComponent } from './button.component'; describe('ButtonComponent', () => { let spectator: Spectator; const createComponent = createComponentFactory({ component: ButtonComponent, imports: [CommonModule], providers: [SomeService], mocks: [DependencyService], detectChanges: true, shallow: false }); beforeEach(() => { spectator = createComponent({ props: { className: 'primary', disabled: false }, detectChanges: true }); }); it('should update button class when input changes', () => { expect(spectator.query('button')).toHaveClass('primary'); spectator.setInput('className', 'danger'); expect(spectator.query('button')).toHaveClass('danger'); expect(spectator.query('button')).not.toHaveClass('primary'); }); it('should emit click events', () => { let output; spectator.output('click').subscribe(result => (output = result)); spectator.click('button'); expect(output).toEqual({ type: 'click' }); }); }); ``` ### createHostFactory Creates a test harness with a custom host component, enabling testing of components in realistic usage scenarios with template-driven inputs and outputs. ```typescript import { SpectatorHost, createHostFactory } from '@ngneat/spectator'; import { ZippyComponent } from './zippy.component'; describe('ZippyComponent with Host', () => { let spectator: SpectatorHost; const createHost = createHostFactory({ component: ZippyComponent, imports: [CommonModule], declarations: [CustomChildComponent] }); it('should display title from host property', () => { spectator = createHost( ``, { hostProps: { title: 'Spectator is Awesome', onToggle: jasmine.createSpy('toggle') } } ); expect(spectator.query('.zippy__title')).toHaveText('Spectator is Awesome'); spectator.click('.zippy__title'); expect(spectator.hostComponent.onToggle).toHaveBeenCalledWith(true); }); it('should project content correctly', () => { spectator = createHost( `
Projected Content
` ); spectator.click('.zippy__title'); expect(spectator.query('.custom-content')).toHaveText('Projected Content'); }); }); ``` ### createServiceFactory Creates a test harness for services with automatic dependency mocking and injection utilities. ```typescript import { SpectatorService, createServiceFactory } from '@ngneat/spectator'; import { AuthService } from './auth.service'; import { DateService } from './date.service'; import { HttpClient } from '@angular/common/http'; describe('AuthService', () => { let spectator: SpectatorService; const createService = createServiceFactory({ service: AuthService, providers: [ { provide: DateService, useValue: { isExpired: () => false } } ], mocks: [HttpClient] }); beforeEach(() => spectator = createService()); it('should check login status based on token expiry', () => { const dateService = spectator.inject(DateService); spyOn(dateService, 'isExpired').and.returnValue(false); expect(spectator.service.isLoggedIn()).toBeTruthy(); dateService.isExpired.and.returnValue(true); expect(spectator.service.isLoggedIn()).toBeFalsy(); }); it('should make HTTP requests with injected client', () => { const http = spectator.inject(HttpClient); http.get.and.returnValue(of({ token: 'abc123' })); spectator.service.login('user', 'pass').subscribe(result => { expect(result.token).toBe('abc123'); }); expect(http.get).toHaveBeenCalledWith('/api/auth/login', jasmine.any(Object)); }); }); ``` ### createHttpFactory Creates a specialized test harness for HTTP data services with automatic HttpTestingController setup and request verification. ```typescript import { SpectatorHttp, createHttpFactory, HttpMethod } from '@ngneat/spectator'; import { TodosDataService } from './todos-data.service'; describe('TodosDataService', () => { let spectator: SpectatorHttp; const createHttp = createHttpFactory({ service: TodosDataService, providers: [SomeProvider] }); beforeEach(() => spectator = createHttp()); it('should fetch todos via GET request', () => { const mockTodos = [{ id: 1, title: 'Test' }]; spectator.service.getTodos().subscribe(todos => { expect(todos).toEqual(mockTodos); }); const req = spectator.expectOne('/api/todos', HttpMethod.GET); expect(req.request.method).toBe('GET'); req.flush(mockTodos); }); it('should post new todo with correct payload', () => { const newTodo = { title: 'New Task' }; spectator.service.postTodo(newTodo).subscribe(); const req = spectator.expectOne('/api/todos', HttpMethod.POST); expect(req.request.body).toEqual(newTodo); req.flush({ id: 2, ...newTodo }); }); it('should handle concurrent requests', () => { spectator.service.collectTodos().subscribe(); const reqs = spectator.expectConcurrent([ { url: '/api1/todos', method: HttpMethod.GET }, { url: '/api2/todos', method: HttpMethod.GET } ]); spectator.flushAll(reqs, [ [{ id: 1, title: 'Todo 1' }], [{ id: 2, title: 'Todo 2' }] ]); }); }); ``` ### createRoutingFactory Creates a test harness with routing capabilities, providing a stubbed ActivatedRoute and utilities for testing route parameters, query params, and navigation. ```typescript import { SpectatorRouting, createRoutingFactory } from '@ngneat/spectator'; import { ProductDetailsComponent } from './product-details.component'; describe('ProductDetailsComponent with Routing', () => { let spectator: SpectatorRouting; const createComponent = createRoutingFactory({ component: ProductDetailsComponent, params: { productId: '3' }, queryParams: { view: 'detailed' }, data: { title: 'Product Details' }, stubsEnabled: true }); beforeEach(() => spectator = createComponent()); it('should display route data and params', () => { expect(spectator.query('.title')).toHaveText('Product Details'); expect(spectator.component.productId).toBe('3'); }); it('should react to route parameter changes', () => { spectator.setRouteParam('productId', '5'); expect(spectator.component.productId).toBe('5'); }); it('should update on query param changes', () => { spectator.setRouteQueryParam('view', 'compact'); expect(spectator.component.viewMode).toBe('compact'); }); it('should trigger navigation with multiple updates', () => { spectator.triggerNavigation({ params: { productId: '7' }, queryParams: { view: 'list', sort: 'asc' }, data: { title: 'Updated Title' } }); expect(spectator.component.productId).toBe('7'); expect(spectator.component.viewMode).toBe('list'); }); }); ``` ### createDirectiveFactory Creates a test harness for testing directives with host element access and event simulation capabilities. ```typescript import { SpectatorDirective, createDirectiveFactory } from '@ngneat/spectator'; import { HighlightDirective } from './highlight.directive'; describe('HighlightDirective', () => { let spectator: SpectatorDirective; const createDirective = createDirectiveFactory({ directive: HighlightDirective, imports: [CommonModule] }); beforeEach(() => { spectator = createDirective( `
Testing Highlight
`, { hostProps: { color: 'yellow' } } ); }); it('should apply background color on mouseover', () => { spectator.dispatchMouseEvent(spectator.element, 'mouseover'); expect(spectator.element).toHaveStyle({ backgroundColor: 'rgba(0, 0, 0, 0.1)' }); }); it('should remove background color on mouseout', () => { spectator.dispatchMouseEvent(spectator.element, 'mouseover'); spectator.dispatchMouseEvent(spectator.element, 'mouseout'); expect(spectator.element).toHaveStyle({ backgroundColor: '#fff' }); }); it('should access directive instance', () => { expect(spectator.directive).toBeDefined(); expect(spectator.directive.color).toBe('yellow'); }); }); ``` ### createPipeFactory Creates a test harness for testing pipes with template rendering and custom host component support. ```typescript import { SpectatorPipe, createPipeFactory } from '@ngneat/spectator'; import { SumPipe } from './sum.pipe'; import { StatsService } from './stats.service'; describe('SumPipe', () => { let spectator: SpectatorPipe; const createPipe = createPipeFactory({ pipe: SumPipe, providers: [StatsService] }); it('should sum numbers from template literal', () => { spectator = createPipe(`{{ [1, 2, 3] | sum }}`); expect(spectator.element).toHaveText('6'); }); it('should sum numbers from host property', () => { spectator = createPipe(`{{ prop | sum }}`, { hostProps: { prop: [10, 20, 30] } }); expect(spectator.element).toHaveText('60'); }); it('should update when host property changes', () => { spectator = createPipe(`{{ numbers | sum }}`, { hostProps: { numbers: [1, 2] } }); expect(spectator.element).toHaveText('3'); spectator.setHostInput('numbers', [5, 10, 15]); spectator.detectChanges(); expect(spectator.element).toHaveText('30'); }); it('should use injected dependencies', () => { const statsService = spectator.inject(StatsService); spyOn(statsService, 'sum').and.returnValue(42); spectator = createPipe(`{{ [1, 2] | sum }}`); expect(spectator.element).toHaveText('42'); }); }); ``` ### createInjectionContextFactory Creates a test harness for testing dependency injection functions that use inject() API without requiring a component context. ```typescript import { SpectatorInjectionContext, createInjectionContextFactory } from '@ngneat/spectator'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { HttpClient } from '@angular/common/http'; import { inject } from '@angular/core'; function getUsers() { return inject(HttpClient).get('/api/users'); } function useLogger() { const logger = inject(LoggerService); return (message: string) => logger.log(message); } describe('DI Functions', () => { let spectator: SpectatorInjectionContext; const createContext = createInjectionContextFactory({ providers: [ provideHttpClientTesting(), LoggerService ] }); beforeEach(() => spectator = createContext()); it('should fetch users using inject()', () => { const controller = spectator.inject(HttpTestingController); spectator.runInInjectionContext(getUsers).subscribe(users => { expect(users.length).toBe(2); expect(users[0].name).toBe('John'); }); controller.expectOne('/api/users').flush([ { id: 1, name: 'John' }, { id: 2, name: 'Jane' } ]); }); it('should use logger from injection context', () => { const logger = spectator.inject(LoggerService); spyOn(logger, 'log'); const logFn = spectator.runInInjectionContext(useLogger); logFn('Test message'); expect(logger.log).toHaveBeenCalledWith('Test message'); }); }); ``` ### DOM Query Methods Spectator provides flexible query methods supporting string selectors, type selectors, and DOM selector utilities for finding elements. ```typescript import { Spectator, createComponentFactory, byText, byPlaceholder, byLabel } from '@ngneat/spectator'; import { DashboardComponent } from './dashboard.component'; describe('Dashboard Query Methods', () => { let spectator: Spectator; const createComponent = createComponentFactory(DashboardComponent); beforeEach(() => spectator = createComponent()); it('should query by CSS selector', () => { const button = spectator.query('button.primary'); const allButtons = spectator.queryAll('button'); const lastButton = spectator.queryLast('button'); expect(button).toHaveClass('primary'); expect(allButtons).toHaveLength(3); }); it('should query by component type', () => { const childComponent = spectator.query(ChildComponent); const allChildren = spectator.queryAll(ChildComponent); expect(childComponent).toBeDefined(); expect(allChildren).toHaveLength(2); }); it('should query with read token', () => { const elementRef = spectator.query(ChildComponent, { read: ElementRef }); const service = spectator.query('.item', { read: SomeService }); expect(elementRef.nativeElement).toBeDefined(); }); it('should query using DOM selectors', () => { const input = spectator.query(byPlaceholder('Enter email')); const checkbox = spectator.query(byLabel('Accept terms')); const button = spectator.query(byText('Submit')); const heading = spectator.query(byRole('heading', { level: 1 })); expect(input).toHaveAttribute('type', 'email'); expect(checkbox).toExist(); expect(button).toHaveText('Submit'); }); it('should query within parent selector', () => { const child = spectator.query(ChildComponent, { parentSelector: '#container-1' }); const nested = spectator.query('.item', { parentSelector: byText('Parent Item') }); expect(child).toBeDefined(); }); it('should query from document root', () => { const modal = spectator.query('.modal-overlay', { root: true }); expect(modal).toExist(); }); }); ``` ### Event Handling and Simulation Comprehensive event simulation utilities for mouse, keyboard, touch, and custom component events with automatic change detection. ```typescript import { Spectator, createComponentFactory, createKeyboardEvent } from '@ngneat/spectator'; import { FormComponent } from './form.component'; describe('Event Handling', () => { let spectator: Spectator; const createComponent = createComponentFactory(FormComponent); beforeEach(() => spectator = createComponent()); it('should handle click events', () => { spectator.click('button.submit'); spectator.click(spectator.query('.checkbox')); spectator.click(byText('Cancel')); expect(spectator.component.submitted).toBe(true); }); it('should handle focus and blur events', () => { const input = spectator.query('input[name="email"]'); spectator.focus(input); expect(spectator.component.emailFocused).toBe(true); spectator.blur(input); expect(spectator.component.emailFocused).toBe(false); }); it('should simulate typing', () => { spectator.typeInElement('john@example.com', 'input[name="email"]'); expect(spectator.component.email).toBe('john@example.com'); }); it('should dispatch keyboard events', () => { spectator.dispatchKeyboardEvent('input', 'keyup', 'Enter'); expect(spectator.component.submitted).toBe(true); spectator.dispatchKeyboardEvent('input', 'keydown', { key: 'Escape', keyCode: 27 }); expect(spectator.component.cancelled).toBe(true); }); it('should use keyboard helpers', () => { spectator.keyboard.pressEnter(); spectator.keyboard.pressEscape(); spectator.keyboard.pressTab(); spectator.keyboard.pressKey('ctrl.s'); expect(spectator.component.saveShortcutTriggered).toBe(true); }); it('should dispatch mouse events', () => { spectator.dispatchMouseEvent('.draggable', 'mousedown', 100, 200); spectator.dispatchMouseEvent('.draggable', 'mousemove', 150, 250); spectator.dispatchMouseEvent('.draggable', 'mouseup'); expect(spectator.component.dragPosition).toEqual({ x: 150, y: 250 }); }); it('should use mouse helpers', () => { spectator.mouse.contextmenu('.item'); spectator.mouse.dblclick('.folder'); expect(spectator.component.contextMenuShown).toBe(true); }); it('should trigger custom component events', () => { spectator.triggerEventHandler(ChildComponent, 'customEvent', { value: 42 }); spectator.triggerEventHandler('app-child', 'statusChange', 'active'); expect(spectator.component.childEventData).toEqual({ value: 42 }); }); }); ``` ### Custom Matchers Rich set of custom matchers for DOM assertions, element state checking, and content validation. ```typescript import { Spectator, createComponentFactory } from '@ngneat/spectator'; import { ProductListComponent } from './product-list.component'; describe('Custom Matchers', () => { let spectator: Spectator; const createComponent = createComponentFactory(ProductListComponent); beforeEach(() => spectator = createComponent()); it('should verify element existence and length', () => { expect('.product-item').toExist(); expect('.product-item').not.toExist(); expect('.product-item').toHaveLength(5); }); it('should verify element attributes and properties', () => { expect(spectator.query('.product')).toHaveId('product-123'); expect(spectator.query('.product')).toHaveAttribute('data-sku', 'ABC-123'); expect(spectator.query('.product')).toHaveAttribute({ 'data-sku': 'ABC-123', 'data-price': '29.99' }); expect(spectator.query('.checkbox')).toHaveProperty('checked', true); expect(spectator.query('.checkbox')).toHaveProperty({ checked: true, disabled: false }); expect(spectator.query('.image')).toContainProperty({ src: 'product.jpg' }); }); it('should verify classes with strict and non-strict modes', () => { expect('.product').toHaveClass('featured'); expect('.product').toHaveClass('featured active'); expect('.product').toHaveClass(['featured', 'active']); // Strict mode: order matters (default) expect('.product').not.toHaveClass(['active', 'featured']); // Non-strict mode: order doesn't matter expect('.product').toHaveClass(['active', 'featured'], { strict: false }); }); it('should verify text content', () => { expect('.title').toHaveText('Product Name'); expect('.title').toHaveText((text) => text.includes('Product')); expect('.titles').toHaveText(['Product 1', 'Product 2', 'Product 3']); expect('.title').toContainText('Name'); expect('.title').toHaveExactText('Product Name'); expect('.title').toHaveExactTrimmedText('Product Name'); }); it('should verify form element values', () => { expect('input[name="quantity"]').toHaveValue('5'); expect('.product-inputs').toHaveValue(['Red', 'Large', '5']); expect('input').toContainValue('search term'); }); it('should verify styles', () => { expect(spectator.element).toHaveStyle({ backgroundColor: 'rgba(0, 0, 0, 0.1)', fontSize: '16px' }); }); it('should verify element states', () => { expect('.checkbox').toBeChecked(); expect('.checkbox').toBeIndeterminate(); expect('.submit-button').toBeDisabled(); expect('.empty-container').toBeEmpty(); expect('.modal').toBeHidden(); expect('.modal').toBeVisible(); expect('.search-input').toBeFocused(); expect('.option').toBeSelected(); }); it('should verify element matching', () => { expect('.product').toBeMatchedBy('.featured-product'); expect(spectator.component.data).toBePartial({ name: 'Product', price: 29.99 }); }); it('should verify descendants', () => { expect('.product-card').toHaveDescendant('.price'); expect('.product-card').toHaveDescendantWithText({ selector: '.title', text: 'Featured Product' }); }); it('should verify select options', () => { const select = spectator.query('#size-select') as HTMLSelectElement; spectator.selectOption(select, 'Large'); expect(select).toHaveSelectedOptions('Large'); const multiSelect = spectator.query('#colors') as HTMLSelectElement; spectator.selectOption(multiSelect, ['Red', 'Blue']); expect(multiSelect).toHaveSelectedOptions(['Red', 'Blue']); // Using HTMLOptionElement const option = spectator.query(byText('Medium')) as HTMLOptionElement; spectator.selectOption(select, option); expect(select).toHaveSelectedOptions(option); }); }); ``` ### Mocking Providers Automatic provider mocking with spy creation and template property support for both Jasmine and Jest. ```typescript import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator'; import { UserService } from './user.service'; import { AuthService } from './auth.service'; import { LoggerService } from './logger.service'; describe('Provider Mocking', () => { let spectator: SpectatorService; const createService = createServiceFactory({ service: UserService, mocks: [AuthService, LoggerService], providers: [ mockProvider(ConfigService, { apiUrl: 'http://test.api', timeout: 5000, isProduction: false, getConfig: () => ({ theme: 'dark' }) }) ] }); beforeEach(() => spectator = createService()); it('should automatically create method spies', () => { const auth = spectator.inject(AuthService); auth.isAuthenticated.and.returnValue(true); expect(spectator.service.canAccessResource()).toBe(true); auth.isAuthenticated.and.returnValue(false); expect(spectator.service.canAccessResource()).toBe(false); }); it('should support mock properties and methods', () => { const config = spectator.inject(ConfigService); expect(config.apiUrl).toBe('http://test.api'); expect(config.timeout).toBe(5000); expect(config.getConfig()).toEqual({ theme: 'dark' }); }); it('should mock constructor dependencies', () => { const logger = mockProvider(LoggerService, { level: 'debug', log: jasmine.createSpy('log') }); spectator = createService({ providers: [logger] }); const loggerInstance = spectator.inject(LoggerService); loggerInstance.log('test'); expect(loggerInstance.log).toHaveBeenCalledWith('test'); expect(loggerInstance.level).toBe('debug'); }); }); ``` ### Deferrable Views Testing utilities for Angular's deferrable views (@defer blocks) with support for nested defer blocks and different render states. ```typescript import { Spectator, createComponentFactory, DeferBlockBehavior } from '@ngneat/spectator'; import { DashboardComponent } from './dashboard.component'; describe('Deferrable Views', () => { let spectator: Spectator; const createComponent = createComponentFactory({ component: DashboardComponent, deferBlockBehavior: DeferBlockBehavior.Manual }); beforeEach(() => spectator = createComponent()); it('should render complete state of defer block', async () => { await spectator.deferBlock().renderComplete(); expect(spectator.query('.deferred-content')).toExist(); expect(spectator.query('.deferred-content')).toHaveText('Loaded Content'); }); it('should render placeholder state', async () => { await spectator.deferBlock().renderPlaceholder(); expect(spectator.query('.placeholder')).toExist(); expect(spectator.query('.placeholder')).toHaveText('Loading...'); }); it('should render loading state', async () => { await spectator.deferBlock().renderLoading(); expect(spectator.query('.loading-spinner')).toExist(); }); it('should render error state', async () => { await spectator.deferBlock().renderError(); expect(spectator.query('.error-message')).toExist(); expect(spectator.query('.error-message')).toContainText('Failed to load'); }); it('should access multiple defer blocks by index', async () => { await spectator.deferBlock(0).renderComplete(); await spectator.deferBlock(1).renderComplete(); expect(spectator.query('#first-defer')).toExist(); expect(spectator.query('#second-defer')).toExist(); }); it('should handle nested defer blocks', async () => { const parentState = await spectator.deferBlock().renderComplete(); expect(spectator.query('.parent-content')).toExist(); await parentState.deferBlock().renderComplete(); expect(spectator.query('.nested-content')).toExist(); }); it('should test multiple levels of nesting', async () => { const level1 = await spectator.deferBlock().renderComplete(); const level2 = await level1.deferBlock().renderComplete(); const level3 = await level2.deferBlock().renderComplete(); expect(spectator.query('.level-3-content')).toExist(); }); }); ``` ### Component Providers and Overrides Override component-level providers, view providers, and imports for testing with mocked dependencies or alternative implementations. ```typescript import { createComponentFactory, Spectator } from '@ngneat/spectator'; import { UserProfileComponent } from './user-profile.component'; import { UserService } from './user.service'; import { LoggerService } from './logger.service'; describe('Component Provider Overrides', () => { let spectator: Spectator; const createComponent = createComponentFactory({ component: UserProfileComponent, componentProviders: [ { provide: UserService, useValue: { getCurrentUser: () => ({ id: 1, name: 'Test User' }) } } ], componentMocks: [LoggerService], componentViewProviders: [ { provide: ThemeService, useClass: MockThemeService } ] }); beforeEach(() => spectator = createComponent()); it('should use overridden component providers', () => { const userService = spectator.inject(UserService, true); const user = userService.getCurrentUser(); expect(user).toEqual({ id: 1, name: 'Test User' }); }); it('should mock component view providers', () => { const logger = spectator.inject(LoggerService, true); logger.log.and.returnValue(undefined); spectator.component.logActivity(); expect(logger.log).toHaveBeenCalled(); }); }); describe('Standalone Component Overrides', () => { const createComponent = createComponentFactory({ component: StandaloneComponent, componentImports: [ [OriginalChildComponent, MockChildComponent] ], overrideComponents: [ [ StandaloneComponent, { remove: { imports: [HeavyDependency] }, add: { imports: [LightweightMock] } } ] ] }); it('should use mocked child component', () => { const spectator = createComponent(); expect(spectator.query('#mock-child')).toExist(); expect(spectator.query('#original-child')).not.toExist(); }); }); ``` ## Summary Spectator transforms Angular testing from a verbose, boilerplate-heavy process into a streamlined developer experience. The library's main use cases include component testing with simplified DOM querying and event handling, service testing with automatic dependency mocking, HTTP data service testing with built-in request verification, directive and pipe testing with minimal setup, and routing integration tests with stubbed ActivatedRoute support. Developers use Spectator to write more maintainable test suites by leveraging custom matchers like toHaveClass, toBeDisabled, and toContainText, which provide clear, readable assertions. Integration patterns span from simple unit tests using createComponentFactory for isolated component testing, to complex scenarios involving createHostFactory for realistic parent-child interactions, createRoutingFactory for navigation testing, and createHttpFactory for API service validation. The library seamlessly integrates with Jest and Vitest through dedicated entry points (@ngneat/spectator/jest and @ngneat/spectator/vitest), supports global injections for shared test dependencies, and provides schematics for generating test files with proper Spectator imports. Whether testing standalone components, handling deferrable views, mocking component providers, or validating complex user interactions, Spectator reduces testing friction while maintaining full compatibility with Angular's testing infrastructure and supporting modern framework features like signals and input bindings.