Skip to main content

Testing in Angular (unit and integration tests)

Testing in Angular means verifying that components, services, and their interactions work correctly, in isolation (unit tests) and together (integration tests), using Jasmine specs and TestBed's dependency injection system.

Theory

TL;DR

  • Unit tests verify a single class or function with all dependencies mocked
  • Integration tests let real dependencies run together and catch bugs at the boundaries
  • TestBed is Angular's test harness: it replaces the real injector and controls what gets injected
  • Unit tests are fast and point to exact failures; integration tests are slower but catch contract bugs
  • Default to unit tests for logic; add integration tests where pieces interact (forms, HTTP, routing)

Quick example

typescript
// Unit test - AuthService is mocked, we verify the call it('should call login with credentials', () => { const authService = jasmine.createSpyObj('AuthService', ['login']); authService.login.and.returnValue(of({ token: 'abc123' })); component.form.patchValue({ email: 'user@test.com', password: 'pass' }); component.onSubmit(); expect(authService.login).toHaveBeenCalledWith({ email: 'user@test.com', password: 'pass' }); }); // Integration test - real UserService runs, HTTP intercepted it('should load and display users on init', fakeAsync(() => { fixture.detectChanges(); // Triggers ngOnInit const req = httpMock.expectOne('/api/users'); req.flush([{ id: 1, name: 'Alice' }]); tick(); fixture.detectChanges(); const items = fixture.nativeElement.querySelectorAll('.user-item'); expect(items.length).toBe(1); expect(items[0].textContent).toContain('Alice'); }));

In the unit test, the service is a spy object and we assert on the call. In the integration test, the real service runs and we intercept the HTTP request.

Unit vs integration: the core difference

Unit tests isolate one piece and mock everything else. They run in milliseconds and point directly to the broken line when something fails. Integration tests let real services interact with real components, catching bugs that only appear at the boundary, like when a component doesn't handle the actual response format the service returns.

You need both. Unit tests catch bugs in logic. Integration tests catch bugs in contracts between pieces.

When to use each

Unit test:

  • Service method logic or calculations
  • Component input/output behavior
  • Pipe transformation or guard decision
  • Any code with no external dependencies

Integration test:

  • Form submission through a service to an API
  • Component loading and displaying data from a service
  • Routing with guards
  • HTTP interceptor behavior

E2E tests (Cypress, Playwright) cover full browser workflows with a real backend. That is a separate layer entirely.

Comparison table

AspectUnit testIntegration test
ScopeSingle class or functionMultiple components and services
DependenciesMocked or stubbedReal or semi-real
SpeedMillisecondsHundreds of milliseconds
Failure clarityPoints to exact unitPoints to interaction issue
SetupSimpleRequires TestBed configuration
Best forBusiness logic, calculationsData flow, component lifecycle

How TestBed works internally

TestBed.configureTestingModule() sets up a test injector that mirrors Angular's real DI system. You specify which providers, imports, and components to use. When you call TestBed.createComponent(), Angular instantiates the component through that injector and returns a ComponentFixture, a wrapper with access to the component instance, the DOM, and manual change detection control.

The fixture does not auto-detect changes. You call fixture.detectChanges() yourself. This gives you control: set state, assert, update state, assert again. For HTTP, HttpClientTestingModule replaces the real HttpClient with a mock. You inject HttpTestingController, intercept requests with expectOne(), and respond with flush(). No real network call happens.

Common mistakes

Forgetting fixture.detectChanges() after state changes

typescript
// Wrong - DOM not updated component.isLoading = false; expect(fixture.nativeElement.textContent).toContain('Done'); // fails // Right component.isLoading = false; fixture.detectChanges(); // Re-renders the template expect(fixture.nativeElement.textContent).toContain('Done'); // passes

Angular does not re-render the template automatically in tests. You trigger it manually.

Testing implementation details instead of behavior

typescript
// Wrong - breaks if you rename the method it('should call getUserData', () => { spyOn(component, 'getUserData'); component.ngOnInit(); expect(component.getUserData).toHaveBeenCalled(); }); // Right - tests what the user actually sees it('should display user name after init', () => { fixture.detectChanges(); expect(fixture.nativeElement.textContent).toContain('Alice'); });

The first test breaks on refactoring even when the feature works. Test outcomes, not method names.

Using async() instead of fakeAsync() for timing control

typescript
// Wrong - real timers, flaky in CI it('should debounce search', async () => { component.search('test'); await new Promise(r => setTimeout(r, 300)); expect(component.results).toBeDefined(); }); // Right - time is controlled it('should debounce search', fakeAsync(() => { component.search('test'); tick(300); expect(component.results).toBeDefined(); }));

fakeAsync() + tick() gives you full control over timers. Real timers in tests lead to intermittent failures in CI.

Mocking too much in integration tests

typescript
// Wrong - testing mocks, not integration providers: [ { provide: UserService, useValue: mockUserService }, { provide: HttpClient, useValue: mockHttp } ] // Right - mock only external systems, let real services run imports: [UserListComponent, HttpClientTestingModule], providers: [UserService] // Real service, HTTP intercepted

If you mock everything in an integration test, you get no information about how the pieces actually work together.

Not calling httpMock.verify() in afterEach

Without httpMock.verify(), unexpected HTTP requests pass silently. Add it to afterEach in every suite that makes HTTP calls.

Real-world usage

  • NestJS uses TestingModule, the same pattern as Angular's TestBed, so the concept transfers directly
  • React (Jest + Testing Library) uses render() + screen queries, same idea as fixture.nativeElement
  • Vue (Vitest) uses mount() + flushPromises(), the equivalent of fakeAsync + tick
  • Redux: unit test reducers as pure functions, integration test with the store and middleware together

Teams that skip integration tests often find issues only in staging, when two well-unit-tested services produce a correctly typed response that the component's template just never renders. Five lines with HttpTestingController would have caught it in local development.

Follow-up questions

Q: What is the difference between TestBed.inject() and getting a service from the component's constructor?
A: Both return the same singleton from the test injector. TestBed.inject() gives you direct access in the test to set up spies or verify calls without going through the component instance.

Q: When should I use fakeAsync() vs async() vs returning a Promise?
A: Use fakeAsync() when you need to control time with tick() for debounce, throttle, or setTimeout. Use async() when you just need to wait for Promises without controlling time. fakeAsync() is the most stable option for CI environments.

Q: How do you test a component with ChangeDetectionStrategy.OnPush?
A: The same way, but you must call fixture.detectChanges() after every state change. OnPush components only re-render when inputs change or events fire. Angular will not pick up direct property mutations, even in tests.

Q: What is the difference between TestBed.createComponent() and calling new ComponentClass()?
A: TestBed.createComponent() uses the configured injector, so dependencies are injected and lifecycle hooks fire. new bypasses the injector entirely: no injection, no lifecycle hooks, no Angular features.

Q (senior-level): Your test for a component that loads data in ngOnInit passes, but the data never shows up in the DOM. What do you check?
A: First, verify that fixture.detectChanges() is called after the async operation completes, not just at init. Then check whether fakeAsync + tick is needed because the service returns an Observable with a delay. If the component uses OnPush, state set directly on the component instance won't trigger re-render without explicit detection. Finally, add a spy to confirm the service actually returns data: spyOn(service, 'getData').and.returnValue(of(mockData)). If the spy fires but the DOM stays empty, the problem is change detection. If the spy shows no call at all, the problem is in ngOnInit itself.

Examples

Unit test: LoginComponent with mocked AuthService

typescript
describe('LoginComponent', () => { let component: LoginComponent; let fixture: ComponentFixture<LoginComponent>; let authService: jasmine.SpyObj<AuthService>; beforeEach(async () => { authService = jasmine.createSpyObj('AuthService', ['login']); authService.login.and.returnValue(of({ token: 'abc123' })); await TestBed.configureTestingModule({ imports: [LoginComponent, ReactiveFormsModule], providers: [{ provide: AuthService, useValue: authService }] }).compileComponents(); fixture = TestBed.createComponent(LoginComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should call authService.login with form values', () => { component.form.patchValue({ email: 'user@test.com', password: 'pass' }); component.onSubmit(); expect(authService.login).toHaveBeenCalledWith({ email: 'user@test.com', password: 'pass' }); }); it('should display error on login failure', () => { authService.login.and.returnValue( throwError(() => new Error('Invalid credentials')) ); component.form.patchValue({ email: 'user@test.com', password: 'wrong' }); component.onSubmit(); fixture.detectChanges(); const errorEl = fixture.nativeElement.querySelector('.error'); expect(errorEl.textContent).toContain('Invalid credentials'); }); });

jasmine.createSpyObj creates a mock with tracked methods. The second test overrides the spy's return value to simulate a failure, then checks that the template reacts. Both tests are isolated: AuthService never touches the network.

Integration test: UserListComponent with real service and intercepted HTTP

typescript
describe('UserListComponent integration', () => { let fixture: ComponentFixture<UserListComponent>; let httpMock: HttpTestingController; beforeEach(async () => { await TestBed.configureTestingModule({ imports: [UserListComponent, HttpClientTestingModule], providers: [UserService] // Real service, not a mock }).compileComponents(); fixture = TestBed.createComponent(UserListComponent); httpMock = TestBed.inject(HttpTestingController); }); afterEach(() => { httpMock.verify(); // Fail if any request was not handled }); it('should load and display users', fakeAsync(() => { fixture.detectChanges(); // Triggers ngOnInit -> UserService.getUsers() const req = httpMock.expectOne('/api/users'); expect(req.request.method).toBe('GET'); req.flush([{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]); tick(); fixture.detectChanges(); const items = fixture.nativeElement.querySelectorAll('.user-item'); expect(items.length).toBe(2); expect(items[0].textContent).toContain('Alice'); })); it('should show error message on network failure', fakeAsync(() => { fixture.detectChanges(); const req = httpMock.expectOne('/api/users'); req.error(new ErrorEvent('Network error')); tick(); fixture.detectChanges(); const errorMsg = fixture.nativeElement.querySelector('.error-message'); expect(errorMsg.textContent).toContain('Failed to load users'); })); });

UserService is the real class but HttpClient is intercepted. The test verifies the full path: ngOnInit calls the service, the service makes an HTTP request, the component renders the response. The network failure test confirms the error handling path works too.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?