Testing Angular Components
"A mistake was made trying to publish the first version of the library as quickly as possible. By covering only the introductory project with integration tests, hoping it would be sufficient for the library itself, but because of this, the most important function of the entire library, runTasksUntilStable, did not work as it should have."
—Me, pre-release process for ngx-testbox
Table of Contents
Problem Description
Nowadays, developers often forget or deliberately don't write tests to speed up product delivery to the customer. This approach is definitely worth it, but only as long as development is at the MVP stage. Everything that follows must be covered with tests if such an application will bring profit in the medium or long term.
Have you ever encountered a problem where you don't know how long to wait until the NG zone in the application becomes stable, all OnPush components are rendered, all server requests receive a response, and all asynchronous operations are completed, even those that will queue up as a result of previous asynchronous operations; and then you can proceed to writing expect statements?
In this guide, I will outline an approach that has proven itself very well on complex components with complex business logic. This article is intended for senior and mid-level developers, as I do not plan to consider the pros or cons of most general statements in this article.
First of all, I want to define the framework in which we will exist for the most effective achievement of the goal. I ask you to familiarize yourself with them, as they may not be suitable for all development teams, in order not to waste your time if you do not want to work within the constraints I propose.
In the description, I will use a library that I wrote to speed up the process and ease the testing of components, which takes responsibility for performing all asynchronous tasks to stabilize the NG Zone in the test environment.
This approach does not cover unit testing of individual functions and services, or e2e application testing. You can use any other direction for unit and e2e testing, they will not interfere with the operation of the proposed approach.
So, let's get started.
Business and Technical Requirements
- The method of writing tests should be as similar as possible to user behavior.
- Functional requirements are tested through the Acceptance Criteria of your user stories. In other words, Acceptance Criteria = Written test case.
- The Angular application should have low dependence on the project infrastructure. With non-working environments, the Angular application remains self-sufficient in terms of the functionality of written tests.
- Functional requirements are tested through the behavior of elements on the page and the interaction of the Angular application with the server through the Angular HTTP Client.
- Side effects are not tested if they do not relate to functional requirements (use regular unit testing for them). Side effects are overwritten with stubs.
- Errors in writing tests caused by human factors should be minimized.
- Written tests are low coupled with code implementation.
- Expect statements are written exclusively for NG Zone in stable status.
- You should have a manageable organization of modules (options include: FSD; your own development; nested modules reflecting your project's routing, somewhat similar to how next.js does it; etc.), which will allow you to divide the code by responsibility, have manageable side effects, and avoid cyclic dependencies.
- An excellent understanding of what the Angular application expects in responses from the server is necessary for writing a test that covers a specific Acceptance Criteria.
- Code execution in the browser is required, but not in memory, since not all test environments in memory have a complete set of browser API. Additionally, running in the browser will be convenient for demonstrations and debugging.
Consequences
- Fake functions exist to create stubs for side effects.
- Only components are tested. Services and related classes are tested as part of component integration testing.
- Components are tested using the black-box testing method. We do not test the code structure and variable values, only input parameters and intermediate/output results.
Positive Effects
- High level of test coverage.
- Written tests are not tied to implementation. When the code changes, the tests remain relevant.
- Tests cover actual functional requirements, not just show a high percentage of coverage for the sake of it.
- The human error factor is reduced when writing fake functions and describing the test case.
- We can write components without a working backend, but if approximate contracts are already known.
- Easier to transition to TDD.
- Test execution speed is higher than with e2e.
- Test reliability provides more guarantees than with unit testing.
Negative Effects
- This approach can be a headache for beginner developers, as working with this approach requires an "A to Z" understanding of functional requirements, and how they relate to server responses that they will have to simulate. As a result, development speed can significantly decrease if the team mostly consists of beginner developers.
- Development speed will decrease in the short term until the new approach is understood. Full adaptation should take from one to two months for a mid-level developer with active work on tests.
- In reports, some code may be falsely shown as covered if you did not write a specific test case for that code section.
Theory
In my understanding, unit testing of components/acceptance criteria is an extremely unjustified attempt to achieve high coverage, as over time such a method is more of a hindrance and does not guarantee test coverage of user stories. E2e testing adds more dependencies to the functionality of tests, as a result of which, tests may not always and everywhere be functional. Therefore, here I will talk about integration black-box testing, using ngx-testbox library, using the example of an introductory project from Angular - Tour of Heroes. The quote after the main title reflects that each type of testing is good at what it was designed for. Unit and e2e testing are excellent for other needs, and for testing Acceptance Criteria, I consider integration testing of components to be the ideal option.
The first thing I'll start the theoretical part with is a description of the features of working with the library:
- The library takes care of processing operations in the queue and responding to HTTP requests based on the provided HTTP call instructions.
- All tests are performed in the fakeAsync zone. This gives the ability to flexibly manage time and tasks in the queue.
- To start executing tasks in the zone, the runTasksUntilStable function is used.
- Instructions for server responses are set through the HttpCallInstruction interface and then passed as an argument to runTasksUntilStable.
- All instructions for HTTP calls are mandatory to call within a single call of the runTasksUntilStable function.
- Any unprocessed HTTP request that gets into the Angular HTTP Client will cause an error. The exception to the rule is canceled requests, they will be ignored.
- The library provides a base Harness class for working with DOM elements: queries, focus, click, and getting text content.
- The Harness class works in conjunction with a test attribute, by default is data-test-id.
- By example, you can generate test ids to assign them to DOM elements, which will be supported by your IDE for ease of use.
- Works only through REST API (Websocket and RPC at the research stage).
There are several steps that help even more to get rid of uncertainty when working in a team on user stories. They are optional, but I recommend them as a proven path to solving tasks in a team:
- Never start writing code as soon as you get a task. Read the task description. Take a breath. Go have some tea.
- While drinking tea, think about who is interested in the end result of your task and/or will accept the completed result. This could be a team lead, product owner, business analyst, your colleague backend developer, or someone else. Create a chat and add these people to the chat, call it "3 Amigos. # and name of your task". Be sure to add the QA engineer who will check your task before sending it for confirmation. It's desirable to add the designer who worked on the mockups for your task.
- First, ask a simple question in the chat: "Is the task ready for code to be written for it, or is additional research needed?". And once again confirm the Acceptance Criteria that are written in the task. If you haven't asked such questions before, you may notice how some part of the tasks will automatically be completed or postponed until circumstances are clarified, which plays into your hands, as you don't want to do useless work.
- As a developer, gather your own observations, taking into account knowledge of your codebase. Based on them, write your own Acceptance Criteria. If they differ, and most likely they will differ, from those written in the task itself, send them for review to your Amigos. You, as an experienced specialist, will notice that the task does not mention exception handling paths or the design mockups do not have a mockup for the loading state, etc. And again confirm the Acceptance Criteria with your Amigos.
- After communication with the team, you should have an approved list of Acceptance Criteria that you will cover with tests.
- Congratulations, you are ready to write code!
Tour of Heroes
You can find all the source code in the repository at the link ngx-testbox
Now we will have to do reverse engineering and gather test cases from a ready-made project. I see 4 components, each of which can be a separate user story:
- dashboard
- hero-detail
- hero-search
- Heroes
Possible Acceptance Criteria for each story
Dashboard:
- should display "Top Heroes" in the title
- should show heroes when server responds with heroes
- should not show any heroes if server responds with error
- should display hero name for each hero
- should create correct detail link for each hero
Hero-detail:
- should display hero details when hero is loaded
- should not display hero details when hero fails to load
- should allow editing the hero name
- should save hero and navigate back when save button is clicked
- should not navigate back when save fails
- should navigate back when go back button is clicked
Hero-search:
- should have empty search box initially
- should have no search results initially
- should show heroes when search term matches hero names
- should show no heroes when search term is an empty string or string of spaces
- should not show any heroes if search term does not match any hero names
- should not show any heroes if search returns an error
- should create correct detail link for each hero in search results
Heroes:
- should show all heroes when server responds with heroes
- should not show any heroes if server responds with an error
- should display hero id and name for each hero
- should create correct detail link for each hero
- should add new hero when valid name is entered
- should not add hero when invalid name is provided
- should not add hero when server responds with error
- should clear input field after adding hero
- should remove hero from list when delete button is clicked
- should not remove hero from list if server responds with error
Checklist for Preparing the Test Environment
- Test identifiers are created
- Test identifiers are applied to elements in the markup
- A Harness class for the component is created, which inherits from the base Harness class
- Test identifiers are passed as an argument to the base class
- Algorithm execution starts from the NgOnInit stage.
Practice
Next, I will describe what the preparation looks like using examples with code.
Step 1. Create test identifiers.I create a separate file for testIds. I assign an array of strings to a variable with an immutable type
designation using "as const
", then create an object where both the key and value are the identifier
using the static method IdsToMap of the TestIdDirective class.
// test-ids.ts
import {TestIdDirective} from 'ngx-testbox';
export const testIds = [
'title',
'nameInput',
'addButton',
'heroesList',
'heroItem',
'heroLink',
'heroDeleteButton',
'heroId',
'heroName',
] as const;
export const testIdMap = TestIdDirective.idsToMap(testIds);
This stage is not complicated. We import the TestIdDirective directive and our testIdMap, go to the markup and apply it to all elements.
import {testIdMap} from "./test-ids";
import {TestIdDirective} from 'ngx-testbox';
@Component({
selector: 'app-heroes',
templateUrl: './heroes.component.html',
imports: [
TestIdDirective
],
styleUrls: ['./heroes.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class HeroesComponent implements OnInit {
readonly testIds = testIdMap;
}
<h2 [testboxTestId]="testIds.title">My Heroes</h2>
<div>
<label for="new-hero">Hero name: </label>
<input id="new-hero" [testboxTestId]="testIds.nameInput" #heroName />
<button type="button"
[testboxTestId]="testIds.addButton"
(click)="add(heroName.value); heroName.value=''">
Add hero
</button>
</div>
<ul [testboxTestId]="testIds.heroesList">
<li *ngFor="let hero of heroes" [testboxTestId]="testIds.heroItem">
<a routerLink="/detail/{{hero.id}}" [testboxTestId]="testIds.heroLink">
<span [testboxTestId]="testIds.heroId">{{hero.id}}</span>
<span [testboxTestId]="testIds.heroName">{{hero.name}}</span>
</a>
<button type="button"
[testboxTestId]="testIds.heroDeleteButton"
(click)="delete(hero)">
x
</button>
</li>
</ul>
At this step, we create a class that will make our lives easier as developers. With it, we can easily search for elements on the page, click and focus on them, extract text content. And all this by calling one function, which your IDE should help you find.
import {DebugElementHarness} from 'ngx-testbox/testing';
import {testIds} from './test-ids';
import {DebugElement} from '@angular/core';
export class HeroesHarness extends DebugElementHarness<typeof testIds> {
constructor(debugElement: DebugElement) {
super(debugElement, testIds);
}
}
This step is more of a recommendation than a call to action.
Algorithm execution starts from the NgOnInit stage. Exceptions may include initializing lines of code in the constructor, such as assigning static (readonly) values to class fields. This is necessary in case we have the opportunity to mock functions before running test cases, otherwise if you run algorithms in the constructor, such an opportunity may not always be available later.
Tour of Heroes
I warn that this introductory project may be heterogeneous. I added onPush strategies, placed test IDs, and wrote spec files, the rest of the codebase is as is.
Let's look at the most interesting test cases. You can familiarize yourself with the entire project at the link ngx-testbox.
For the first test cases, let's take 2 simple examples from the Heroes component:
- should show all heroes when server responds with heroes
- should not show any heroes if server responds with an error
Let's open the heroes.spec.ts file.
Initialize variables for the component's Fixture and Harness.
Configure our test module through configureTestingModule.
Write an initComponent function that we will call inside each test case. It will be responsible for:
- Assigning values to these variables.
- Getting instructions for http requests.
- Executing asynchronous tasks from the queue.
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {HeroesComponent} from './heroes.component';
import {HeroesHarness} from './heroes.harness';
import {provideHttpClient} from '@angular/common/http';
import {provideHttpClientTesting} from '@angular/common/http/testing';
import {HttpCallInstruction, runTasksUntilStable,} from 'ngx-testbox/testing';
import {provideRouter} from '@angular/router';
describe('HeroesComponent', () => {
let fixture: ComponentFixture<HeroesComponent>;
let harness: HeroesHarness;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HeroesComponent],
providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])],
}).compileComponents();
})
function initComponent(httpCallInstructions: HttpCallInstruction[]) {
fixture = TestBed.createComponent(HeroesComponent);
harness = new HeroesHarness(fixture.debugElement);
runTasksUntilStable(fixture, {
httpCallInstructions,
})
}
})
The server can return errors. Let's write a couple of http instructions for a request that will be successful, and for a request that will receive an error in response. Let's add an amount parameter to the function with a successful response for convenience.
The library has a predefined set of functions that return one or another type of HttpCallInstruction. We will
use "predefinedHttpCallInstructions.get.error
" and "predefinedHttpCallInstructions.get.success
"
respectively to simulate responses from the server.
import {HEROES_URL} from '../hero.service';
import {HEROES} from '../mock-heroes';
import {predefinedHttpCallInstructions} from 'ngx-testbox/testing';
const getHeroesSuccessHttpCallInstruction = (amount: number) =>
predefinedHttpCallInstructions.get.success(HEROES_URL, () => HEROES.slice(0, amount));
const getHeroesFailHttpCallInstruction = () =>
predefinedHttpCallInstructions.get.error(HEROES_URL, () => null);
Next, everything will be very easy. We don't need spy functions to check whether methods that send requests were called or not, all this is very cumbersome and unreliable.
Let's write our first 2 tests. As you can see, a few simple lines of code cover 2 test cases.
it('should show all heroes when server responds with heroes', fakeAsync(async () => {
const heroesLength = HEROES.length;
initComponent([getHeroesSuccessHttpCallInstruction(heroesLength)]);
expect(harness.elements.heroItem.queryAll().length).toBe(heroesLength);
}))
it('should not show any heroes if server responds with an error', fakeAsync(async () => {
initComponent([getHeroesFailHttpCallInstruction()]);
expect(harness.elements.heroItem.queryAll().length).toBe(0);
}))
Here a question may arise: "how can I be sure that in the case of an error, my test case really worked from start to finish, since not a single hero will be shown, even if the application does not send a request to get heroes?". It is for such cases that I added a guarantee at the library code level that any HTTP request instruction that you send to runTasksUntilStable will be called, otherwise it will throw an error before completing the function execution.
In the following examples, we interact with data from the request to form an appropriate response.
const getPostHeroesSuccessHttpCallInstruction = () =>
predefinedHttpCallInstructions.post.success(HEROES_URL, (httpRequest) => ({
name: (httpRequest.body as any).name,
id: Math.floor(Math.random() * 1000000)
}));
it('should add new hero when valid name is entered', fakeAsync(async () => {
initComponent([getHeroesSuccessHttpCallInstruction(0)]);
expect(harness.elements.heroItem.queryAll().length).toBe(0);
const name = `Test Hero`;
for (let i = 1; i <= 10; i++) {
harness.setNameInputValue(`${name} ${i}`);
harness.elements.addButton.click();
runTasksUntilStable(fixture, {
httpCallInstructions: [
getPostHeroesSuccessHttpCallInstruction(),
],
})
const elements = harness.elements.heroItem.queryAll();
expect(elements.length).toBe(i);
elements.forEach((el, index) => {
expect(el.nativeElement.textContent.trim().includes(`${name} ${index + 1}`)).toBeTrue();
})
}
}))
const getHeroesSearchSuccessHttpCallInstruction = () =>
predefinedHttpCallInstructions.get.success(new RegExp(`${HEROES_URL}/\\?name=\\w+`), (httpRequest, urlSearchParams) => {
const term = urlSearchParams.get('name')!;
return HEROES.filter(hero => hero.name.toLowerCase().includes(term.toLowerCase()))
});
it('should show heroes when search term matches hero names', fakeAsync(async () => {
const searchTerm = 'ma'; // Should match heroes with 'ma' in their name
initComponent();
harness.setSearchBoxValue(searchTerm);
runTasksUntilStable(fixture, {
httpCallInstructions: [
getHeroesSearchSuccessHttpCallInstruction()
],
});
const heroElements = harness.getHeroElements();
expect(heroElements.length).toBeGreaterThan(0);
// Verify each result contains the search term
heroElements.forEach(heroElement => {
const heroName = harness.elements.heroLink.query(heroElement).nativeElement.textContent.trim();
expect(heroName.toLowerCase()).toContain(searchTerm.toLowerCase());
});
}));
If we know that no HTTP calls are expected in the process, then we can run runTasksUntilStable without an array of HTTP instructions
it('should not add hero when invalid name is provided', fakeAsync(() => {
initComponent();
expect(harness.elements.heroItem.queryAll().length).toBe(0);
const name = ``;
for (let i = 1; i <= 10; i++) {
harness.setNameInputValue(`${name}`);
harness.elements.addButton.click();
runTasksUntilStable(fixture)
const elements = harness.elements.heroItem.queryAll();
expect(elements.length).toBe(0);
}
}));
Conclusion
This approach allows you to have a high percentage of test coverage, but what's more important is that you are now testing full-fledged user stories.

You have seen by example that writing integration tests is not difficult, but it is necessary to prepare the
project in advance (see the technical requirements above).
You can find the repository with heroes, documentation, and the library at the link ngx-testbox.
I am open to suggestions and questions, or requests for assistance in transitioning your project to this approach. You can contact me via email kkolomin.w@gmail.com or on Linkedin.