Featured resource
2026 Tech Forecast
2026 Tech Forecast

1,500+ tech insiders, business leaders, and Pluralsight Authors share their predictions on what’s shifting fastest and how to stay ahead.

Download the forecast
  • Lab
    • Libraries: If you want this lab, consider one of these libraries.
    • Core Tech
Labs

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 platform
Lab Info
Level
Intermediate
Last updated
Jul 28, 2026
Duration
45m

Contact sales

By clicking submit, you agree to our Privacy Policy and Terms of Use, and consent to receive marketing emails from Pluralsight.
Table of Contents
  1. 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 HttpClientTestingModule and HttpTestingController
    • Replace real Angular services with vi.fn() spies in component tests
    • Test asynchronous Observable behavior using fakeAsync and tick
    • Use ComponentFixture to 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 helpers
    • task-list.component.ts — displays all tasks in a list
    • task-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 test
    

    Vitest 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.Y comments 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 solution folder in your Filetree, or click the Task Solution link at the bottom of each task after you've attempted it.

  2. 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

    TruncatePipe has no injected dependencies. You create it with new TruncatePipe() and call transform() 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.ts to understand what transform() does:

    transform(value: string | null | undefined, limit: number = 50): string
    

    It 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.ts in the task-manager project. The describe block and it shells are already in place. You only need to fill in each marked block.

    With a fresh pipe available in every test, you can start verifying transform() 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 null or empty string coming from the API. The pipe handles invalid input cleanly. One edge case remains: callers may omit the limit argument entirely, so the default of 50 must be exercised as a distinct code path.

    You wrote 5 tests for TruncatePipe without 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.

  3. Challenge

    Testing service business logic

    TaskService does 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

    TaskService needs HttpClient injected in its constructor, but the helper methods never use it. You can pass null in its place:

    service = new TaskService(null as any);
    

    TypeScript is satisfied because of the any cast. 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 first describe block (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 sortByDueDate actually put the earliest date first? The return order looks right. The next task checks a separate contract: sortByDueDate is supposed to sort a copy and leave the original array untouched. That guarantee is worth a dedicated test. sortByDueDate is 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 for TaskService business 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.

  4. Challenge

    Mocking the network

    TaskService.getTasks(), getTask(), and updateTask() all call HttpClient.

    Unit tests should never make real network requests — they would be slow, non-deterministic, and require a live server. Angular provides HttpClientTestingModule and HttpTestingController to intercept HTTP calls and let you control exactly what comes back.


    Concept: HttpTestingController

    HttpClientTestingModule replaces 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.

    HttpTestingController is 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 fire
    

    flush() is what triggers the Observable to emit. If you forget it, the subscribe callback never runs and your assertions are silently skipped.

    After every test, httpMock.verify() in afterEach confirms no unexpected requests were made or left pending.


    Before you begin

    Open src/app/services/task.service.spec.ts and find the second describe block: TaskService – HTTP methods. The mock data, variable declarations, TestBed configuration, and afterEach are already in place.

    --- With the service and controller injected, every it block can now intercept HTTP calls.

    The next task tests the happy path: a successful GET that 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 a PUT to 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.

  5. Challenge

    Testing components with mocked services

    TaskListComponent depends on TaskService. 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 and ComponentFixture

    vi.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 called
    

    You inject the spy as the value for TaskService:

    providers: [{ provide: TaskService, useValue: { getTasks: spy } }]
    

    TestBed.createComponent() gives you a ComponentFixture, your handle on the live component:

    const fixture = TestBed.createComponent(TaskListComponent);
    fixture.detectChanges();            // triggers ngOnInit + first render
    const el = fixture.nativeElement;  // the real DOM element
    

    Important: 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, TestBed setup, and fixture creation are already in place. You will write the spy and the assertions in each it block.

    --- The spy is in place and the component has initialized.

    The next task makes the first assertion: did ngOnInit actually 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 — isOverdue encapsulates business logic that deserves its own assertion independent of the template. You replaced a real Angular service with a vi.fn() spy, rendered the component with TestBed, 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.

  6. Challenge

    Testing async behavior

    TaskDetailComponent calls updateTask() when a user changes a task's status. That method returns an Observable. After it resolves, the component sets saveSuccess or saveError and the template shows a feedback message. Testing this requires controlling when the Observable resolves.


    Concept: Synchronous Observables and detectChanges

    The updateTask service method returns an Observable. In tests you replace it with a spy that returns of(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 immediately
    

    For 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, the ActivatedRoute stub, and the TestBed setup are already in place. You will write the spies and the assertions in each it block. 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 false until the user does something.

    The initial state is confirmed clean.

    The next three tasks all follow the same fakeAsync flow — 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 saveSuccess to true is only useful if the DOM reflects it. The success path — component state and DOM — is fully covered.

    The next task runs the identical fakeAsync flow but replaces the spy with one that throws, testing the error branch. The previous tasks called component.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
    
  7. 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 | Synchronous of() spies + fresh createComponent for 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 any to 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 with nativeElement.querySelector.

    Synchronous of() spies + fresh component: replace the service with a spy returning of(value), which fires synchronously. For conditional DOM elements, create a fresh TestBed.createComponent() instance, set state before the first detectChanges(), then query the DOM.

    DOM event simulation: call .click() on a nativeElement button 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 RouterTestingModule to 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, and effect follow 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

Amar Sonwani is a software architect with more than twelve years of experience. He has worked extensively in the financial industry and has expertise in building scalable applications.

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.

Get started with Pluralsight