Week 19: Component, Integration & E2E Testing

Unit tests prove one class works in isolation. This week is about the seams — whether components actually cooperate correctly together, and whether a real user, in a real browser, can complete a real flow end to end.

Phase 5 of 7 Week 19 of 26 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Write tests that catch real regressions across component boundaries
  • Write a maintainable Playwright suite using the page object pattern
  • Recognize and fix a flaky test instead of just re-running it

1. Component Harnesses & the CDK Testing Harness API

Testing a component by querying raw CSS selectors (.querySelector('.star'), from Week 18) is brittle — a class rename breaks every test that referenced it. A component harness is a small API the component's own author writes, giving tests a stable, intention-revealing way to interact with it regardless of internal markup changes.

rating-stars-harness.ts
import { ComponentHarness } from '@angular/cdk/testing';

export class RatingStarsHarness extends ComponentHarness {
  static hostSelector = 'app-rating-stars';

  private getStars = this.locatorForAll('.star');

  async clickStar(index: number): Promise<void> {
    const stars = await this.getStars();
    await stars[index].click();
  }

  async getFilledCount(): Promise<number> {
    const stars = await this.getStars();
    const states = await Promise.all(stars.map((s) => s.hasClass('is-filled')));
    return states.filter(Boolean).length;
  }
}
using it in a test
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';

it('fills three stars after clicking the third', async () => {
  const fixture = TestBed.createComponent(RatingStars);
  fixture.componentRef.setInput('max', 5);
  fixture.detectChanges();

  const harness = await TestbedHarnessEnvironment.harnessForFixture(fixture, RatingStarsHarness);
  await harness.clickStar(2);

  expect(await harness.getFilledCount()).toBe(3);
});

If .star ever gets renamed to .rating-star, only RatingStarsHarness needs updating — every test using it keeps working unchanged. This is the same "test behavior, not implementation" principle from Week 18, formalized into reusable tooling.

2. Integration Testing Patterns

An integration test renders a component with its real children, instead of stubbing them out — proving the pieces actually cooperate, which a unit test of each piece in isolation can't guarantee on its own.

checkout-form.integration.spec.ts
it('shows the order total updating as items are added', () => {
  const fixture = TestBed.createComponent(CheckoutPage); // renders CartSummary, RatingStars, etc. for real
  fixture.detectChanges();

  const addButton = fixture.nativeElement.querySelector('[data-testid="add-item"]');
  addButton.click();
  fixture.detectChanges();

  const total = fixture.nativeElement.querySelector('[data-testid="total"]').textContent;
  expect(total).toContain('$10.00');
});

The tradeoff versus a pure unit test: slower (more of the app actually runs), and a failure could originate in any of the involved components — but it catches a real class of bug that isolated unit tests structurally cannot: two components that each pass their own tests but don't actually work correctly together.

3. End-to-End Testing with Playwright

An E2E test drives a real, running app in a real browser — no TestBed, no simulated DOM, an actual application your test clicks through exactly as a user would.

signup.spec.ts
import { test, expect } from '@playwright/test';

test('a new user can sign up successfully', async ({ page }) => {
  await page.goto('/signup');

  await page.getByLabel('Email').fill('ada@example.com');
  await page.getByLabel('Username').fill('ada_l');
  await page.getByLabel('Password').fill('Sup3rSecret!');
  await page.getByLabel('Confirm password').fill('Sup3rSecret!');

  await page.getByRole('button', { name: 'Next' }).click();
  // ... continue through remaining steps ...

  await page.getByRole('button', { name: 'Create account' }).click();

  await expect(page.getByText('Welcome, ada_l')).toBeVisible();
});

getByLabel and getByRole are deliberate choices over CSS selectors — they query the page the way a user (or a screen reader) actually perceives it, which means these tests double as a lightweight accessibility check: if a form field has no real associated label, getByLabel can't find it either.

4. Page Object Patterns for E2E Suites

As an E2E suite grows, repeating raw selectors and interactions in every test file becomes its own maintenance burden. A page object wraps one page (or flow) behind a small, readable API — the E2E equivalent of Section 1's component harness.

signup-page.ts
import { Page, expect } from '@playwright/test';

export class SignupPage {
  constructor(private page: Page) {}

  async goto() {
    await this.page.goto('/signup');
  }

  async fillAccountStep(email: string, username: string) {
    await this.page.getByLabel('Email').fill(email);
    await this.page.getByLabel('Username').fill(username);
    await this.page.getByRole('button', { name: 'Next' }).click();
  }

  async expectWelcomeMessage(username: string) {
    await expect(this.page.getByText(`Welcome, ${username}`)).toBeVisible();
  }
}
signup.spec.ts — using the page object
test('a new user can sign up successfully', async ({ page }) => {
  const signup = new SignupPage(page);
  await signup.goto();
  await signup.fillAccountStep('ada@example.com', 'ada_l');
  // ...
  await signup.expectWelcomeMessage('ada_l');
});

If the signup form's markup changes, only SignupPage needs updating — every test using it keeps reading like a description of user behavior, not a list of raw selectors.

5. Visual Regression Testing (Overview)

A visual regression test compares a screenshot of a page or component against a saved "baseline" image, flagging pixel differences — catching a category of bug functional tests miss entirely: something still works, but looks visually broken (an overlapping element, a color regression, a broken layout on a specific viewport).

visual.spec.ts
test('product card looks correct', async ({ page }) => {
  await page.goto('/products/42');
  await expect(page.locator('app-product-card')).toHaveScreenshot('product-card.png');
});

The tradeoff worth knowing before adopting this broadly: baselines need deliberate updating whenever a change is intentional, and they're inherently sensitive to rendering differences across operating systems and browser versions — usually run in a single, consistent CI environment rather than every developer's local machine, precisely to avoid false failures from unrelated font-rendering differences.

6. CI Test Strategy & Flaky-Test Triage

A reasonable default split, from fastest/cheapest to slowest/most expensive: run unit tests on every commit, integration tests on every pull request, and the full E2E suite on merges to your main branch (or on a schedule) — since E2E tests are both the slowest and the most prone to environmental flakiness.

A flaky test is a bug in the test, not bad luck

The instinct to just re-run a flaky test until it passes trains a team to ignore CI failures entirely — eventually a real failure gets re-run away too. The two most common actual causes: a race condition (asserting before an async action finishes — usually fixed by waiting for a specific element/state rather than a fixed delay) or shared state leaking between tests (one test's data affecting another's). Both are fixable; neither is actually random.

7. Hands-on Exercise

Hands-on

Write an E2E Playwright suite for the Week 12 signup flow

Cover the full multi-step signup form end to end, using the page object pattern.

Requirements:

  1. A SignupPage page object wrapping every step of the Week 12 multi-step form (account info, passwords, skills), with one method per logical action.
  2. A "happy path" test completing all three steps successfully and asserting the final review step shows correctly entered data.
  3. A test asserting the mismatched-password error appears and blocks progression to the next step.
  4. A test asserting the "add another skill" flow works, and that removing a skill down to zero re-triggers the "at least one required" validation.
  5. Run the suite at least twice in a row and confirm it passes consistently — if any test is flaky, diagnose whether it's a race condition or shared state per Section 6, and fix the actual cause.
Hint

If the async-validated username field (Week 12) causes intermittent failures, that's very likely a race condition — Playwright's expect(...).toBeVisible() auto-retries and waits, but only if you assert on the actual eventual state rather than proceeding immediately after a click.

8. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

What real problem does a component harness solve that direct querySelector calls in a test don't?

It decouples tests from the component's internal markup and CSS class names. If the component's implementation changes (a class rename, restructured DOM) but its externally-observable behavior doesn't, only the harness needs updating — every test using it keeps working, rather than every test that happened to reference .star directly breaking at once.

Q2

What kind of bug can an integration test catch that two passing unit tests, for each component involved, cannot?

A mismatch at the boundary between two components — each one individually does exactly what its own unit tests say it should, but they don't actually cooperate correctly when combined (wrong event name, mismatched data shape, a parent not properly wiring a child's output to its own state). Isolated unit tests, by design, never render both components together, so they structurally cannot catch this class of bug.

Q3

Why does this lesson prefer page.getByLabel(...) and page.getByRole(...) over CSS selectors in Playwright tests?

They query the page the way a real user (or assistive technology) perceives it — by its accessible label and role — rather than by implementation details like class names. As a side effect, a test using getByLabel can only pass if the field genuinely has a proper accessible label, which means the test suite itself catches a real accessibility regression (echoing Week 13's forms-accessibility lesson) essentially for free.

Q4

Per this lesson, what are the two most common actual causes of a flaky test — and why is "just re-run it" the wrong response to either?

A race condition (asserting before an async action has actually finished) and shared state leaking between tests. Both have a concrete, fixable root cause — re-running just hides the symptom without fixing it, and normalizes ignoring CI failures generally, which eventually means a genuine, reproducible failure gets dismissed as "probably flaky" too.