Angular Unit Testing Essentials

Best Practices, Tips, and Tools for Ensuring Quality and User Satisfaction

Patric
17 min readMay 8, 2023

--

Testing is a critical part of software development, and it’s especially important for Angular apps. Proper testing ensures that your app functions correctly and delivers the desired user experience.

In this article, we’ll share some best practices for unit testing Angular apps. We’ll cover some key tips and techniques to help you write effective tests and maintainable code, and deliver high-quality software. Whether you’re a seasoned Angular developer or just getting started, these best practices will help you write better code and build better apps.

Here is a brief overview of the examples we will go through:

  1. Use Test-Driven Development (TDD): TDD helps you write tests before the code, ensuring that your code meets the required specifications and is testable.
  2. Use Test Data Builders: Test data builders can help you create test data more efficiently and make your tests more readable. They allow you to define test data in a structured way, making it easier to understand what data is being used in each test case.
  3. Isolate Components: Always isolate components when writing tests to ensure that they function independently of other components in the application.
  4. Use Mocks and Spies: Use mocks and spies to simulate the behavior of external dependencies and ensure that your tests remain focused on the component being tested.
  5. Use the Right Matchers: Use matchers to write tests that are easy to read and maintain. Jasmine provides several matchers that you can use, such as toBeTruthy, toBeFalsy, toBeGreaterThan, etc.
  6. Use Code Coverage Tools: Code coverage tools help you identify areas of your code that are not being tested, ensuring that you have comprehensive test coverage.
  7. Use Debugging Tools: Debugging tools like the Chrome DevTools or Augury can help you identify issues in your code and test cases.
  8. Test User Interactions: Test user interactions with the application to ensure that the application provides the desired user experience.
  9. Use Dependency Injection: Use dependency injection in your tests to create and inject test doubles and other dependencies, making your tests more flexible and easier to maintain.
  10. Automate Tests: Automate tests using tools like Jenkins, CircleCI, or TravisCI to ensure that your tests are run automatically and frequently, thereby reducing the risk of bugs going unnoticed.

Use Test-Driven Development (TDD)

Here’s an example of how you could use TDD to develop a new feature in an existing Angular project:

Step 1: Create a Test File

Create a new test file for the component you want to add or modify. This file should be named component.spec.ts and should be located in the same directory as the component file.

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MyComponent } from './my.component';

describe('MyComponent', () => {
let component: MyComponent;
let fixture: ComponentFixture<MyComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [MyComponent]
})
.compileComponents();

fixture = TestBed.createComponent(MyComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});
});

This test file sets up a basic test suite for MyComponent and includes a simple test to ensure that the component is created successfully.

Step 2: Run the Test

Run the test using the Angular CLI command: ng test

The test should fail initially because the MyComponent has not yet been implemented.

Step 3: Implement the Feature

Implement the feature using TDD. Start by writing a failing test case for the new feature.

it('should display the name', () => {
component.name = 'John';
fixture.detectChanges();
const nameElement = fixture.debugElement.nativeElement.querySelector('.name');
expect(nameElement.textContent).toContain('John');
});

This test case assumes that the MyComponent has a property named name and that it is displayed in the component's template using a CSS class named .name. This test case expects that the component will display the name property in the template.

Run the test using the Angular CLI command: ng test. The test should fail because the feature has not yet been implemented.

Now, update the MyComponent to implement the feature.

import { Component, Input } from '@angular/core';

@Component({
selector: 'app-my',
template: '<div class="name">{{ name }}</div>'
})
export class MyComponent {
@Input() name = '';
}

The MyComponent now has a template that includes a div element with the name property. The name property is displayed using Angular's interpolation syntax {{ }}.

Step 4: Run the Test

Again Run the test again using the Angular CLI command: ng test

The test should now pass because the MyComponent has been updated to implement the new feature.

Step 5: Refactor

Refactor the code if necessary to ensure that it is clean, modular, and maintainable. Repeat the TDD cycle by adding more test cases and implementing new features until the component meets all of its requirements.

By following this process, you can ensure that your code meets the required specifications and is testable. TDD can help you write more robust and reliable code, and it can also help you catch bugs and defects early in the development process.

Use Test Data Builders

Here is an example of how to use test data builders in a unit test for an Angular component.

import { Component, Input, Output, EventEmitter } from '@angular/core'
import { Item } from './item'

@Component({
selector: 'app-item-list',
template: `
<ul>
<li *ngFor="let item of sortedItems" (click)="selectItem(item)">
{{ item.name }}
</li>
</ul>
`,
})
export class ItemListComponent {
@Input() items: Item[] = []
@Output() itemSelected = new EventEmitter<Item>()

selectItem(item: Item) {
this.itemSelected.emit(item)
}

get sortedItems(): Item[] {
return this.items.sort((a, b) => a.name.localeCompare(b.name))
}
}
// item.ts
export class Item {
id: number
name: string

constructor(id: number, name: string) {
this.id = id
this.name = name
}
}

Suppose you have a component that displays a list of items, and you want to test that the component correctly sorts the items by name. Here is an example of how you could use a test data builder to create test data for this scenario:

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ItemListComponent } from './item-list.component';
import { Item } from './item';

describe('ItemListComponent', () => {
let component: ItemListComponent;
let fixture: ComponentFixture<ItemListComponent>;
let items: Item[];

beforeEach(async () => {
// Define test data using a test data builder
items = [
{ id: 1, name: 'Burger' },
{ id: 2, name: 'Pizza' },
{ id: 3, name: 'Salad' },
{ id: 4, name: 'Chips' },
];

await TestBed.configureTestingModule({
declarations: [ItemListComponent],
}).compileComponents();
});

beforeEach(() => {
fixture = TestBed.createComponent(ItemListComponent);
component = fixture.componentInstance;
// Assign test data to the component's input property
component.items = items;
fixture.detectChanges();
});

it('should sort items by name', () => {
// Assert that the component correctly sorts the items by name
expect(component.sortedItems).toEqual([
{ id: 1, name: 'Burger' },
{ id: 1, name: 'Chips' },
{ id: 2, name: 'Pizza' },
{ id: 3, name: 'Salad' },
]);
});
});

In this example, we define test data using a simple array of Item objects. We then assign this test data to the component's input property in the beforeEach hook. Finally, in the test case, we assert that the component's sortedItems property correctly sorts the test data by name.

Using a test data builder, you could define the test data in a more structured and reusable way, making it easier to read and maintain:

import { Item } from './item';

export class ItemBuilder {
private item: Item = { id: 0, name: '' };

withId(id: number): ItemBuilder {
this.item.id = id;
return this;
}

withName(name: string): ItemBuilder {
this.item.name = name;
return this;
}

build(): Item {
return { ...this.item };
}
}

With this test data builder, you could define the test data like this:

items = [
new ItemBuilder().withId(1).withName('Burger').build(),
new ItemBuilder().withId(2).withName('Pizza').build(),
new ItemBuilder().withId(3).withName('Salad').build(),
new ItemBuilder().withId(4).withName('Chips').build(),
];

This makes it clearer what data is being used in each test case and makes it easier to create new test cases with similar data.

Isolate Components

Isolating components in testing means removing any external dependencies that can affect the behavior of the component being tested. This ensures that the test is only focused on the component being tested and not influenced by other components in the application.

To isolate a component, you can use Angular’s testing utilities to create a test bed environment. The test bed allows you to create a module that contains only the component being tested and any necessary dependencies that are required for the component to function.

Here’s an example of how to isolate a component in a test using the TestBed utility:

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MyComponent } from './my.component';
import { MyService } from './my.service';

describe('MyComponent', () => {
let component: MyComponent;
let fixture: ComponentFixture<MyComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ MyComponent ],
providers: [ MyService ],
})
.compileComponents();
});

beforeEach(() => {
fixture = TestBed.createComponent(MyComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});
});

In this example, the TestBed.configureTestingModule() method is used to create a testing module that declares the MyComponent and provides the MyService dependency. The beforeEach() method is used to create a fixture for the component and initialize it for testing.

By isolating the component in this way, the test only focuses on the behavior of the MyComponent and its interaction with MyService. Any other components or services in the application are not included in the test, ensuring that the test remains focused and reliable.

Use Mocks and Spies

Using mocks and spies in testing is a common practice to simulate the behavior of external dependencies and ensure that the test remains focused on the component being tested.

Mocks are objects that simulate the behavior of dependencies that the component under test relies on. You can use mocks to return specific values or simulate errors that would occur in real-world scenarios. This allows you to test how the component under test behaves in different scenarios, without actually interacting with the external dependencies.

Spies are functions that allow you to track and verify how a function was called. You can use spies to ensure that a specific function was called with specific parameters, or that it was not called at all. Spies can also be used to test how a component behaves when a function call fails.

Here’s an example of how to use a spy in a test:

import { TestBed } from '@angular/core/testing';
import { MyComponent } from './my.component';
import { MyService } from './my.service';

describe('MyComponent', () => {
let component: MyComponent;
let myServiceSpy: jasmine.SpyObj<MyService>;

beforeEach(async () => {
const spy = jasmine.createSpyObj('MyService', ['getData']);

await TestBed.configureTestingModule({
declarations: [ MyComponent ],
providers: [
{ provide: MyService, useValue: spy }
]
})
.compileComponents();

myServiceSpy = TestBed.inject(MyService) as jasmine.SpyObj<MyService>;

fixture = TestBed.createComponent(MyComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should get data from service', () => {
myServiceSpy.getData.and.returnValue(of('test'));

component.getData();

expect(myServiceSpy.getData).toHaveBeenCalled();
expect(component.data).toEqual('test');
});
});

In this example, a spy object is created for the MyService dependency and injected into the TestBed configuration. The spy object has a getData function that is being tracked by a spy.

In the test case, the getData function of the MyService dependency is being mocked to return the value of('test'). The getData function is then called on the component being tested and its result is checked against the expected value.

By using mocks and spies, you can ensure that the component under test behaves as expected, regardless of any external dependencies.

Here’s an updated version of the code that uses a mock instead of a spy:

import { TestBed } from '@angular/core/testing';
import { MyComponent } from './my.component';
import { MyService } from './my.service';

describe('MyComponent', () => {
let component: MyComponent;
let myServiceMock: MyService;

beforeEach(async () => {
myServiceMock = {
getData: jasmine.createSpy('getData').and.returnValue(of('test'))
};

await TestBed.configureTestingModule({
declarations: [ MyComponent ],
providers: [
{ provide: MyService, useValue: myServiceMock }
]
})
.compileComponents();
});

beforeEach(() => {
fixture = TestBed.createComponent(MyComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should get data from service', () => {
component.getData();

expect(myServiceMock.getData).toHaveBeenCalled();
expect(component.data).toEqual('test');
});
});

In this version of the code, we create a mock of MyService using a plain JavaScript object instead of a spy object. We then use this mock to provide the service in the test bed configuration.

In the test case, we can then call getData on the mock and verify that it was called by the component.

In the context of testing, a spy is a testing utility provided by Jasmine that allows you to observe and verify the behavior of functions or methods within your code, without actually invoking the original function or method. You can use a spy to check whether a function was called or not, with what arguments, and how many times it was called.

On the other hand, a mock is a testing utility that allows you to create a fake implementation of a function or method, and specify its behavior based on your testing needs. You can use a mock to simulate the behavior of external dependencies and ensure that your tests remain focused on the component being tested.

In the previous code example, we used a spy to observe and verify the behavior of the getData() method in the MyService class, while in the updated code example, we used a mock to create a fake implementation of the MyService class, and specify its behavior using the getMock() function. The mock allows us to simulate the behavior of the getData() method, and ensure that our test remains focused on testing the MyComponent class in isolation.

Use the Right Matchers

Matchers are an essential feature of Jasmine. Matchers allow you to write expressive and readable tests by providing a range of functions that you can use to make assertions about the values and behaviors of your code.

Each matcher function takes one or more values, compares them against an expected result, and returns a Boolean value indicating whether the assertion passed or failed. If the assertion fails, Jasmine will provide a helpful error message to help you identify the problem.

For example, the toEqual() matcher compares two values for equality, using a deep comparison for objects and arrays. If the values are not equal, the test will fail with an error message indicating the expected and actual values.

it('should add two numbers', () => {
const result = 1 + 2;
expect(result).toEqual(3);
});

Using matchers is important to write clear and maintainable tests in Jasmine. Jasmine provides several built-in matchers that can be used to compare expected values with actual values.

Here are some examples of commonly used matches:

  • expect(actual).toBe(expected): Compare using === for primitive types and Object.is() for objects.
  • expect(actual).not.toBe(expected): Inverse of toBe().
  • expect(actual).toEqual(expected): Deep equality comparison.
  • expect(actual).not.toEqual(expected): Inverse of toEqual().
  • expect(actual).toMatch(pattern): Match a string against a regular expression.
  • expect(actual).not.toMatch(pattern): Inverse of toMatch().
  • expect(actual).toBeDefined(): Check if a value is defined.
  • expect(actual).toBeUndefined(): Check if a value is undefined.
  • expect(actual).toBeNull(): Check if a value is null.
  • expect(actual).toBeTruthy(): Check if a value is truthy (not just true).
  • expect(actual).toBeFalsy(): Check if a value is falsy (not just false).
  • expect(actual).toContain(expected): Check if an array or string contains a certain value.
  • expect(actual).not.toContain(expected): Inverse of toContain().
  • expect(actual).toBeLessThan(expected): Check if a value is less than another value.
  • expect(actual).toBeGreaterThan(expected): Check if a value is greater than another value.
  • expect(actual).toBeCloseTo(expected, precision): Check if a floating-point value is close to another value within a certain precision.
  • expect(actual).toThrow(error): Check if a function throws an error.
  • expect(actual).not.toThrow(error): Inverse of toThrow().

These matchers cover a wide range of scenarios and can be combined with other testing techniques to create powerful and expressive tests.

Use Code Coverage Tools

Code coverage tools are tools that help you measure how much of your code is being executed during the testing process. By analyzing the code coverage report generated by these tools, you can identify areas of your code that are not being tested and ensure that you have comprehensive test coverage.

Code coverage is typically measured as a percentage of lines of code executed during the testing process. For example, if your code coverage report shows that you have covered 80% of your code, it means that 80% of your lines of code have been executed during the testing process.

One popular code coverage tool for Angular is Istanbul. Istanbul is a code coverage tool that works with a variety of frameworks, including Angular, and it provides detailed coverage reports that show how much of your code is being exercised by your tests.

To use Istanbul with Angular, you can follow these steps:

Install the Istanbul package using npm:

npm install -D istanbul

Add the following script to your package.json file:

"scripts": {
"test": "ng test --code-coverage",
"coverage": "istanbul cover ./node_modules/.bin/ng test -- --code-coverage"
}

The test script runs your tests and generates a coverage report. The coverage script uses Istanbul to generate a coverage report based on the tests run by the test script.

Run the coverage script:

npm run coverage

View the coverage report in your browser:

After running the coverage script, you can view the coverage report by opening the coverage/index.html file in your browser. This report provides a detailed overview of your code coverage, including which lines of code are covered by your tests and which lines are not.

By using a code coverage tool like Istanbul, you can ensure that your tests are comprehensive and that your code is thoroughly tested.

Use Debugging Tools

Debugging tools are essential for identifying and resolving issues in code and test cases. Two commonly used debugging tools for Angular are Chrome DevTools and Augury.

Chrome DevTools is a set of web development tools built into the Google Chrome browser. It provides a comprehensive set of debugging tools that can be used to inspect and debug web applications, including Angular applications. Some of the key features of Chrome DevTools include:

  • Console: The console allows you to log messages and interact with the JavaScript code running in your application.
  • Elements: The elements panel allows you to inspect the HTML and CSS of your application, as well as modify the styles and attributes in real time.
  • Network: The network panel allows you to monitor the network requests made by your application, including HTTP requests and WebSocket connections.
  • Sources: The sources panel allows you to debug the JavaScript code running in your application, including setting breakpoints, stepping through code, and inspecting variables.

Augury is a Chrome and Firefox extension that provides additional Angular-specific debugging tools. It can be used to inspect the component tree, view the input and output bindings of components, and view the Angular modules and services used in the application. Augury can be particularly helpful when debugging complex Angular applications with many components and services.

With Augury, developers can:

  • Inspect the component tree: Augury allows developers to inspect the hierarchy of their application’s components, providing a clear and detailed view of the component structure and its associated dependencies.
  • Monitor the application state: Augury allows developers to monitor the state of their application in real time, providing insight into how data is flowing through the application.
  • Debug and diagnose issues: Augury includes a range of debugging and diagnostic tools, including the ability to inspect and modify the component properties and view component events.
  • Optimize performance: Augury includes a performance profiler that allows developers to monitor the performance of their application, identify performance bottlenecks, and optimize the application’s performance.

Overall, Augury is a powerful tool for Angular developers, providing an easy-to-use interface for debugging, diagnosing, and optimizing Angular applications.

Test User Interactions

Testing user interactions is an important aspect of testing an application. It involves testing how the application responds to user input and interaction, such as button clicks, form submissions, and navigation. By testing user interactions, you can ensure that the application provides the desired user experience and that user actions are processed correctly.

To test user interactions, you can use tools like Protractor, which is an end-to-end testing framework for Angular applications. Protractor allows you to simulate user interactions with the application and test how the application responds to those interactions.

Protractor provides several methods for interacting with the application, such as clicking buttons, entering text into input fields, and navigating to different pages. You can use these methods to simulate user interactions and test how the application responds.

Here’s an example of testing a form submission with Protractor:

describe('My Form', () => {
it('should submit the form', () => {
browser.get('/my-form');
element(by.id('name-input')).sendKeys('John Doe');
element(by.id('email-input')).sendKeys('john.doe@example.com');
element(by.id('submit-button')).click();
expect(element(by.id('success-message')).getText()).toEqual('Form submitted successfully');
});
});

In this example, we’re navigating to a form page, entering some data into the form fields, clicking the submit button, and then checking that a success message is displayed. This simulates a user submitting a form and tests how the application responds to that action.

By testing user interactions, you can ensure that the application provides a smooth and error-free user experience.

Use Dependency Injection

Using Dependency Injection (DI) in your tests can be very helpful. With DI, you can create and inject test doubles and other dependencies into the code being tested, which can make your tests more flexible and easier to maintain. Here’s an example of how DI can be used in an Angular test:

Let’s say we have a component called ProductListComponent that fetches a list of products from an external API and displays them to the user. In the ngOnInit() method of the component, we make an HTTP request to get the list of products.

import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Component({
selector: 'app-product-list',
templateUrl: './product-list.component.html',
styleUrls: ['./product-list.component.css']
})
export class ProductListComponent implements OnInit {
products: Product[];

constructor(private http: HttpClient) {}

ngOnInit() {
this.http.get<Product[]>('http://api.example.com/products')
.subscribe(products => this.products = products);
}
}

interface Product {
id: number;
name: string;
price: number;
}

To test this component, we could use DI to inject a mock HttpClient into the component. This would allow us to simulate the HTTP request and return a set of predefined products for the test. Here's an example:

import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { ProductListComponent } from './product-list.component';

describe('ProductListComponent', () => {
let component: ProductListComponent;
let httpMock: HttpTestingController;

beforeEach(() => {
TestBed.configureTestingModule({
imports: [ HttpClientTestingModule ],
providers: [ ProductListComponent ]
});

component = TestBed.inject(ProductListComponent);
httpMock = TestBed.inject(HttpTestingController);
});

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

it('should fetch a list of products from the server', () => {
const mockProducts = [
{ id: 1, name: 'Product 1', price: 10.00 },
{ id: 2, name: 'Product 2', price: 20.00 },
{ id: 3, name: 'Product 3', price: 30.00 }
];

component.ngOnInit();

const req = httpMock.expectOne('http://api.example.com/products');
expect(req.request.method).toEqual('GET');

req.flush(mockProducts);

expect(component.products).toEqual(mockProducts);
});
});

In this example, we use HttpClientTestingModule to provide the mock HttpClient and HttpTestingController to control the HTTP requests and responses. We also inject ProductListComponent into the test bed and then use TestBed.inject() to get an instance of the component and the HttpTestingController.

With DI, we can easily swap out the real HttpClient with a mock version that returns predefined data for our tests. This makes our tests more flexible and easier to maintain, as we don't have to rely on an external API to test our code.

Automate Tests

Automating tests is a crucial part of ensuring that your Angular application is thoroughly tested and bug-free. By automating your tests, you can catch issues early and ensure that your codebase remains stable even as it evolves over time. Here are some best practices for automating tests in Angular:

  1. Use Continuous Integration (CI) tools: Popular CI tools like Jenkins, CircleCI, and TravisCI can automatically build and run your test suite whenever new changes are pushed to the code repository. This helps catch issues early and ensures that your codebase remains stable.
  2. Use Test Automation Frameworks: Test automation frameworks like Protractor, Karma, and Jest can help you write and run automated tests for your Angular applications. These frameworks offer features like test runners, assertions, and debugging tools, making it easier to write and debug tests.
  3. Separate Unit and Integration Tests: It’s important to separate your unit tests (which test individual units of code in isolation) from your integration tests (which test how different units of code work together). This makes it easier to pinpoint issues and ensures that your tests are faster and more reliable.
  4. Use Headless Browsers: Headless browsers like PhantomJS and Puppeteer allow you to run automated tests without launching a full browser, making your tests faster and more efficient.
  5. Set Up Test Environments: Test environments should be set up to mimic production environments as closely as possible. This ensures that your tests are running in an environment that is representative of what your users will experience.
  6. Run Tests Locally: Running tests locally is a great way to catch issues before they are pushed to the code repository. You can use tools like Karma or Jest to run tests locally and fix any issues before pushing your changes.
  7. Use Continuous Deployment (CD) Tools: CD tools like Jenkins, CircleCI, and TravisCI can automatically deploy your codebase to production after all tests have passed. This ensures that your code is always tested and deployed in a consistent and reliable manner.
  8. Monitor Test Results: Monitoring test results is important to ensure that your test suite is running as expected and to identify any issues that may have been missed. You can use tools like SonarQube or CodeClimate to track your test coverage and identify areas of your code that may need more testing.
  9. Use Code Review Tools: Code review tools like GitHub and GitLab allow you to review code changes before they are merged into the code repository. This helps catch issues early and ensures that your codebase remains stable.
  10. Collaborate with Your Team: Collaborating with your team is important to ensure that everyone is aware of the test automation strategy and is following best practices. Regular code reviews, team meetings, and communication channels can help ensure that your team is working towards a shared goal of delivering high-quality, tested code.

Conclusion: By following these best practices, developers can ensure that their Angular apps function correctly and deliver the desired user experience. Testing is an essential part of the development process, and automating tests can reduce the risk of bugs going unnoticed. With the help of various tools and techniques, developers can create comprehensive test suites that cover all aspects of their Angular apps.

--

--

Patric

Loving web development and learning something new. Always curious about new tools and ideas.