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 frameimport { 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 patternProduction 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.