Angular Code Coverage
Introduction
Code coverage is a crucial metric in software testing that measures how much of your code is being tested by your automated tests. In Angular applications, code coverage helps you identify which parts of your application are well-tested and which areas need more attention.
By the end of this tutorial, you'll understand what code coverage is, how to enable it in your Angular projects, how to interpret coverage reports, and how to improve your overall test coverage.
What is Code Coverage?
Code coverage is a measurement of how many lines, statements, branches, and functions in your code are executed when your test suite runs. It's typically expressed as a percentage:
- Statement coverage: Percentage of code statements that have been executed
- Branch coverage: Percentage of code branches (like if/else statements) that have been executed
- Function coverage: Percentage of functions that have been called
- Line coverage: Percentage of executable lines that have been executed
Higher coverage percentages generally indicate more thorough testing, though 100% coverage doesn't necessarily mean your code is bug-free or well-tested.
Setting Up Code Coverage in Angular
Angular CLI projects come with built-in support for code coverage via Karma and Istanbul. Here's how to enable and configure it:
Running Tests with Coverage
To run your tests with coverage reporting, simply add the --code-coverage
flag to the test command:
ng test --code-coverage
This will generate a coverage report in your project's /coverage
directory.
Configuring Coverage in angular.json
You can also configure code coverage settings in your angular.json
file:
"test": {
"options": {
"codeCoverage": true,
"codeCoverageExclude": [
"src/polyfills.ts",
"src/testing/**/*"
]
}
}
This configuration:
- Enables code coverage by default when running tests
- Excludes specific files or directories from coverage calculation
Setting Coverage Thresholds
You can enforce minimum coverage requirements in your karma.conf.js
file:
module.exports = function (config) {
config.set({
// existing config...
coverageReporter: {
dir: require('path').join(__dirname, './coverage'),
subdir: '.',
reporters: [
{ type: 'html' },
{ type: 'text-summary' }
],
check: {
global: {
statements: 80,
branches: 70,
functions: 80,
lines: 80
}
}
}
});
};
With this configuration, your tests will fail if coverage falls below the specified thresholds.
Understanding Coverage Reports
After running tests with coverage enabled, you'll have access to detailed reports. Let's explore how to interpret them:
HTML Report
The HTML report is the most user-friendly way to view coverage. Open the index.html
file in the /coverage
directory with your browser:
The report includes:
- Overview of total coverage percentages
- File-by-file breakdown
- Detailed view showing which lines were executed (green) and which were not (red)
Console Summary
The test runner also outputs a summary to the console:
-----------------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-----------------------|---------|----------|---------|---------|-------------------
All files | 85.71 | 78.26 | 81.25 | 85.71 |
app | 84.62 | 75 | 80 | 84.62 |
app.component.ts | 100 | 100 | 100 | 100 |
user.service.ts | 72.73 | 50 | 66.67 | 72.73 | 25,35,45
-----------------------|---------|----------|---------|---------|-------------------
Real-World Example: Testing a Service
Let's look at a practical example of improving code coverage for an Angular service:
1. The Service
Here's a simple UserService
with methods to fetch, create, and delete users:
// user.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { User } from './user.model';
@Injectable({
providedIn: 'root'
})
export class UserService {
private apiUrl = 'https://api.example.com/users';
constructor(private http: HttpClient) {}
getUsers(): Observable<User[]> {
return this.http.get<User[]>(this.apiUrl)
.pipe(
catchError(error => {
console.error('Error fetching users', error);
return throwError(() => new Error('Failed to fetch users'));
})
);
}
createUser(user: User): Observable<User> {
return this.http.post<User>(this.apiUrl, user)
.pipe(
catchError(error => {
console.error('Error creating user', error);
return throwError(() => new Error('Failed to create user'));
})
);
}
deleteUser(id: number): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`)
.pipe(
catchError(error => {
console.error('Error deleting user', error);
return throwError(() => new Error('Failed to delete user'));
})
);
}
}
2. Initial Test with Low Coverage
An incomplete test might look like this:
// user.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { UserService } from './user.service';
import { User } from './user.model';
describe('UserService', () => {
let service: UserService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [UserService]
});
service = TestBed.inject(UserService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should get users', () => {
const mockUsers: User[] = [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
];
service.getUsers().subscribe(users => {
expect(users).toEqual(mockUsers);
});
const req = httpMock.expectOne('https://api.example.com/users');
expect(req.request.method).toBe('GET');
req.flush(mockUsers);
});
});
This test only covers the getUsers()
method and the success path. The coverage report would show low coverage.
3. Improved Test with Better Coverage
Let's enhance our tests to improve coverage:
// user.service.spec.ts - improved version
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { UserService } from './user.service';
import { User } from './user.model';
describe('UserService', () => {
let service: UserService;
let httpMock: HttpTestingController;
const apiUrl = 'https://api.example.com/users';
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [UserService]
});
service = TestBed.inject(UserService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('getUsers', () => {
it('should return users on success', () => {
const mockUsers: User[] = [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
];
service.getUsers().subscribe(users => {
expect(users).toEqual(mockUsers);
});
const req = httpMock.expectOne(apiUrl);
expect(req.request.method).toBe('GET');
req.flush(mockUsers);
});
it('should handle errors when fetching users fails', () => {
service.getUsers().subscribe({
next: () => fail('Expected an error, not users'),
error: error => {
expect(error.message).toBe('Failed to fetch users');
}
});
const req = httpMock.expectOne(apiUrl);
req.flush('Error fetching users', {
status: 500,
statusText: 'Server Error'
});
});
});
describe('createUser', () => {
it('should create a user successfully', () => {
const mockUser: User = { id: 1, name: 'John' };
service.createUser(mockUser).subscribe(user => {
expect(user).toEqual(mockUser);
});
const req = httpMock.expectOne(apiUrl);
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(mockUser);
req.flush(mockUser);
});
it('should handle errors when creating a user fails', () => {
const mockUser: User = { id: 1, name: 'John' };
service.createUser(mockUser).subscribe({
next: () => fail('Expected an error, not a successful response'),
error: error => {
expect(error.message).toBe('Failed to create user');
}
});
const req = httpMock.expectOne(apiUrl);
req.flush('Error creating user', {
status: 400,
statusText: 'Bad Request'
});
});
});
describe('deleteUser', () => {
it('should delete a user successfully', () => {
const userId = 1;
service.deleteUser(userId).subscribe(() => {
// Should complete successfully
expect(true).toBeTrue();
});
const req = httpMock.expectOne(`${apiUrl}/${userId}`);
expect(req.request.method).toBe('DELETE');
req.flush(null);
});
it('should handle errors when deleting a user fails', () => {
const userId = 1;
service.deleteUser(userId).subscribe({
next: () => fail('Expected an error, not a successful response'),
error: error => {
expect(error.message).toBe('Failed to delete user');
}
});
const req = httpMock.expectOne(`${apiUrl}/${userId}`);
req.flush('Error deleting user', {
status: 404,
statusText: 'Not Found'
});
});
});
});
Now our test suite covers:
- Success and error cases for all methods
- Verification of HTTP methods and URLs
- Handling of request bodies and response data
This comprehensive approach should result in much higher code coverage, potentially 100% for this service.
Best Practices for Angular Code Coverage
Here are some guidelines to help you make the most of code coverage in your Angular projects:
-
Aim for realistic targets: Instead of blindly targeting 100% coverage, focus on covering critical parts of your application thoroughly.
-
Test both success and error paths: As demonstrated in our example, test both expected behaviors and error handling.
-
Focus on logic, not boilerplate: Prioritize testing complex business logic over simple getters/setters or auto-generated code.
-
Use coverage to identify gaps: Use coverage reports to find untested parts of your application, not just to achieve a certain percentage.
-
Integrate with CI/CD: Add coverage checks to your continuous integration process to prevent coverage from decreasing over time.
-
Exclude appropriate files: Some files don't need testing (e.g., environment configurations). Exclude these from coverage calculations.
Beyond Code Coverage: Additional Testing Considerations
While code coverage is valuable, it's not the only measure of test quality:
-
Edge cases: Coverage doesn't tell you if you're testing edge cases and boundary conditions.
-
Meaningful assertions: High coverage with weak assertions doesn't provide good test quality.
-
Integration testing: Unit test coverage doesn't replace the need for integration and end-to-end tests.
-
Mutation testing: Consider using tools like Stryker to assess how effective your tests are at catching bugs.
Summary
Code coverage is an essential tool in Angular testing that helps you measure and improve the thoroughness of your test suite. In this tutorial, you've learned:
- What code coverage is and why it matters
- How to enable and configure code coverage in Angular projects
- How to read and interpret coverage reports
- Practical techniques for improving coverage through comprehensive testing
- Best practices for using coverage effectively
Remember that code coverage is a tool to help you write better tests, not an end goal itself. Use it as part of a broader testing strategy that includes different types of tests and focuses on building reliable, maintainable applications.
Additional Resources
- Official Angular Testing Guide
- Istanbul.js Documentation (the coverage tool used by Angular)
- Karma Configuration
- Stryker Mutator for mutation testing
Exercises
- Run code coverage on an existing Angular project and identify areas with low coverage.
- Add tests to improve coverage for a component in your application.
- Configure minimum coverage thresholds for your project.
- Try adding coverage reporting to your CI/CD pipeline.
- Experiment with excluding specific files or patterns from coverage calculation.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)