TutorialsLast Updated Jul 24, 20265 min read

Continuous integration for Angular applications

Olususi Oluyemi

Fullstack Developer and Tech Author

Automated testing is the foundation of a continuous integration practice. It runs on every commit or pull request, and it catches regressions before code reaches production.

This tutorial covers how to automate testing for an Angular application. Angular is a TypeScript framework, open-sourced by Google, used to build Single Page Applications (SPA) of any size or complexity.

We’ll build a small Angular app that fetches a list of dummy users from JSONPlaceholder, a free fake REST API often used for testing and prototyping.

Prerequisites

For this tutorial, we’ll need:

This tutorial was tested with these versions:

  • Angular CLI: 21.2.9
  • Node: 22.22.2
  • Package Manager: npm 10.9.7
  • OS: darwin arm64

Getting started

Scaffold a new Angular workspace:

ng new circleci-angular-demo --routing=false --style=css --test-runner=karma --zoneless=false

The flags answer the interactive prompts up front: skip the router, use plain CSS for stylesheets, scaffold with Karma as the test runner (Angular 21’s default is Vitest), and keep zone-based change detection (Angular 21 defaults to zoneless). The CLI will also ask whether to generate AI assistant configuration files; we don’t need any for this tutorial.

When scaffolding finishes, switch into the new directory and start the dev server:

cd circleci-angular-demo
ng serve

The app is served at http://localhost:4200.

Angular homepage

Creating the user service

The application fetches users from a third-party API. Standard Angular practice puts that kind of communication in a service so it can be reused across components. Create one with the CLI:

ng g service service/user

This generates src/app/service/user.ts (exporting class User) and the matching test file user.spec.ts in src/app/service. Angular’s 2025 style guide drops the type suffix from class names, so the service is named User rather than UserService. To avoid clashing with the data interface we’ll create next, we’ll keep the service class as User and call the interface UserData.

Replace the contents of src/app/service/user.ts with:

import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { UserData } from '../user';

@Injectable({
  providedIn: 'root',
})
export class User {
  private httpClient = inject(HttpClient);
  apiURL = 'https://jsonplaceholder.typicode.com/users';

  getUsers() {
    return this.httpClient.get<UserData[]>(this.apiURL);
  }
}

Two things to note:

  • HttpClient is Angular’s HTTP client. The new inject() function reads the dependency without a constructor parameter, which is the recommended pattern in modern standalone-component apps.
  • Injectable({ providedIn: 'root' }) registers the service with the root injector so it’s available anywhere in the app.

Before HttpClient can issue requests, register it as a provider. In a standalone-bootstrapped app, providers live in src/app/app.config.ts. Open the file and add provideHttpClient:

import {
  ApplicationConfig,
  provideBrowserGlobalErrorListeners,
  provideZoneChangeDetection,
} from '@angular/core';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideHttpClient(withInterceptorsFromDi()),
  ],
};

provideHttpClient(withInterceptorsFromDi()) is the standalone-API replacement for the older HttpClientModule, which has been deprecated since Angular 18.

Creating a user interface

The service references a UserData interface from ../user. Create the file at src/app/user.ts:

export interface UserData {
  id: number;
  name: string;
  email: string;
  phone: string;
  website: string;
  address: {
    street: string;
    suite: string;
    city: string;
    zipcode: string;
  };
}

This interface describes the shape of each user object returned by the API.

Modifying the app component

Inject the service into the root component and call getUsers() once the component initializes. Replace src/app/app.ts with:

import { Component, OnInit, inject } from '@angular/core';
import { UserData } from './user';
import { User } from './service/user';

@Component({
  selector: 'app-root',
  imports: [],
  templateUrl: './app.html',
  styleUrl: './app.css',
})
export class App implements OnInit {
  private userService = inject(User);

  title = 'List Of dummy users';
  users: UserData[] = [];

  ngOnInit(): void {
    this.userService.getUsers().subscribe((res) => {
      this.users = res;
    });
  }
}

Displaying the list of users

Replace src/app/app.html with:

<div class="page-content">
  <div class="container content-wrapper">
    <div class="page-title">
      <h2>{{ title }}</h2>
    </div>
    <div class="row">
      @for (user of users; track user.id) {
        <div class="col-md-4">
          <div class="card">
            <div class="card-body">
              <h5 class="card-title">{{ user.name }}</h5>
              <div class="card-text">
                <span>{{ user.address.city }}, {{ user.address.street }}</span>
              </div>
              <div>
                <p>{{ user.phone }}</p>
                <p>{{ user.email }}</p>
                <p>{{ user.website }}</p>
              </div>
            </div>
          </div>
        </div>
      }
    </div>
  </div>
</div>

The @for block iterates the users array and renders a Bootstrap card for each entry. @for is Angular’s built-in template control flow (available since Angular 17) and replaces the older *ngFor directive without needing a CommonModule import.

Adding style to the application

We’ll use Bootstrap for the card styling. Install it via npm:

npm install bootstrap

Open angular.json and add bootstrap.css to the styles array under the build options:

"styles": [
      "./node_modules/bootstrap/dist/css/bootstrap.css",
      "src/styles.css"
],

Open src/styles.css and add some grid styling on top:

.page-content {
  margin-top: 100px;
}
.content-wrapper {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(305px, 1fr));
  grid-gap: 15px;
}

Stop the dev server (CTRL+C) and start it again with ng serve so the new stylesheet takes effect.

The list of users now renders with the Bootstrap card layout.

List of users

The app works locally. Next, we’ll write tests for the component and the service.

Testing the user service and app component

The component spec verifies the root component creates and exposes the expected title. Replace src/app/app.spec.ts with:

import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { App } from './app';

describe('App', () => {
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [App],
      providers: [provideHttpClient(), provideHttpClientTesting()],
    }).compileComponents();
  });

  it('should create the app', () => {
    const fixture = TestBed.createComponent(App);
    const app = fixture.componentInstance;
    expect(app).toBeTruthy();
  });

  it("should have as title 'List Of dummy users'", () => {
    const fixture = TestBed.createComponent(App);
    const app = fixture.componentInstance;
    expect(app.title).toEqual('List Of dummy users');
  });
});

provideHttpClientTesting() replaces the live HTTP backend with a mock so the component spec doesn’t issue real network calls during creation.

The service spec checks that getUsers() issues a GET to the right URL and returns the response. We’ll mock the HTTP layer with HttpTestingController so the test doesn’t depend on the live JSONPlaceholder API. Replace src/app/service/user.spec.ts with:

import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import {
  HttpTestingController,
  provideHttpClientTesting,
} from '@angular/common/http/testing';
import { User } from './user';
import { UserData } from '../user';

describe('User', () => {
  let service: User;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [provideHttpClient(), provideHttpClientTesting()],
    });
    service = TestBed.inject(User);
    httpMock = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    httpMock.verify();
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });

  it('should retrieve the list of users', () => {
    const dummyUsers: UserData[] = [
      {
        id: 1,
        name: 'Oluyemi',
        email: 'yem@me.com',
        phone: '43434343',
        website: 'me.com',
        address: {
          street: 'sample street',
          suite: '29',
          city: 'Abuja',
          zipcode: '23401',
        },
      },
      {
        id: 2,
        name: 'Temi',
        email: 'tem@me.com',
        phone: '55242',
        website: 'tems.com',
        address: {
          street: 'Tems street',
          suite: '45',
          city: 'Lagos',
          zipcode: '23401',
        },
      },
    ];

    service.getUsers().subscribe((users) => {
      expect(users.length).toBe(2);
      expect(users).toEqual(dummyUsers);
    });

    const req = httpMock.expectOne('https://jsonplaceholder.typicode.com/users');
    expect(req.request.method).toBe('GET');
    req.flush(dummyUsers);
  });
});

httpMock.expectOne(...) confirms the service issues exactly one GET to the JSONPlaceholder URL. req.flush(dummyUsers) returns the mocked payload to the subscriber, so the assertions inside subscribe actually run. httpMock.verify() in afterEach fails the test if any unmatched HTTP requests remain.

Running the test locally

Run the tests:

npm run test

CircleCI runs in a headless environment, so we’ll switch to headless Chrome and disable watch mode. Update the scripts object in package.json:

"scripts": {
    "ng": "ng",
    "start": "ng serve",
    "build": "ng build",
    "watch": "ng build --watch --configuration development",
    "test": "ng test --no-watch --no-progress --browsers=ChromeHeadless"
  },

Stop the running tests with CTRL+C, then run npm run test again. The output:

> circleci-angular-demo@0.0.0 test
> ng test --no-watch --no-progress --browsers=ChromeHeadless

Application bundle generation complete. [3.104 seconds]

INFO [karma-server]: Karma v6.4.4 server started at http://localhost:9876/
INFO [launcher]: Launching browsers ChromeHeadless with concurrency unlimited
INFO [launcher]: Starting browser ChromeHeadless
INFO [Chrome Headless 147.0.0.0 (Linux 0.0.0)]: Connected on socket
Chrome Headless 147.0.0.0 (Linux 0.0.0): Executed 4 of 4 SUCCESS (0.1 secs / 0.066 secs)
TOTAL: 4 SUCCESS

All four specs pass.

Automating the test

With the tests passing locally, the next step is to run them on CircleCI on every commit. Create a .circleci directory at the project root and add a config.yml file:

version: 2.1
orbs:
  browser-tools: circleci/browser-tools@2.4.2
jobs:
  build:
    working_directory: ~/ng-project
    docker:
      - image: cimg/node:22.22-browsers
    steps:
      - browser-tools/install_chrome
      - browser-tools/install_chromedriver
      - run:
          name: Check install
          command: |
            google-chrome --version
            chromedriver --version
      - checkout
      - restore_cache:
          key: ng-project-{{ .Branch }}-{{ checksum "package-lock.json" }}
      - run: npm install
      - save_cache:
          key: ng-project-{{ .Branch }}-{{ checksum "package-lock.json" }}
          paths:
            - "node_modules"
      - run: npm run test
workflows:
  build-test:
    jobs:
      - build

The browser-tools orb installs Chrome and ChromeDriver. cimg/node:22.22-browsers is CircleCI’s Node 22 LTS convenience image with Java and Selenium pre-installed for browser testing. Note that the orb v2.x commands are install_chrome and install_chromedriver (snake_case); v1.x used kebab-case names.

Push the project to GitHub. Review Pushing your project to GitHub for instructions.

Sign in to CircleCI. From Organization Home, select Create Project, find the repo in the list, and follow the prompts. CircleCI detects the existing .circleci/config.yml and uses it; the first build kicks off automatically, and subsequent commits trigger new builds.

Successful build

Conclusion

This tutorial built an Angular application from scratch, wrote unit tests for the root component and a service that fetches data from a third-party API, and ran the tests automatically on CircleCI. The complete source code is at CIRCLECI-GWP/circleci-angular-demo.


Oluyemi is a tech enthusiast with a background in Telecommunication Engineering. With a keen interest in solving day-to-day problems encountered by users, he ventured into programming and has since directed his problem solving skills at building software for both web and mobile. A full stack software engineer with a passion for sharing knowledge, Oluyemi has published a good number of technical articles and blog posts on several blogs around the world. Being tech savvy, his hobbies include trying out new programming languages and frameworks.