Reactive forms are among the most complex Angular components to test well — they have validation state, async validators, submission logic, server error handling, and unsaved change detection. The correct approach tests the form as users interact with it: fill fields, submit, check errors. Testing that the right validators were configured is less important than testing that the right error messages appear when inputs are invalid.
Reactive Form Testing
describe('PostFormComponent', () => {
let fixture: ComponentFixture<PostFormComponent>;
let component: PostFormComponent;
let postsApi: jasmine.SpyObj<PostsApiService>;
beforeEach(async () => {
postsApi = jasmine.createSpyObj('PostsApiService', ['create', 'slugExists']);
postsApi.slugExists.and.returnValue(of(false)); // slug is unique by default
postsApi.create.and.returnValue(of({ id: 1, slug: 'my-post' } as PostDto));
await TestBed.configureTestingModule({
imports: [PostFormComponent, ReactiveFormsModule, NoopAnimationsModule],
providers: [
provideRouter([]),
{ provide: PostsApiService, useValue: postsApi },
{ provide: APP_CONFIG, useValue: { apiUrl: '' } },
],
}).compileComponents();
fixture = TestBed.createComponent(PostFormComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
// ── Validation error display ──────────────────────────────────────────
it('shows title required error when title is touched and empty', () => {
const titleControl = component.form.get('title')!;
titleControl.markAsTouched();
titleControl.setValue('');
fixture.detectChanges();
const errorEl = fixture.nativeElement.querySelector('[data-cy="title-error"]');
expect(errorEl).not.toBeNull();
expect(errorEl.textContent).toContain('required');
});
it('enables submit button only when form is valid', async () => {
const submitBtn = fixture.nativeElement.querySelector('[data-cy="publish-button"]');
// Initially invalid (empty title)
expect(submitBtn.disabled).toBeTrue();
// Fill valid data
component.form.patchValue({
title: 'Valid Post Title',
slug: 'valid-post-title',
body: 'Body content long enough to pass validation requirements.',
});
component.form.get('title')!.markAsTouched();
// Wait for async slug validator
await fixture.whenStable();
fixture.detectChanges();
expect(submitBtn.disabled).toBeFalse();
});
// ── Form submission ───────────────────────────────────────────────────
it('calls postsApi.create with form values on submit', async () => {
component.form.patchValue({
title: 'New Post Title',
slug: 'new-post-title',
body: 'Body content here and there.',
status: 'draft',
});
await fixture.whenStable();
fixture.detectChanges();
component.onSave('draft');
await fixture.whenStable();
expect(postsApi.create).toHaveBeenCalledWith(
jasmine.objectContaining({
title: 'New Post Title',
slug: 'new-post-title',
status: 'draft',
})
);
});
// ── Server error handling ─────────────────────────────────────────────
it('displays field errors from 422 response', async () => {
postsApi.create.and.returnValue(
throwError(() => ({
status: 422,
errors: { slug: ['Slug is already taken.'] },
message: 'Validation failed',
} as ApiError))
);
component.form.patchValue({ title: 'Title', slug: 'taken',
body: 'Body content here.' });
component.onSave('draft');
await fixture.whenStable();
fixture.detectChanges();
const slugError = fixture.nativeElement.querySelector('[data-cy="slug-error"]');
expect(slugError).not.toBeNull();
expect(slugError.textContent).toContain('already taken');
});
// ── Unsaved changes ───────────────────────────────────────────────────
it('returns true from hasUnsavedChanges() after user edits', () => {
component.form.patchValue({ title: 'Changed' });
expect(component.hasUnsavedChanges()).toBeTrue();
});
it('returns false from hasUnsavedChanges() after successful save', async () => {
component.form.patchValue({ title: 'New Title', slug: 'new',
body: 'Body content here.' });
component.onSave('draft');
await fixture.whenStable();
expect(component.hasUnsavedChanges()).toBeFalse();
});
});
await fixture.whenStable() waits for all pending async operations in the component’s zone to complete — including async validators (like the slug uniqueness check), HTTP requests through HttpClientTestingModule, and timer-based operations. After whenStable() resolves, fixture.detectChanges() applies any pending template updates. Use this pair for any test that involves async validators or async form submission.NoopAnimationsModule instead of BrowserAnimationsModule in component tests. Angular Material components often use animations that run asynchronously — they interfere with test timing and cause tests to fail or become slow. NoopAnimationsModule disables all animations while preserving the component’s full functionality, making tests synchronous and predictable. Always import NoopAnimationsModule when testing components that import Angular Material modules.component.onSave() bypasses the form’s submit event and any (ngSubmit) binding. This is usually fine for unit tests — you’re testing the method logic, not the HTML event binding. However, if your component has specific logic in the template’s (ngSubmit) handler that differs from calling the method directly, also add a test that triggers the DOM submit event: fixture.debugElement.query(By.css('form')).triggerEventHandler('ngSubmit', null).Common Mistakes
Mistake 1 — Not using NoopAnimationsModule (animation interference causes timing failures)
❌ Wrong — BrowserAnimationsModule in tests; form dialogs animate; assertions run before animation completes; failures.
✅ Correct — always NoopAnimationsModule in component tests using Material; instant transitions, predictable timing.
Mistake 2 — Missing whenStable() for async validators (form appears invalid when it’s valid)
❌ Wrong — patchValue + detectChanges only; async slug validator not resolved; form still in pending state; submit button disabled.
✅ Correct — await fixture.whenStable() after patchValue; all async validators resolve before assertions.