The Page Object pattern encapsulates all interactions with a component’s DOM into a reusable class — tests describe user behaviour using the page object’s methods rather than raw DOM queries. This makes tests read like specifications, reduces code duplication across test cases, and isolates DOM structure changes to a single place. Angular Testing Library’s approach achieves similar goals through accessibility-centric queries.
Page Objects and Angular Testing Library
// ── Page Object for PostFormComponent ─────────────────────────────────────
class PostFormPage {
constructor(private fixture: ComponentFixture<PostFormComponent>) {}
// ── Queries ────────────────────────────────────────────────────────────
get titleInput() { return this.query<HTMLInputElement>('[data-cy="title-input"]'); }
get slugInput() { return this.query<HTMLInputElement>('[data-cy="slug-input"]'); }
get bodyTextarea() { return this.query<HTMLTextAreaElement>('[data-cy="body-input"]'); }
get publishButton() { return this.query<HTMLButtonElement>('[data-cy="publish-button"]'); }
get titleError() { return this.queryText('[data-cy="title-error"]'); }
get slugError() { return this.queryText('[data-cy="slug-error"]'); }
get successToast() { return this.queryText('[data-cy="success-toast"]'); }
// ── Actions ────────────────────────────────────────────────────────────
fillTitle(title: string): this {
this.titleInput!.value = title;
this.titleInput!.dispatchEvent(new Event('input'));
this.fixture.detectChanges();
return this;
}
fillBody(body: string): this {
this.bodyTextarea!.value = body;
this.bodyTextarea!.dispatchEvent(new Event('input'));
this.fixture.detectChanges();
return this;
}
async clickPublish(): Promise<this> {
this.publishButton!.click();
this.fixture.detectChanges();
await this.fixture.whenStable();
this.fixture.detectChanges();
return this;
}
// ── Assertions ─────────────────────────────────────────────────────────
expectTitleError(message: string): this {
expect(this.titleError).toContain(message);
return this;
}
expectNoTitleError(): this {
expect(this.query('[data-cy="title-error"]')).toBeNull();
return this;
}
// ── Helpers ────────────────────────────────────────────────────────────
private query<T extends Element>(selector: string): T | null {
return this.fixture.nativeElement.querySelector(selector) as T | null;
}
private queryText(selector: string): string {
return this.query(selector)?.textContent?.trim() ?? '';
}
}
// ── Tests using Page Object ───────────────────────────────────────────────
describe('PostFormComponent — via Page Object', () => {
let page: PostFormPage;
beforeEach(async () => {
// ... TestBed setup ...
const fixture = TestBed.createComponent(PostFormComponent);
page = new PostFormPage(fixture);
fixture.detectChanges();
});
it('should validate title as required', () => {
page.fillTitle('').expectTitleError('Title is required');
});
it('should publish a complete post', async () => {
await page
.fillTitle('My New Post Title')
.fillBody('Body content that passes minimum length validation.')
.clickPublish();
expect(page.successToast).toContain('Post created');
});
});
// ── Angular Testing Library — user-centric approach ───────────────────────
describe('PostFormComponent — via Angular Testing Library', () => {
it('validates required fields on submission attempt', async () => {
const { getByRole, getByText, findByText } = await render(PostFormComponent, {
providers: [/* ... */],
});
// Try to submit with empty form
const publishBtn = getByRole('button', { name: /publish/i });
await userEvent.click(publishBtn);
// Validation errors appear
expect(await findByText(/title is required/i)).toBeInTheDocument();
expect(await findByText(/body is required/i)).toBeInTheDocument();
});
it('submits successfully with valid data', async () => {
const createSpy = jest.fn().mockReturnValue(of({ id: 1, slug: 'my-post' }));
const { getByLabelText, getByRole } = await render(PostFormComponent, {
providers: [
{ provide: PostsApiService, useValue: { create: createSpy, slugExists: () => of(false) } },
],
});
await userEvent.type(getByLabelText(/title/i), 'My New Post');
await userEvent.type(getByLabelText(/body/i), 'Body content here and there.');
await userEvent.click(getByRole('button', { name: /publish/i }));
expect(createSpy).toHaveBeenCalledWith(
expect.objectContaining({ title: 'My New Post' })
);
});
});
getByRole, getByLabelText) enforce accessibility-centric selectors inline. Both are valid — choose based on your team’s preference. Page Objects excel when the same component has many test cases; ATL excels when you want tests that enforce accessibility compliance by default.jest-axe to add accessibility testing to component tests. const { container } = await render(PostFormComponent, ...); const results = await axe(container); expect(results).toHaveNoViolations(). This catches common ARIA issues — missing labels, incorrect roles, low-contrast text — automatically as part of the component test suite. Accessibility bugs caught by automated tests in development are cheaper to fix than those found in production by users with assistive technology.fillTitle() method dispatches an input event to simulate user typing. Some Angular form controls also listen to change events — if the control uses (change) binding instead of (input), the page object’s action may not trigger the expected update. Use userEvent.type() from @testing-library/user-event instead — it dispatches all real browser events (keydown, keypress, keyup, input, change) in the correct order, matching real user interaction more accurately than manual event dispatch.Common Mistakes
Mistake 1 — Page Object queries without null checks (NPE when element absent)
❌ Wrong — this.titleError.textContent; when error element doesn’t exist, NPE thrown; confusing failure message.
✅ Correct — defensive query: this.query('[data-cy="title-error"]')?.textContent ?? ''; returns empty string when absent.
Mistake 2 — Not using NoopAnimationsModule in ATL render (animation timing failures)
❌ Wrong — render without NoopAnimationsModule; Material components animate; ATL queries find elements before they animate in.
✅ Correct — include imports: [NoopAnimationsModule] in ATL render options for any component using Angular Material.