- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech
Implement Unit Testing in Angular
In this Code Lab, learners add focused unit tests to an Angular application to verify behavior in services, pipes, and components. They write isolated tests for class logic, use TestBed for component tests that include templates, replace dependencies with mocks and spies, and validate DOM and asynchronous behavior. The lab focuses on practical implementation of maintainable unit testing patterns in Angular applications.
Lab Info
Table of Contents
-
Challenge
Introduction
In this lab you will add a complete unit test suite to a working Angular 22 Task Manager application. The app compiles and runs. Your job is to write the tests that verify it behaves correctly.
You will learn six testing patterns, each one introduced by a new step and each building on the last.
Learning objectives
By the end of this lab you will be able to:
- Test pure Angular classes (pipes, utility methods) without TestBed
- Mock HTTP calls using
HttpClientTestingModuleandHttpTestingController - Replace real Angular services with
vi.fn()spies in component tests - Test asynchronous Observable behavior using
fakeAsyncandtick - Use
ComponentFixtureto assert DOM output in unit tests
--- ### The application
The Task Manager app has four testable pieces:
truncate.pipe.ts— trims long task titles with...task.service.ts— fetches and updates tasks via HTTP and also has sort and filter helperstask-list.component.ts— displays all tasks in a listtask-detail.component.ts— lets users change a task's status
--- ### How to run tests
In the Terminal, use the following commands to navigate to the application folder and run the tests:
cd task-manager npm testVitest starts and re-runs every time you save a spec file. You will see red (failing) tests. That is expected. Your goal is to make every test green by the last step.
You reviewed the project layout and started the test runner. Every spec file has
// Task X.Ycomments that mark exactly where you need to add code. You will work through them step by step.info> If you get stuck on a task, you can view the solution in the
solutionfolder in your Filetree, or click the Task Solution link at the bottom of each task after you've attempted it. -
Challenge
Testing in isolation
Not all Angular code needs
TestBed.If a class takes inputs and returns outputs with no dependencies, you can test it like a plain JavaScript function. This is the fastest and simplest kind of unit test you can write.
Concept: Direct instantiation
TruncatePipehas no injected dependencies. You create it withnew TruncatePipe()and calltransform()directly:const pipe = new TruncatePipe(); const result = pipe.transform('Hello world', 5); expect(result).toBe('Hello...');No Angular. No async setup. No
TestBed. Just create, call, assert.Open
src/app/pipes/truncate.pipe.tsto understand whattransform()does:transform(value: string | null | undefined, limit: number = 50): stringIt takes a string and a character limit, truncates if the string is too long, and returns
''for null or empty input.
Before you begin
Open
src/app/pipes/truncate.pipe.spec.tsin the task-manager project. Thedescribeblock anditshells are already in place. You only need to fill in each marked block.With a fresh
pipeavailable in every test, you can start verifyingtransform()output.Task 2.2 covers the primary behavior: strings that exceed the limit get truncated and
...is appended. You have confirmed what happens when the input is too long.Task 2.3 checks the opposite boundary — strings that fit within the limit should pass through exactly unchanged. Normal strings are handled on both sides of the limit.
Task 2.4 covers the edge case the component is most likely to see in production: a
nullor empty string coming from the API. The pipe handles invalid input cleanly. One edge case remains: callers may omit thelimitargument entirely, so the default of 50 must be exercised as a distinct code path.You wrote 5 tests for
TruncatePipewithout touching TestBed or Angular at all. Each test is three lines: create, call, assert. This is the pattern for any pipe or stateless utility class — when there are no dependencies, the test can be as simple as the code itself.In the next step you will apply the same pattern to service methods.
-
Challenge
Testing service business logic
TaskServicedoes two very different things: pure helper methods that transform data in memory (sortByDueDate,filterByStatus), and HTTP methods that call an API. This step covers only the helper methods. Testing them requires no HTTP mocking and no TestBed.Concept: Separating logic tests from HTTP tests
Testing these two categories independently lets you:
- Keep logic tests fast: no Angular bootstrap overhead
- Test edge cases (empty arrays, boundary dates) without network setup
- Catch logic bugs before they interact with the HTTP layer
TaskServiceneedsHttpClientinjected in its constructor, but the helper methods never use it. You can passnullin its place:service = new TaskService(null as any);TypeScript is satisfied because of the
anycast. The HTTP methods would throw if called, but you will not call them here.
Before you begin
Open
src/app/services/task.service.spec.ts. Work inside the firstdescribeblock (TaskService – pure methods). Mock task data is already defined at the top of the file. With the service available, you can call its methods directly.The next task tests the sort output — does calling
sortByDueDateactually put the earliest date first? The return order looks right. The next task checks a separate contract:sortByDueDateis supposed to sort a copy and leave the original array untouched. That guarantee is worth a dedicated test.sortByDueDateis fully covered. The next task moves to the second helper method —filterByStatus— starting with the happy path: a filter that returns exactly the tasks you expect.The happy path is covered. The next task checks the edge case: when nothing matches, the method should return a predictable empty array — not
undefined, not an error. You wrote 4 tests forTaskServicebusiness logic without HTTP mocking or TestBed. The key decision: if a method does not touch the network, it does not need Angular infrastructure around it.In the next step you will test the HTTP methods — the ones that do touch the network.
-
Challenge
Mocking the network
TaskService.getTasks(),getTask(), andupdateTask()all callHttpClient.Unit tests should never make real network requests — they would be slow, non-deterministic, and require a live server. Angular provides
HttpClientTestingModuleandHttpTestingControllerto intercept HTTP calls and let you control exactly what comes back.
Concept:
HttpTestingControllerHttpClientTestingModulereplaces Angular's real HTTP backend with a fake one. Instead of sending requests over the network, it queues them in memory where you can inspect and respond to them.HttpTestingControlleris the object you use to do that inspection. It lets you:- Assert that a request was made to a specific URL
- Check the HTTP method (
GET,PUT, etc.) - Flush a mock response — which is the moment the Observable emits
The pattern for every HTTP test follows the same four moves:
1. service.method().subscribe(callback) → request is intercepted, not sent 2. const req = httpMock.expectOne(url) → find and capture the request 3. req.flush(mockData) → simulate the server responding 4. callback runs with the flushed data → assertions in the callback fireflush()is what triggers the Observable to emit. If you forget it, thesubscribecallback never runs and your assertions are silently skipped.After every test,
httpMock.verify()inafterEachconfirms no unexpected requests were made or left pending.
Before you begin
Open
src/app/services/task.service.spec.tsand find the seconddescribeblock:TaskService – HTTP methods. The mock data, variable declarations,TestBedconfiguration, andafterEachare already in place.--- With the service and controller injected, every
itblock can now intercept HTTP calls.The next task tests the happy path: a successful
GETthat returns the full task list. The success path is confirmed.The next task tests the other branch: does the service correctly propagate a server error back to the caller when a 500 comes back? Error propagation is confirmed. The next task shifts from reading to writing — does
updateTask()send aPUTto the correct per-task URL? You intercepted HTTP calls, controlled what the server returned, and tested both the success and error paths — without ever touching a real network.The subscribe → expectOne → flush → assert rhythm is the complete pattern for testing any Angular HTTP service.
-
Challenge
Testing components with mocked services
TaskListComponentdepends onTaskService. In a unit test you replace the real service with a spy object so the component can be tested in isolation: no HTTP, no real data, no side effects.
Concept:
vi.fn()spies andComponentFixturevi.fn()creates a spy function that records calls and lets you control return values:const spy = vi.fn().mockReturnValue(of(mockTasks)); spy(); // returns Observable<Task[]> expect(spy).toHaveBeenCalledTimes(1); // assert how many times it was calledYou inject the spy as the value for
TaskService:providers: [{ provide: TaskService, useValue: { getTasks: spy } }]TestBed.createComponent()gives you aComponentFixture, your handle on the live component:const fixture = TestBed.createComponent(TaskListComponent); fixture.detectChanges(); // triggers ngOnInit + first render const el = fixture.nativeElement; // the real DOM elementImportant: Angular does not update the DOM automatically in tests. Call
fixture.detectChanges()after any state change to see the updated output.
Before you begin
Open
src/app/components/task-list/task-list.component.spec.ts. The mock data, variable declarations,TestBedsetup, and fixture creation are already in place. You will write the spy and the assertions in eachitblock.--- The spy is in place and the component has initialized.
The next task makes the first assertion: did
ngOnInitactually call the service, or is the data wiring missing? The service call is confirmed.The next task follows the data one step further — do the tasks the service returned actually end up as visible elements in the DOM? The normal render is verified.
The next task tests a different initial condition: when the service returns an empty list, the component should show a placeholder rather than a blank page. DOM rendering is covered for both states.
The next task steps back from the DOM and tests a component method directly —
isOverdueencapsulates business logic that deserves its own assertion independent of the template. You replaced a real Angular service with avi.fn()spy, rendered the component withTestBed, and asserted both service call counts and DOM output. This pattern applies to any component with injected dependencies.The next step adds one more concept: testing what the component shows after an async operation resolves.
-
Challenge
Testing async behavior
TaskDetailComponentcallsupdateTask()when a user changes a task's status. That method returns an Observable. After it resolves, the component setssaveSuccessorsaveErrorand the template shows a feedback message. Testing this requires controlling when the Observable resolves.
Concept: Synchronous Observables and
detectChangesThe
updateTaskservice method returns an Observable. In tests you replace it with a spy that returnsof(result)— a synchronous Observable that fires its callback immediately when subscribed.This means assertions work in the same call stack as
updateStatus():component.updateStatus('completed'); // spy fires synchronously → saveSuccess = true expect(component.saveSuccess).toBe(true); // passes immediatelyFor DOM assertions, one extra step is required. Angular batches template updates and only flushes them when you call
fixture.detectChanges():component.updateStatus('completed'); fixture.detectChanges(); // pushes saveSuccess into the template const msg = fixture.nativeElement.querySelector('.success-message'); expect(msg).toBeTruthy();Every DOM test in this step follows those same three lines: call → detectChanges → assert.
Before you begin
Open
src/app/components/task-detail/task-detail.component.spec.ts. The spy type declarations, theActivatedRoutestub, and theTestBedsetup are already in place. You will write the spies and the assertions in eachitblock. The spies are in place and the component has rendered.Before testing what happens after an update, the next task captures a clean baseline — both flags should be
falseuntil the user does something.The initial state is confirmed clean.
The next three tasks all follow the same
fakeAsyncflow — the only difference is the spy return value and the outcome being asserted.The next task tests the success path first. The component flag is set correctly.
The next task checks that the flag actually drives the template — setting
saveSuccesstotrueis only useful if the DOM reflects it. The success path — component state and DOM — is fully covered.The next task runs the identical
fakeAsyncflow but replaces the spy with one that throws, testing the error branch. The previous tasks calledcomponent.updateStatus()directly. That verifies the method logic, but leaves one question open: is the button in the template actually wired to the method? A(click)binding in the HTML is a separate concern from the TypeScript method.The next task tests the binding itself. You tested both success and failure paths for a component that consumes an Observable service, then closed the loop by verifying the template's
(click)binding is wired correctly. The key insight:of()is synchronous, so assertions work immediately after calling the method —fixture.detectChanges()is the only extra step needed for DOM assertions.With this step complete you have tested every layer of the Task Manager app.
Run the following command again to verify that all tests pass.
npm test -
Challenge
Conclusion and next steps
You built a complete test suite
You started with zero test coverage and wrote tests for every layer of the Task Manager app using five distinct patterns.
| Step | What you tested | Pattern | Tests | |------|----------------|---------|-------| | 2 | TruncatePipe | Direct
new ClassName()| 5 | | 3 | TaskService pure methods | Direct instantiation | 4 | | 4 | TaskService HTTP methods |HttpClientTestingModule+flush()| 3 | | 5 | TaskListComponent |TestBed+vi.fn()+ DOM queries | 5 | | 6 | TaskDetailComponent | Synchronousof()spies + freshcreateComponentfor DOM | 6 | | Total | | | 23 |
Patterns to take with you
Direct instantiation: for any class with no dependencies, skip TestBed entirely and use
new ClassName(). Fastest tests you can write.Null injection: pass
null as anyto satisfy an unused dependency. Lets you test service methods in isolation without a DI container.HttpTestingController: intercept HTTP calls, control what the server returns, verify no unexpected requests with
afterEach(() => httpMock.verify()).vi.fn() spies + ComponentFixture: replace real services with spy objects, render with
TestBed, assert DOM output withnativeElement.querySelector.Synchronous
of()spies + fresh component: replace the service with a spy returningof(value), which fires synchronously. For conditional DOM elements, create a freshTestBed.createComponent()instance, set state before the firstdetectChanges(), then query the DOM.DOM event simulation: call
.click()on anativeElementbutton to fire the(click)binding and verify the component method is actually wired up in the template, not just callable in TypeScript.
Where to go next
Testing Angular Router: use
RouterTestingModuleto test navigation and URL-driven component behavior.Testing Reactive Forms: test validation, disabled states, and form submission without clicking through a browser.
Testing Angular Signals:
signal,computed, andeffectfollow the same patterns you used here, but update synchronously, making assertions even simpler.End-to-end testing with Playwright: once unit tests cover the logic, Playwright covers full user flows in a real browser. Unit tests catch regressions fast; E2E tests prove everything works together.
Well done
You went from no tests to a complete coverage suite, learning six reusable patterns along the way. These patterns apply to any Angular 22 application. Take them with you.
About the author
Real skill practice before real-world application
Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.
Learn by doing
Engage hands-on with the tools and technologies you’re learning. You pick the skill, we provide the credentials and environment.
Follow your guide
All labs have detailed instructions and objectives, guiding you through the learning process and ensuring you understand every step.
Turn time into mastery
On average, you retain 75% more of your learning if you take time to practice. Hands-on labs set you up for success to make those skills stick.