The fastest way to make a team stop writing tests is to give them tests that break every time they touch unrelated code. Angular component tests earn that reputation when they reach into internals — calling private methods, asserting on component instance state, mocking every dependency down to the last method. The alternative is testing through the same interface a user does: render, interact, assert on what's visible.

Query by What the User Sees, Not by CSS Class

fixture.debugElement.query(By.css('.submit-btn')) couples the test to a class name that has nothing to do with behavior — rename the class for a styling refactor and the test breaks despite nothing meaningful changing. Prefer semantic queries:

typescript
it('disables submit until the form is valid', () => {
  const fixture = TestBed.createComponent(SignupForm);
  fixture.detectChanges();

  const submit = fixture.debugElement.query(
    By.css('button[type=submit]'),
  ).nativeElement as HTMLButtonElement;

  expect(submit.disabled).toBe(true);

  const emailInput = fixture.debugElement.query(By.css('input[name=email]')).nativeElement;
  emailInput.value = 'user@example.com';
  emailInput.dispatchEvent(new Event('input'));
  fixture.detectChanges();

  expect(submit.disabled).toBe(false);
});

This test would survive a complete internal rewrite of SignupForm — new validators, a different form library, restructured template — as long as the observable behavior (disabled button, enabled after valid email) stays the same.

Stop Mocking Everything

A common failure mode is a TestBed.configureTestingModule with ten provide overrides, each a hand-rolled spy object. This produces tests that verify your mocks behave as you configured them, not that the component behaves correctly. Mock at the boundary — HTTP, browser APIs, third-party SDKs — and let everything else run for real:

typescript
TestBed.configureTestingModule({
  imports: [ProductList],
  providers: [
    provideHttpClient(),
    provideHttpClientTesting(), // mocks HTTP, nothing else
  ],
});

HttpTestingController lets you assert on the actual request the component made and flush a controlled response, which catches real bugs (wrong URL, missing header, unparsed query param) that a hand-mocked service silently hides.

Test the Signal, Not the Change Detection Cycle

With signal-based components, avoid asserting on internal signal values directly — that couples the test to implementation the same way private method calls do. Assert on rendered output instead:

typescript
it('shows the empty state with no items', () => {
  const fixture = TestBed.createComponent(CartList);
  fixture.componentRef.setInput('cart', []);
  fixture.detectChanges();

  const empty = fixture.nativeElement.querySelector('.empty-state');
  expect(empty?.textContent).toContain('Your cart is empty');
});

setInput is the important detail here — it goes through Angular's actual input-binding path (triggering ngOnChanges and signal-based input updates correctly), unlike setting a component property directly, which can produce false positives for OnPush components that a real parent binding would never trigger.

Harnesses for Anything Backed by Angular Material

Querying Material component internals by CSS class is especially fragile because Material's DOM structure changes between versions. Component test harnesses solve this by exposing a stable, version-independent API:

typescript
it('selects an option from the dropdown', async () => {
  const fixture = TestBed.createComponent(FilterBar);
  const loader = TestbedHarnessEnvironment.loader(fixture);
  const select = await loader.getHarness(MatSelectHarness);

  await select.open();
  await select.clickOptions({ text: 'In Stock' });

  expect(fixture.componentInstance.selectedFilter()).toBe('in-stock');
});

What to Leave Untested at the Component Level

Business logic that doesn't depend on the DOM — pricing calculations, date formatting, validation rules — belongs in plain unit tests against extracted functions or services, not component tests. A component test that re-verifies a pure function's math on every render path is redundant coverage that slows the suite down without catching anything a focused unit test wouldn't already catch faster.