Spectator for Angular: Reducing TestBed Boilerplate with createComponent and createService

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/spectator

For Jest:

npm install --save-dev @ngneat/spectator/jest

createComponentFactory

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 run

For Jest, configure it in jest.config.js and run:

npx jest                   # or yarn jest

Production 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.

Read more