Angular’s testing story has changed dramatically. Karma is gone. Jasmine is optional. The default test runner is now Vitest with browser mode, and it runs your component tests in real Chromium via Playwright. Not a simulated DOM. Not jsdom. A real browser.
This matters more than it sounds. I made the switch from Karma/Jasmine to Vitest during an Angular 17 to 21 migration, and it eliminated an entire category of “works in tests, breaks in the browser” bugs. When you’re building a design system shared across three apps — patient portal, provider dashboard, and admin — you cannot afford false confidence from your test suite. Here’s everything I’ve learned about writing effective tests with this setup.
The Setup
When you generate a new Angular project, Vitest with browser mode is configured out of the box. Tests run in Playwright’s Chromium instance. You get a real rendering engine, real layout, real event handling. The tradeoff is slightly slower startup compared to jsdom, but the confidence gain is worth it.
Your test files look familiar — .spec.ts extension, describe/it blocks. But the way you query the DOM and make assertions is different from what you might be used to.
ARIA-Based Locators
This is the biggest mental shift. Instead of querying by CSS selectors or test IDs, Vitest pushes you toward ARIA-based locators:
import { page } from '@vitest/browser/context';
it('should display the search button', async () => {
const button = page.getByRole('button', { name: 'Search flights' });
await expect.element(button).toBeVisible();
});
it('should have an accessible email input', async () => {
const input = page.getByLabelText('Email address');
await input.fill('[email protected]');
await expect.element(input).toHaveValue('[email protected]');
});
page.getByRole() and page.getByLabelText() are the primary locators. They query the accessibility tree, not the DOM. This is deliberate — Vitest intentionally avoids id and name selectors to push you toward ARIA-accessible patterns. getByTestId exists as a last resort, but if you’re reaching for it often, your components probably aren’t accessible enough.
The side effect is that writing tests becomes a forcing function for accessibility. You can’t test a button that doesn’t have a proper role and accessible name. That’s a feature, not a limitation. In compliance-sensitive environments where every edge case gets scrutinized, ARIA-based locators give you accessibility coverage as a byproduct of testing, instead of treating it as a separate audit.
Locators Are Lazy
A subtle but important detail: locators are lazy. They don’t resolve when you create them. They resolve when you call an action or assertion on them. And they auto-retry with a configurable interval and timeout.
// This doesn't query the DOM yet
const button = page.getByRole('button', { name: 'Submit' });
// THIS queries the DOM, and retries until found or timeout
await expect.element(button).toBeVisible();
This means you can define locators early in your test and they’ll automatically wait for the element to appear. No manual waitFor wrappers needed for most cases.
expect.element() vs expect()
This tripped me up at first. There are two assertion styles and they behave differently:
// Browser-aware, auto-retries until condition is met or timeout
await expect.element(button).toBeVisible();
await expect.element(input).toHaveValue('[email protected]');
// Synchronous, no retry -- fails immediately if condition isn't met
expect(component.title()).toBe('Search');
expect(service.isLoading()).toBe(false);
expect.element() is for DOM assertions. It wraps a locator and retries the assertion until it passes or times out. Use it for anything that touches the rendered UI.
expect() is the standard synchronous assertion. Use it for checking signal values, service state, or anything that doesn’t need to wait for the DOM.
Mixing them up is a common source of flaky tests. If your assertion involves the DOM, use expect.element(). Always.
Mocking HTTP with httpResource
Testing components that use httpResource requires HttpTestingController combined with vi.waitFor. The pattern looks like this:
import { vi } from 'vitest';
import { HttpTestingController } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
it('should display flights from API', async () => {
const fixture = TestBed.createComponent(FlightSearchComponent);
const ctrl = TestBed.inject(HttpTestingController);
// Wait for the HTTP request to be made
const req = await vi.waitFor(
() => ctrl.expectOne('/api/flights'),
);
req.flush([
{ id: 1, origin: 'MVD', destination: 'EZE' },
{ id: 2, origin: 'MVD', destination: 'GRU' },
]);
const rows = page.getByRole('row');
await expect.element(rows.nth(0)).toContainText('MVD');
});
The vi.waitFor call is essential because httpResource triggers its request inside an effect, which runs asynchronously. Without waitFor, the expectOne would fire before the request is made and the test would fail.
The { interval: 0 } Trick
Sometimes you don’t need to poll. You just need to wait for a single microtask to complete. Pass { interval: 0 } to vi.waitFor:
const req = await vi.waitFor(
() => ctrl.expectOne('/api/flights'),
{ interval: 0 }
);
With interval: 0, vi.waitFor awaits one microtask tick before running the callback. This is faster than the default polling interval and sufficient when you know the async operation will resolve in a single cycle.
Fake Timers and the httpResource Double-Flush
When testing debounced inputs or timed behavior, fake timers are essential:
it('should debounce search input', async () => {
vi.useFakeTimers();
const fixture = TestBed.createComponent(FlightSearchComponent);
const ctrl = TestBed.inject(HttpTestingController);
const input = page.getByLabelText('Search');
await input.fill('Montevideo');
// Advance past the debounce timer
await vi.runAllTimersAsync();
const req = ctrl.expectOne('/api/flights?q=Montevideo');
req.flush([{ id: 1, origin: 'MVD', destination: 'EZE' }]);
// Second timer advance -- httpResource resolves a Promise internally
await vi.runAllTimersAsync();
await expect.element(page.getByText('MVD')).toBeVisible();
vi.useRealTimers();
});
Here’s the non-obvious part: httpResource requires TWO vi.runAllTimersAsync() calls in fake-timer mode. The first one (before expectOne) triggers the effect that fires the HTTP request. The second one (after flush) is needed because httpResource internally resolves a Promise when the response arrives, and that Promise resolution needs to be processed by the fake timer system.
Miss the second call and your test will appear to pass but the component state won’t have updated. This one cost me a couple hours the first time I hit it.
Prefer Mocking Debounce Over Fake Timers
Fake timers work, but they introduce complexity and the double-flush footgun above. A simpler approach: extract your timing configuration and mock it.
// debounce-config.ts
export const DEBOUNCE_CONFIG = {
searchMs: 300,
};
// flight-search.component.ts
import { DEBOUNCE_CONFIG } from './debounce-config';
// ... inside the component:
searchTerm = signal('');
debouncedTerm = toSignal(
toObservable(this.searchTerm).pipe(
debounceTime(DEBOUNCE_CONFIG.searchMs)
)
);
Now in your test, just spy on it:
import { DEBOUNCE_CONFIG } from './debounce-config';
beforeEach(() => {
vi.spyOn(DEBOUNCE_CONFIG, 'searchMs', 'get').mockReturnValue(0);
});
No fake timers needed. The debounce fires immediately. Your test is simpler, faster, and avoids the double-flush problem entirely. I reach for this pattern first and only fall back to fake timers when the timing behavior itself is what I’m testing.
Gray-Box Testing with vi.spyOn
Sometimes you need to verify that a component called a service method without testing the full integration. vi.spyOn gives you that:
it('should call the booking service on submit', async () => {
const bookingService = TestBed.inject(BookingService);
const spy = vi.spyOn(bookingService, 'createBooking');
const submitButton = page.getByRole('button', { name: 'Book now' });
await submitButton.click();
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({ flightId: 42 })
);
});
This is gray-box testing — you’re verifying behavior at the boundary between your component and its dependencies. It’s more resilient than checking internal state but more specific than a pure black-box approach.
Testing Routed Components
For components that depend on route parameters or the router, use RouterTestingHarness:
import { RouterTestingHarness } from '@angular/router/testing';
it('should load flight details for the given route', async () => {
const harness = await RouterTestingHarness.create();
const ctrl = TestBed.inject(HttpTestingController);
await harness.navigateByUrl('/flights/42');
const req = await vi.waitFor(
() => ctrl.expectOne('/api/flights/42')
);
req.flush({ id: 42, origin: 'MVD', destination: 'EZE' });
await expect.element(page.getByText('MVD')).toBeVisible();
await expect.element(page.getByText('EZE')).toBeVisible();
});
The harness sets up a real router with your route configuration. No need to manually mock ActivatedRoute or deal with RouterTestingModule. Navigate to the URL and let the router do its thing.
Shallow Testing via Override
When a component has heavy child components you don’t want to render, use TestBed.overrideComponent to swap them out:
@Component({ selector: 'app-heavy-chart', template: '' })
class MockChartComponent {}
beforeEach(() => {
TestBed.overrideComponent(DashboardComponent, {
remove: { imports: [HeavyChartComponent] },
add: { imports: [MockChartComponent] },
});
});
This gives you shallow rendering without sacrificing the real component’s template and bindings. The component under test still goes through its full lifecycle — you’re just cutting off expensive subtrees.
Hot take: shallow testing is even more important when you’re testing across microfrontends. When design system components are consumed by multiple apps, each composing them differently, the design system’s own tests shouldn’t pull in every consumer’s subtree — that path leads to slow and brittle tests. Shallow rendering at the design system level, full integration tests at the app level. That’s the split that works.
Always Verify in afterEach
One last pattern that’s saved me from silent test failures: call ctrl.verify() in afterEach.
afterEach(() => {
const ctrl = TestBed.inject(HttpTestingController);
ctrl.verify();
});
This fails the test if any HTTP request was made but not matched by expectOne or expectNone. Without it, a component might be making unexpected API calls and your tests would never catch it. It’s one line and it’s caught real bugs for me.
Final Thoughts
Vitest’s browser mode is a genuine improvement for Angular testing. Real browser rendering eliminates a class of false positives. ARIA-based locators make accessibility a natural side effect of testing. And the combination of vi.waitFor, expect.element(), and HttpTestingController gives you the tools to test async components reliably.
The learning curve is mostly around the async patterns — especially the httpResource double-flush and the difference between expect.element() and expect(). Once you internalize those, the tests are cleaner and more trustworthy than anything Karma ever gave us. Having migrated a large Angular test suite from Karma/Jasmine to Vitest, I can tell you the upfront cost is real but the payoff is immediate. Tests that used to be flaky just work. Tests that used to lie about passing now fail honestly. That’s the upgrade that matters.