1. Testing Philosophy: What to Test, What Not To
100% coverage is not the goal — confidence that your app behaves correctly when you change it is. A few principles that hold up across most Angular codebases:
- Test behavior, not implementation — assert on what a component or service does, not how it does it internally, so refactoring doesn't break tests that never should have cared.
- Favor tests that would actually catch a real regression over tests that exist to inflate a coverage number.
- Business logic (validators, computed state, services) deserves thorough unit tests. Simple, prop-driven template markup often doesn't need its own dedicated test at all.
- A test that never fails — because it doesn't actually assert anything meaningful — is worse than no test, since it creates false confidence.
2. Vitest/Jasmine Fundamentals & TestBed
Recent Angular CLI versions default to Vitest; older and existing projects commonly use
Jasmine + Karma. The test-writing syntax (describe, it,
expect) is nearly identical either way — what matters more is
TestBed, Angular's own testing utility for constructing a test module and
resolving dependencies.
import { TestBed } from '@angular/core/testing';
import { NotificationService } from './notification.service';
describe('NotificationService', () => {
let service: NotificationService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(NotificationService);
});
it('is created', () => {
expect(service).toBeTruthy();
});
});
You met the two extremes already: Week 9 showed a service simple enough to
new up directly with no TestBed at all, and Week 17 showed a
Signal Store that needs it because signalStore() relies on Angular's
injector. Reach for TestBed specifically when the thing under test needs
Angular's DI, change detection, or template compilation to function.
3. Testing Components with Signal Inputs/Outputs
Testing a component means rendering it via TestBed.createComponent(),
setting its signal inputs, running change detection, and asserting on the rendered
output or emitted events.
import { TestBed } from '@angular/core/testing';
import { RatingStars } from './rating-stars';
describe('RatingStars', () => {
it('emits `rated` when a star is clicked', () => {
const fixture = TestBed.createComponent(RatingStars);
fixture.componentRef.setInput('max', 5);
fixture.detectChanges();
let emitted: number | undefined;
fixture.componentInstance.rated.subscribe((value) => (emitted = value));
const thirdStar = fixture.nativeElement.querySelectorAll('.star')[2];
thirdStar.click();
fixture.detectChanges();
expect(emitted).toBe(3);
});
it('does nothing when readOnly is true', () => {
const fixture = TestBed.createComponent(RatingStars);
fixture.componentRef.setInput('max', 5);
fixture.componentRef.setInput('readOnly', true);
fixture.detectChanges();
const firstStar = fixture.nativeElement.querySelector('.star');
firstStar.click();
fixture.detectChanges();
expect(fixture.componentInstance.value()).toBe(0);
});
});
fixture.componentRef.setInput() is specifically required for signal inputs
— directly assigning fixture.componentInstance.max = 5 doesn't work, since
a signal input isn't a plain writable property from outside the component.
4. Mocking Services & HTTP
TestBed lets you override any provider with a fake — for HTTP specifically,
provideHttpClientTesting() plus HttpTestingController intercepts
requests without hitting a real network at all.
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { ProductsService } from './products.service';
describe('ProductsService', () => {
let service: ProductsService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [ProductsService, provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(ProductsService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify()); // fails the test if any request went unhandled
it('fetches all products', () => {
let result: unknown;
service.getAll().subscribe((products) => (result = products));
const req = httpMock.expectOne('/api/products');
expect(req.request.method).toBe('GET');
req.flush([{ id: '1', name: 'Demo', price: 9.99 }]);
expect(result).toEqual([{ id: '1', name: 'Demo', price: 9.99 }]);
});
});
httpMock.verify() in afterEach is worth always including — it
fails the test if your code sent a request the test never asserted on, catching
accidental extra calls (exactly the double-subscribe bug from Week 16) as a test failure
instead of a silent surprise in production.
5. Testing RxJS Streams (Marble Testing Basics)
For RxJS logic involving timing — debouncing, delays — marble testing lets you assert on an Observable's exact emission timeline using a compact string syntax, run on a virtual clock so tests run instantly regardless of the real delays involved.
import { TestScheduler } from 'rxjs/testing';
import { debounceTime } from 'rxjs';
it('debounces rapid emissions into one', () => {
const scheduler = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});
scheduler.run(({ cold, expectObservable }) => {
// 'a' at frame 0, 'b' at frame 20ms, then nothing -- both within the 30ms debounce window
const source$ = cold('a-b|', { a: 1, b: 2 });
const result$ = source$.pipe(debounceTime(30, scheduler));
// only 'b' survives -- debounceTime drops 'a' since 'b' arrived before the window closed
expectObservable(result$).toBe('----(b|)', { b: 2 });
});
});
Each character in a marble string represents one frame of virtual time; -
means nothing happens, a letter is an emission, | is completion. It reads
unusually at first, but it's the standard way the RxJS ecosystem itself tests
time-sensitive operators — worth recognizing even if you don't write many of these day
to day.
6. Snapshot vs. Behavioral Assertions
A snapshot test captures a component's rendered output (or a data structure) once, and fails if it ever differs later — convenient to write, but prone to becoming noise.
it('matches snapshot', () => {
const fixture = TestBed.createComponent(ProductCard);
fixture.detectChanges();
expect(fixture.nativeElement.innerHTML).toMatchSnapshot();
// Any styling tweak at all fails this test -- even ones that don't change behavior
});
it('shows the sale badge when the product is discounted', () => {
const fixture = TestBed.createComponent(ProductCard);
fixture.componentRef.setInput('product', { name: 'Demo', price: 9.99, onSale: true });
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.sale-badge');
expect(badge).toBeTruthy();
});
The behavioral version survives a CSS class rename or markup restructuring that doesn't change what the user actually sees — it asserts on the thing that matters (the badge exists when on sale), not on incidental implementation detail. Snapshots aren't useless, but they're best reserved for output that's genuinely meant to be stable and reviewed carefully on every intentional change, not applied as a default testing strategy.
7. Hands-on Exercise
Write a full unit-test suite for the Week 17 Signal Store
Cover the AppStore (or CartStore, if you built the simpler version) thoroughly, including edge cases.
Requirements:
- A test for every method, covering both its typical case and at least one edge case (removing an item that doesn't exist, adding a duplicate, logging out when already logged out).
- A test for every computed selector, with more than one distinct state shape to prove the computation is actually correct rather than coincidentally right for one input.
- At least one test asserting that an unrelated piece of state is unchanged after a method call — proving
patchStatemerges rather than replaces the whole state object. - If your store depends on an injected service (e.g. an HTTP-backed one), mock it via
TestBedprovider overrides rather than letting a real request go out. - Run your test suite's coverage report and look specifically for any method or branch with zero coverage — decide deliberately whether it needs a test or is acceptable to leave uncovered, rather than chasing 100% automatically.
If you're unsure whether a test is meaningful, ask: "if I introduced a real bug here, would this test actually fail?" If the answer is no, the test isn't testing what you think it is.
8. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why is "100% test coverage" a poor goal to optimize for directly?
Why is "100% test coverage" a poor goal to optimize for directly?
Coverage measures which lines ran during tests, not whether those tests would actually catch a bug — a test with no meaningful assertions can execute every line and still contribute nothing to confidence. Chasing the number tends to produce tests written to pass, not tests written to fail when something is actually broken.
Q2
Why does setting a signal input in a component test require fixture.componentRef.setInput() instead of direct property assignment?
Why does setting a signal input in a component test require fixture.componentRef.setInput() instead of direct property assignment?
A signal input isn't a plain writable class property from the outside — it's backed by Angular's internal input-binding machinery, the same mechanism that connects a real parent template's binding to the signal. setInput() goes through that same official API, ensuring the component reacts exactly as it would with a real parent, rather than silently doing nothing (or erroring) from a direct assignment.
Q3
What does calling httpMock.verify() in afterEach actually catch?
What does calling httpMock.verify() in afterEach actually catch?
Any HTTP request your code under test made that the test never explicitly checked with expectOne() (or similar) — it fails the test if there's an outstanding, unhandled request. This catches unexpected extra requests (like an accidental double-subscribe from Week 16) as a clear, immediate test failure rather than a silent bug that only shows up in production.
Q4
Why is the "sale badge" behavioral test more resilient to refactoring than the innerHTML snapshot test?
Why is the "sale badge" behavioral test more resilient to refactoring than the innerHTML snapshot test?
The behavioral test asserts on one specific, meaningful fact (a sale badge element exists when the product is on sale) and ignores everything else about the markup. The snapshot test captures the entire rendered HTML, so any unrelated change — a class rename, added whitespace, a wrapping element for unrelated styling — fails it, even when nothing about actual behavior changed. That makes snapshot failures common and easy to rubber-stamp without real scrutiny, which defeats the point of having a test at all.