Testing is essential for maintaining code quality and preventing regressions. Angular provides robust testing utilities that integrate with Jasmine and Karma out of the box, with support for Jest as an alternative.A common question from developers new to testing: “Why bother? I can just check it in the browser.” The answer becomes obvious when your app grows past a few dozen components. Without tests, every change becomes a game of whack-a-mole — fixing a bug in the checkout flow silently breaks the user profile page, and you do not find out until a customer reports it. Tests are the safety net that lets you refactor, upgrade dependencies, and add features with confidence.What You’ll Learn:
Spy objects are fake implementations of your services that let you control what they return and verify how they were called. They are the backbone of isolated unit testing — you replace real dependencies (which would make HTTP calls, access databases, etc.) with spies that return predictable data instantly.
// Create spy object with methods.// The first argument is just a name for error messages.// The array lists the methods you want to spy on.const userServiceSpy = jasmine.createSpyObj('UserService', [ 'getUser', 'updateUser', 'deleteUser']);// Configure return values -- what should the fake service return?userServiceSpy.getUser.and.returnValue(of(mockUser));userServiceSpy.updateUser.and.returnValue(of(updatedUser));userServiceSpy.deleteUser.and.returnValue(of(void 0));// Verify interactions -- was the service called correctly?expect(userServiceSpy.getUser).toHaveBeenCalled();expect(userServiceSpy.getUser).toHaveBeenCalledWith(1);expect(userServiceSpy.getUser).toHaveBeenCalledTimes(1);
Common pitfall: Forgetting to set up return values before the component calls the spy. If your component calls userService.getUser() during ngOnInit, you must configure userServiceSpy.getUser.and.returnValue(...)before calling fixture.detectChanges() — because detectChanges triggers ngOnInit.
# Run tests with coverageng test --code-coverage# Coverage report at coverage/index.html# Configure in karma.conf.js or angular.json{ "test": { "options": { "codeCoverage": true, "codeCoverageExclude": [ "src/app/**/*.spec.ts", "src/app/**/*.mock.ts" ] } }}
Q: You inherit a codebase with zero tests and 200+ components. How do you prioritize what to test first, and what testing strategy do you implement going forward?
Strong Answer: I would not try to retroactively write tests for all 200 components. Instead, I apply the “test on change” strategy: any code you touch gets tests. This is pragmatic and ensures testing effort is proportional to risk — the code you are modifying is the code most likely to break.For prioritization of the initial testing push, I focus on three categories. First, critical user flows: login, checkout, payment, any flow where a bug costs money or users. I write E2E tests for these using Playwright — they give the highest confidence with the least effort for existing code. Second, shared services and utilities: these are used everywhere, so a bug here has blast radius across the entire app. Unit tests for services are fast to write and high-value. Third, complex business logic: any code with conditional branching, calculations, or state machines.Going forward, I implement a testing policy: all new code requires tests, all bug fixes require a regression test (a test that fails before the fix and passes after). I configure code coverage thresholds that only apply to new/changed files, not the entire codebase, so the team does not feel paralyzed by the 0% baseline.For the test distribution, I aim for 70% unit tests (services, pipes, pure functions), 20% component integration tests (TestBed with real child components), and 10% E2E tests (critical flows). I avoid testing implementation details — I test behavior, not internal state.Follow-up: How do you decide between unit testing a component in isolation versus integration testing it with its children?
Answer: If the component is presentational (only inputs/outputs, no services), I test it in isolation — set inputs, check rendered output, click buttons, verify emitted events. If it is a smart/container component that orchestrates children, I test with real children where possible, using stub components only for children with complex dependencies. The reason: testing with real children catches integration bugs (wrong input name, mismatched event type) that isolated tests miss.
Q: Explain fakeAsync and tick. How do they work under the hood, and when would you use them versus waitForAsync?
Strong Answer: fakeAsync creates a synchronous test zone where time is under your control. It intercepts all async operations (setTimeout, setInterval, Promise.then) and queues them instead of executing them. tick(milliseconds) advances the virtual clock by the specified amount, executing any queued operations whose timer has elapsed. flush() runs all pending async operations regardless of their scheduled time.Under the hood, fakeAsync replaces the real browser timing APIs with virtual ones within the test zone. This is why you can write linear, synchronous-looking test code that actually tests asynchronous behavior: set up the state, call tick(1000), assert the result.waitForAsync (formerly async) is different — it runs the test body in a real async zone and waits for all async operations to complete naturally. You call fixture.whenStable() to wait, and the test resolves when all promises and observables settle. The downside: you cannot control timing. If a debounce is 300ms, you actually wait 300ms. For fast tests, fakeAsync with tick(300) is instant.I use fakeAsync for 90% of async tests because it is deterministic and fast. I use waitForAsync when I need to test real async behavior that fakeAsync cannot intercept — for example, actual HTTP calls in integration tests (rare), or when testing zone-related behavior itself. I use the done callback when testing plain Observables outside of Angular’s zone.Follow-up: What is the gotcha with fakeAsync and HTTP requests?
Answer: fakeAsync with HttpTestingController works perfectly because you control when the response arrives (via req.flush). But fakeAsync with REAL HTTP requests does not work — fakeAsync cannot intercept actual XHR/fetch calls. That is why Angular testing always uses HttpClientTestingModule: it replaces the real HTTP backend with a mock that you control synchronously within the fakeAsync zone.
Q: How do you test a component that uses signals, computed values, and effects? What are the differences from testing observable-based components?
Strong Answer: Signal-based components are actually easier to test than observable-based ones. Signals are synchronous — when you call component.count.set(5), the value is immediately available. You do not need fakeAsync, tick, or subscribe. You just set the signal and assert: expect(component.count()).toBe(5). Computed signals also update synchronously: after setting count to 5, component.doubled() immediately returns 10.For testing signal inputs, use fixture.componentRef.setInput(‘name’, ‘value’), which is the modern way to set inputs in tests. This works for both decorator-based and signal-based inputs. Then call fixture.detectChanges() to update the template.Effects are the one area where testing gets tricky. Effects are scheduled to run asynchronously (on the microtask queue). In tests, you need to trigger change detection (fixture.detectChanges()) or use TestBed.flushEffects() to ensure effects have run before asserting their side effects. For example, if an effect syncs to localStorage, you need to flush effects before checking localStorage.The key difference from observable testing: no subscription management, no async pipe concerns, no fakeAsync needed for most cases. You set signals, detect changes, and assert DOM state. It is closer to testing plain JavaScript objects than testing reactive streams.Follow-up: How do you test a component that uses toSignal internally to convert an HTTP observable to a signal?
Answer: You still mock the HTTP service (or use HttpTestingController) the same way. The toSignal call subscribes on component creation, so when TestBed creates the component, it subscribes to the mocked observable. If the mock returns of(data), the signal has the value immediately. If you need to control timing, use a Subject as the mock return value — you can call subject.next(data) at the exact moment you want in the test, then call fixture.detectChanges() to see the template update.