TDD in Angular: Building a Signal-Based Component Test-First with Vitest
Starting TDD in Angular from a plain TypeScript function instead of the component keeps tests fast and the component thin. With Angular 22 and Vitest we grow cart totals while catching a floating point surprise, a CartSummary component with signal inputs using whenStable and setInput, and an HttpClient service by writing the request first with provideHttpClientTesting; and we see with real output why fakeAsync fails in a zoneless project.
Version note: The code in this post was built and run with Angular 22.2, Vitest 5.1.0, jsdom 30.1 and Node.js 24.
Predict First
We are writing a function that calculates the cart summary in Bookstore's Angular frontend. The earlier tests are green: an empty cart is zero, two "Domain-Driven Design" and one "Refactoring" make 121, the discount is subtracted, the total never goes below zero. The implementation rests on this line:
const subtotal = lines.reduce((sum, line) => sum + line.unitPrice * line.quantity, 0);
There is one more item on the test list. Three notebooks at 9.95 each are added to the cart:
it('adds prices without floating point drift', () => {
const notebook: CartLine = { isbn: '978-0000000001', title: 'Notebook', unitPrice: 9.95, quantity: 3 };
expect(calculateCartTotals([notebook], 0).subtotal).toBe(29.85);
});
Is this test green? If it's red, which number shows up on the screen? We see the answer at the end of the first section.
The Test Setup in Angular 22: A Quick Look
As we covered in the Angular 21 post of the Angular series, the default test runner in new Angular projects is now Vitest. With Angular 22, ng new sets the project up with Vitest and jsdom; ng test compiles the tests through the @angular/build:unit-test builder and hands them to Vitest. jsdom emulates the DOM inside Node.js; since no real browser is launched, the loop is fast. There is also a browser mode that runs in a real browser, but it's not the topic of this post. The details of setup, configuration options and component harnesses are in the upcoming Frontend Testing series.
Two things matter for TDD. First, ng test runs in watch mode by default: when you save a file the tests run again. That keeps the red-green-refactor rhythm as short as on the backend. Second, the Angular builder also type-checks TypeScript while compiling the tests. That's why the first red of a test that imports a file that doesn't exist comes from the compiler:
✘ [ERROR] TS2307: Cannot find module './cart-totals' or its corresponding type declarations. [plugin angular-compiler]
src/app/cart/cart-totals.spec.ts:1:36:
1 │ import { calculateCartTotals } from './cart-totals';
╵ ~~~~~~~~~~~~~~~
Plain TypeScript First: Cart Totals
On the frontend it's tempting to start TDD from the component, because you want to see something on the screen. But component tests bring the DOM, change detection and dependency injection; a plain function test needs none of them. Growing the calculation logic first in a function independent of Angular keeps the tests fast and leaves the component thin. Our test list:
// Test list: calculateCartTotals
// [ ] Empty cart: all zeros
// [ ] Subtotal = sum of unit price × quantity
// [ ] The discount is subtracted from the subtotal
// [ ] The total never goes below zero (the discount can't exceed the subtotal)
// [ ] Sums without floating point drift
We pass the first test with fake it, a function that returns zeros. The second test says the constant isn't enough:
AssertionError: expected { subtotal: +0, discount: +0, …(1) } to deeply equal { subtotal: 121, discount: +0, …(1) }
Writing the sum with reduce and the total with subtotal - discount makes two tests green; the discount test also passes on the first run, because we had added that to the implementation too. The no-negative-total test is red (expected { subtotal: 30, discount: 50, …(1) }) and goes green with Math.min.
Now the answer to the prediction:
× adds prices without floating point drift 2ms
AssertionError: expected 29.849999999999998 to be 29.85 // Object.is equality
In JavaScript a number is an IEEE (Institute of Electrical and Electronics Engineers) 754 double-precision floating point number, and it can't represent decimal values like 9.95 exactly. The guarantee decimal gives on the backend doesn't exist here. Without this test we would one day have seen "29.849999999999998" on the cart screen or, worse, a difference of a cent between the rounded value on the screen and the amount sent to the server. The fix is to calculate in whole cents:
const toCents = (amount: number) => Math.round(amount * 100);
export function calculateCartTotals(lines: CartLine[], discount: number): CartTotals {
const subtotalCents = lines.reduce((sum, line) => sum + toCents(line.unitPrice) * line.quantity, 0);
const discountCents = Math.min(toCents(discount), subtotalCents);
return {
subtotal: subtotalCents / 100,
discount: discountCents / 100,
total: (subtotalCents - discountCents) / 100,
};
}
Tests 7 passed (7)
Duration 513ms (environment 61%, setup 20%, transform 10%, tests 5%, import 3%, worker 1%)
Two of the seven tests are the App tests that come with the template. Most of the duration Vitest reports is environment setup; the tests themselves take milliseconds.
The Component Test-First: CartSummary
The calculation is ready. The component's job now comes down to three things: take the inputs, call the calculation, show the result. We write the tests first. A small render helper that creates the component with the inputs a parent would give keeps the tests short:
async function render(inputs: Record<string, unknown> = {}): Promise<HTMLElement> {
const fixture = TestBed.createComponent(CartSummary);
fixture.componentRef.setInput('lines', lines);
for (const [name, value] of Object.entries(inputs)) {
fixture.componentRef.setInput(name, value);
}
await fixture.whenStable();
return fixture.nativeElement;
}
const text = (host: HTMLElement, selector: string) => host.querySelector(selector)?.textContent?.trim();
describe('CartSummary', () => {
it('shows the subtotal and the total', async () => {
const host = await render();
expect(text(host, '[data-testid="subtotal"]')).toBe('121.00');
expect(text(host, '[data-testid="total"]')).toBe('121.00');
});
it('shows the discount row only when a coupon applies', async () => {
expect((await render()).querySelector('[data-testid="discount"]')).toBeNull();
expect(text(await render({ discount: 20 }), '[data-testid="discount"]')).toBe('-20.00');
});
it('explains why a coupon was rejected', async () => {
const host = await render({ couponError: 'Expired' });
expect(text(host, '[role="alert"]')).toBe('This coupon has expired.');
});
});
Writing the test made several design decisions. The component takes three inputs, and componentRef.setInput sets an input the way a parent would; it works correctly with signal inputs too. In a zoneless application TestBed.createComponent doesn't run change detection synchronously; await fixture.whenStable() waits for rendering to finish. We find elements not by CSS classes but by data-testid and role.
With a component that defines the inputs but has an empty template, the three tests are red for the right reason:
× shows the subtotal and the total 17ms
× shows the discount row only when a coupon applies 5ms
× explains why a coupon was rejected 3ms
AssertionError: expected undefined to be '121.00' // Object.is equality
AssertionError: expected undefined to be '-20.00' // Object.is equality
AssertionError: expected undefined to be 'This coupon has expired.' // Object.is equality
The first line of the second test (toBeNull) passes with the empty template as well; had that assertion been alone, we would be facing a test that is green right away. The second line keeps it honest. The implementation:
const rejectionMessages: Record<string, string> = {
Expired: 'This coupon has expired.',
BelowMinimumSubtotal: 'Your cart is below the minimum amount for this coupon.',
};
@Component({
selector: 'app-cart-summary',
imports: [DecimalPipe],
template: `
<dl>
<dt>Subtotal</dt>
<dd data-testid="subtotal">{{ totals().subtotal | number: '1.2-2' }}</dd>
@if (totals().discount > 0) {
<dt>Discount</dt>
<dd data-testid="discount">-{{ totals().discount | number: '1.2-2' }}</dd>
}
<dt>Total</dt>
<dd data-testid="total">{{ totals().total | number: '1.2-2' }}</dd>
</dl>
@if (errorMessage(); as message) {
<p role="alert">{{ message }}</p>
}
`,
})
export class CartSummary {
readonly lines = input.required<CartLine[]>();
readonly discount = input(0);
readonly couponError = input<string | null>(null);
protected readonly totals = computed(() => calculateCartTotals(this.lines(), this.discount()));
protected readonly errorMessage = computed(() => {
const reason = this.couponError();
return reason ? (rejectionMessages[reason] ?? 'This coupon cannot be used.') : null;
});
}
Tests 10 passed (10)
Duration 595ms (environment 64%, setup 20%, transform 8%, tests 4%, import 3%, worker 1%)
What to Test Through the DOM, What Through the Function
The rule is simple: calculations and decisions are tested in the function, wiring and visibility in the DOM. The five tests of calculateCartTotals carry all the numeric edge cases: an empty cart, a negative total, floating point. The component tests only ask: is the calculation called with the right inputs, does the result appear in the right place, do conditional rows appear at the right moment, does the error message reach the user.
Repeating the floating point test at the component level would mean testing the same behavior through a slower and more brittle path. Conversely, you can't test the rule "the discount row appears only when there is a coupon" in the function; that's a template decision. This split is the frontend counterpart of the functional core and humble object idea in the testable design post of the Unit Test series: logic in a plain function, the component as thin as possible.
In terms of test speed the difference isn't measurable yet, but it adds up. All 13 tests in this post run in under a second. Once the number of components reaches the hundreds, the difference between a suite that tests every calculation edge case through TestBed and one that tests them in plain functions decides whether watch mode stays usable.
Test-First at the HttpClient Boundary: CouponApi
We write the service that will call the POST /api/cart/{id}/coupon endpoint we built on the backend. Test-first has a very concrete meaning here: before writing the code, you describe in the test the HTTP request that must go out.
describe('CouponApi', () => {
let api: CouponApi;
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
api = TestBed.inject(CouponApi);
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify());
it('posts the code to the cart coupon endpoint', async () => {
const result = firstValueFrom(api.apply(42, 'BOOK10'));
const request = http.expectOne({ method: 'POST', url: '/api/cart/42/coupon' });
expect(request.request.body).toEqual({ code: 'BOOK10' });
request.flush({ id: 42, subtotal: 200, discount: 20, total: 180, couponCode: 'BOOK10' });
expect(await result).toEqual({
kind: 'applied',
cart: { id: 42, subtotal: 200, discount: 20, total: 180, couponCode: 'BOOK10' },
});
});
});
The Angular documentation stresses the provider order: provideHttpClientTesting() overrides parts of provideHttpClient(), so it must come after it. firstValueFrom subscribes to the observable and sends the request; the request waits until we flush it, so we can verify the request itself first. The verify in afterEach stops an unexpected request from slipping through silently.
With an apply that returns EMPTY, the red states exactly the missing behavior:
Error: Expected one matching request for criteria "Match method: POST, URL: /api/cart/42/coupon", found none.
We go green with HttpClient.post. The second test turns the 422 problem response the backend returns for a rejected coupon into a result:
it('turns a 422 problem into a rejection reason', async () => {
const result = firstValueFrom(api.apply(42, 'OLD5'));
http
.expectOne('/api/cart/42/coupon')
.flush({ title: 'Coupon rejected', detail: 'Expired' }, { status: 422, statusText: 'Unprocessable Entity' });
expect(await result).toEqual({ kind: 'rejected', reason: 'Expired' });
});
Red: HttpErrorResponse: Http failure response for /api/cart/42/coupon: 422 Unprocessable Entity. With a catchError that catches the 422 and passes other errors through unchanged, twelve tests are green:
apply(cartId: number, code: string): Observable<ApplyCouponResult> {
return this.http
.post<CartSummaryDto>(`/api/cart/${cartId}/coupon`, { code })
.pipe(
map((cart): ApplyCouponResult => ({ kind: 'applied', cart })),
catchError((error: HttpErrorResponse) =>
error.status === 422
? of<ApplyCouponResult>({ kind: 'rejected', reason: error.error.detail })
: throwError(() => error),
),
);
}
The reason value is the same as what CartSummary's couponError input expects. The name of the backend's CouponRejection enum has become a contract between the two sides. Verifying that the contract stays the same on both sides is the job of contract testing; we named it in the test types post of the Test Strategy series.
Time in a Zoneless World: Vitest Timers Instead of fakeAsync
Someone who wrote Angular with Zone.js reaches for fakeAsync and tick by reflex in a test with a timer. An Angular 22 project is zoneless by default and has no Zone.js dependency. The reflexive test is met like this:
FAIL |bookstore-web| src/app/cart/fake-async-probe.spec.ts [ src/app/cart/fake-async-probe.spec.ts ]
Error: zone-testing.js is needed for the fakeAsync() test helper but could not be found.
Please make sure that your environment includes zone.js/testing
Note: the error doesn't fail a single test but the whole file; the other tests in the same file don't run either. Angular's component testing scenarios documentation also says fakeAsync is no longer recommended and to prefer native async strategies or other fake timers. Vitest's own timers exist for this:
it('waits for a debounce with Vitest timers', async () => {
vi.useFakeTimers();
let fired = false;
setTimeout(() => (fired = true), 300);
await vi.advanceTimersByTimeAsync(300);
expect(fired).toBe(true);
vi.useRealTimers();
});
Tests 13 passed (13)
If you want to add a debounce so that typing a coupon code doesn't send a request to the backend on every keystroke, you write the test with this shape: advance the timer, then expect a single request with HttpTestingController.
In Production
Focus in watch mode. In a large project, running the whole suite on every save slows the loop down. You can temporarily use describe.only to focus on the describe you're working on, but it must not get into a commit; the include option of ng test also narrows down which files run. In a CI environment ng test runs once when the CI=true variable is set.
The limits of jsdom. jsdom doesn't compute layout: element widths, visibility and scroll positions aren't real. You can't write the question "is this button visible on the screen?" test-first with jsdom; those tests belong to browser mode or the E2E (end-to-end) level. Keeping TDD's inner loop in jsdom and visual verification in another layer preserves the rhythm.
Money calculations. In this post we calculated in cents, but the real authority should be the backend. The total the frontend calculates is only for display; if it differs from the CartSummaryDto returned by the server, the server's wins. Writing that rule down as a test too will warn whoever later tries to send the frontend total to the server.
Common Pitfalls
Snapshotting the template and calling it TDD. Recording the component's whole HTML (HyperText Markup Language) with toMatchSnapshot() is always green on the first run; you never see red. A snapshot freezes existing output; it doesn't describe the desired behavior. A test written first says what you expect before the code exists.
Tests that break on every CSS change. Selectors like .summary-row:nth-child(3) span break with every touch from the designer. data-testid, role and accessible text separate tests from visual layout. Component harnesses take this separation one step further; that's for the upcoming Frontend Testing series.
Old fakeAsync habits in a zoneless environment. As we saw above, the error fails the whole file. Adding Zone.js back only for tests means testing in an environment the application doesn't run in. Write with Vitest timers and whenStable.
Leaving the calculation in the component. A ten-line calculation inside computed makes it testable only through the DOM. The moment the calculation starts to grow, move it out to a plain function; component tests get shorter, function tests multiply.
A Small Experiment
★☆☆ Add the item "a line with zero or negative quantity is ignored" to calculateCartTotals test-first. Is this behavior the function's responsibility or that of the code that adds to the cart? Which did the test tell you?
★★☆ Add a coupon code input and an "Apply" button to CartSummary; clicking the button should emit an output event (couponSubmitted). Write the test first: catch the event with componentRef.instance.couponSubmitted.subscribe. Clicking with an empty code must not emit the event.
★★★ Build a flow that calls CouponApi.apply with a 300 ms debounce while the coupon code is typed. Use Vitest fake timers and HttpTestingController together in the test: verify that no request goes out before 300 ms and that exactly one request goes out after.
In the next post we leave writing new code and go back to existing code: catching a bug with a red test first, adding a new rule test-first to an untested legacy class, and the difference between a characterization test and a TDD test.