Angular TestBed Deep Dive: Module Setup, Dependency Injection, fakeAsync, and Marble Testing

Angular TestBed Deep Dive: Module Setup, Dependency Injection, fakeAsync, and Marble Testing

TestBed is Angular's testing module — it creates a mini Angular application for each test suite, complete with dependency injection, component compilation, and the Angular zone. Understanding how to configure TestBed, provide mock services, and control async execution is the foundation of effective Angular testing. This guide goes deep on all three topics.

Key Takeaways

TestBed is reset between describe blocks, not between it blocks. The same TestBed configuration applies to all tests in a beforeEach. If you need different DI configuration for different tests, use nested describe blocks each with their own beforeEach.

TestBed.inject<T>(token) replaces TestBed.get(). The old get() is deprecated. inject<T>(ServiceClass) is the typed replacement.

provideValue vs useClass vs useFactory. provideValue: mock is for simple objects. useClass: MockService creates a new instance. useFactory: () => createMock() gives you control over construction.

fakeAsync doesn't work with real setTimeout from outside the zone. If you're testing code that uses native timers (not Angular's async mechanisms), waitForAsync + fixture.whenStable() is the alternative.

Marble testing requires exact frame notation. '-a-b|' means: 1 frame empty, emit 'a', 1 frame empty, emit 'b', complete. Miscount the dashes and tests fail misleadingly.

TestBed Fundamentals

Every Angular unit test starts with TestBed.configureTestingModule(). This method creates a test NgModule that mirrors how you'd configure a real module:

beforeEach(async () => {
  await TestBed.configureTestingModule({
    declarations: [MyComponent],           // components to test
    imports: [CommonModule, FormsModule],  // required Angular modules
    providers: [
      MyService,                           // real service
      { provide: AnotherService, useValue: mockAnotherService },  // mock
    ],
  }).compileComponents();  // await for external templateUrl/styleUrls
});

compileComponents() compiles component templates. It's required when components use templateUrl or styleUrls. For components with inline templates, it's a no-op but harmless to include.

Providing Mock Dependencies

useValue — simple object mock

const mockUserService = {
  getUser: jasmine.createSpy('getUser').and.returnValue(of({ id: '1', name: 'Alice' })),
  deleteUser: jasmine.createSpy('deleteUser').and.returnValue(of(null)),
};

TestBed.configureTestingModule({
  providers: [
    { provide: UserService, useValue: mockUserService },
  ],
});

useClass — mock class

class MockUserService {
  getUser = jasmine.createSpy('getUser').and.returnValue(of({ id: '1', name: 'Alice' }));
  deleteUser = jasmine.createSpy('deleteUser').and.returnValue(of(null));
}

TestBed.configureTestingModule({
  providers: [
    { provide: UserService, useClass: MockUserService },
  ],
});

useFactory — dynamic construction

TestBed.configureTestingModule({
  providers: [
    {
      provide: UserService,
      useFactory: () => {
        const mock = jasmine.createSpyObj('UserService', ['getUser', 'deleteUser']);
        mock.getUser.and.returnValue(of({ id: '1', name: 'Alice' }));
        return mock;
      },
    },
  ],
});

Accessing Injected Services in Tests

let component: UserListComponent;
let fixture: ComponentFixture<UserListComponent>;
let userService: jasmine.SpyObj<UserService>;

beforeEach(async () => {
  const userServiceSpy = jasmine.createSpyObj('UserService', ['getUsers', 'deleteUser']);
  
  await TestBed.configureTestingModule({
    declarations: [UserListComponent],
    providers: [
      { provide: UserService, useValue: userServiceSpy },
    ],
  }).compileComponents();

  fixture = TestBed.createComponent(UserListComponent);
  component = fixture.componentInstance;
  
  // Get the injected instance — it's the spy object
  userService = TestBed.inject(UserService) as jasmine.SpyObj<UserService>;
});

it('calls getUsers on init', () => {
  userService.getUsers.and.returnValue(of([]));
  fixture.detectChanges();  // triggers ngOnInit
  
  expect(userService.getUsers).toHaveBeenCalledOnce();
});

Change Detection in Tests

Angular's change detection doesn't run automatically in tests. You control it:

fixture.detectChanges();                     // run change detection once
await fixture.whenStable();                  // wait for async stabilization

// For components with OnPush strategy
component.someInput = 'new value';
fixture.detectChanges();

For ChangeDetectionStrategy.OnPush components, detectChanges() only re-renders if an input changed or a signal/observable emitted. Setting internal state without an input change won't trigger rendering.

fakeAsync and tick()

fakeAsync wraps your test in a special zone that intercepts timers and microtasks. tick() advances the virtual clock:

import { fakeAsync, tick, flush, discardPeriodicTasks } from '@angular/core/testing';

it('debounces search by 300ms', fakeAsync(() => {
  const spy = spyOn(component, 'performSearch');
  
  component.searchQuery = 'angular';
  component.onSearchChange('angular');
  
  tick(299);
  expect(spy).not.toHaveBeenCalled();
  
  tick(1);  // total: 300ms
  expect(spy).toHaveBeenCalledWith('angular');
}));

it('polls for updates every 5 seconds', fakeAsync(() => {
  const fetchSpy = spyOn(component, 'fetchUpdates');
  
  component.startPolling();
  
  tick(5000);
  expect(fetchSpy).toHaveBeenCalledTimes(1);
  
  tick(10000);  // advance 10 more seconds
  expect(fetchSpy).toHaveBeenCalledTimes(3);
  
  // Must discard ongoing periodic tasks before test ends
  discardPeriodicTasks();
}));

fakeAsync with Promises

it('loads data from API', fakeAsync(() => {
  const mockData = [{ id: '1', name: 'Alice' }];
  userService.getUsers.and.returnValue(Promise.resolve(mockData));
  
  fixture.detectChanges();  // triggers ngOnInit
  
  // Promise resolution is micro-task scheduled
  flush();  // drain all microtasks
  fixture.detectChanges();  // update DOM
  
  const items = fixture.nativeElement.querySelectorAll('.user-item');
  expect(items.length).toBe(1);
}));

waitForAsync Alternative

When fakeAsync doesn't work (e.g., XHR in some environments), use waitForAsync:

import { waitForAsync } from '@angular/core/testing';

it('loads users', waitForAsync(() => {
  userService.getUsers.and.returnValue(of([{ id: '1', name: 'Alice' }]));
  
  fixture.detectChanges();
  
  fixture.whenStable().then(() => {
    fixture.detectChanges();
    const items = fixture.nativeElement.querySelectorAll('.user-item');
    expect(items.length).toBe(1);
  });
}));

whenStable() returns a Promise that resolves when the Angular zone has no pending async tasks.

RxJS Marble Testing

Marble tests verify Observable timing using ASCII diagrams:

'-'  = one frame (10ms virtual time)
'a'  = emit value 'a'  
'|'  = complete
'#'  = error
'('  = synchronous group: '(ab|)' emits a, b, completes in one frame
import { TestScheduler } from 'rxjs/testing';

describe('searchWithDebounce', () => {
  let scheduler: TestScheduler;

  beforeEach(() => {
    scheduler = new TestScheduler((actual, expected) => {
      expect(actual).toEqual(expected);
    });
  });

  it('debounces emissions by 300ms', () => {
    scheduler.run(({ cold, hot, expectObservable }) => {
      const input$  = hot('a-b--c----|', { a: 'a', b: 'ab', c: 'abc' });
      const expected =    '------c----|';
      
      // debounceTime(300) in rxjs marble = 300 frames
      const result$ = input$.pipe(debounceTime(300, scheduler));
      
      expectObservable(result$).toBe(expected, { c: 'abc' });
    });
  });

  it('retries on error with exponential backoff', () => {
    scheduler.run(({ cold, expectObservable }) => {
      const source$ = cold('#', {}, new Error('Network error'));
      const expected =    '3000ms a|';
      
      const result$ = source$.pipe(
        retryWhen(errors => errors.pipe(delay(3000, scheduler))),
        take(1)
      );
      
      // After retry delay, inject success
      // (simplified — real retry tests need more setup)
      expectObservable(result$);
    });
  });
});

Marble testing is most useful for services that combine, transform, or time-shift Observable streams.

Nested describe for Different Configurations

When different scenarios require different DI configurations:

describe('UserCardComponent', () => {
  describe('when user is admin', () => {
    beforeEach(async () => {
      await TestBed.configureTestingModule({
        declarations: [UserCardComponent],
        providers: [
          { provide: AuthService, useValue: { currentUser: { role: 'admin' } } },
        ],
      }).compileComponents();
      // setup...
    });

    it('shows the delete button', () => {
      expect(fixture.nativeElement.querySelector('.delete-btn')).toBeTruthy();
    });
  });

  describe('when user is viewer', () => {
    beforeEach(async () => {
      await TestBed.configureTestingModule({
        declarations: [UserCardComponent],
        providers: [
          { provide: AuthService, useValue: { currentUser: { role: 'viewer' } } },
        ],
      }).compileComponents();
      // setup...
    });

    it('hides the delete button', () => {
      expect(fixture.nativeElement.querySelector('.delete-btn')).toBeNull();
    });
  });
});

Running Tests

ng test                         <span class="hljs-comment"># watch mode
ng <span class="hljs-built_in">test --no-watch --code-coverage  <span class="hljs-comment"># CI with coverage
ng <span class="hljs-built_in">test --include=<span class="hljs-string">"**/user*"    <span class="hljs-comment"># filter by file pattern

Production Monitoring

TestBed tests verify Angular component logic against mocked dependencies. They don't verify the deployed app — API connectivity, authentication flows, or cross-browser behavior.

HelpMeTest continuously tests your live Angular application. Start with the free tier: 10 tests, 5-minute monitoring intervals.

Read more