Photo by Louis Reed on Unsplash
How To Test Components In Angular Using Testbed
Mistakes in Angular Component Testing and TestBed
When talking about unit testing, the first definition that comes into our head is to test the minor testable parts of an app, like functions, methods, and classes. It sounds perfect for a simple function or isolated class without dependencies, but it is not in the real world.
In Angular, the components have decorators, services, pipes, templates to render the information, and sometimes child components, so we have too many actors in our test.
This article focuses on the typical problems or issues to face when we start adding testing in a single component with dependencies like services and child components.
Scenario
We have an application showing a list of NBA Players from API using PlayerService
; the app works with two main components.
Player: The dumb component to show the single-player info.
PlayerListComponent: Uses the list of players from the `PlayerService` to render in the view using the player component.
We are going to test the playerlist.component.ts
handling dependencies of services and the child component.
The Typescript:
import { Component, OnInit } from '@angular/core';
import { PlayerService } from 'src/app/services/players.services';
import { APIResponse, Player } from '../models/player';
@Component({
selector: 'app-player-list',
templateUrl: './player-list.component.html',
styleUrls: ['./player-list.component.css'],
})
export class PlayerListComponent implements OnInit {
players: Player[] = [];
constructor(private playerService: PlayerService) { }
ngOnInit(): void {
this.playerService.getPlayers().subscribe((players: APIResponse) => {
this.players = players.data;
});
}
}
The HTML Markup uses the <app-player>
component.
<div *ngFor="let player of players" >
<app-player [player]="player"></app-player>
</div>
Let's try to test the component!
Basic Of Jasmine
Before starting, remember that Angular relies on Jasmine and Karma as test runners and our tests. If you never write a test, it is short and intro.
describe
: Group-related tests together. It takes a string and a function as arguments.beforeEach
: Runs before each test case for setup operations.it
: Defines a test case; it takes a string and a function as arguments.expect
: used to assert the behavior of the code being tested.
Learn more about Jasmine and Karma
WAIT JUST A MOMENT!
I understand that testing in Angular can be intimidating for many developers, myself included. Would you like to learn about Angular testing, from the basics to the advanced aspects, and discover which elements are crucial for real-world situations?
Take a look at "Conscious Angular Testing" by Decoded Frontend – now with a special discount!
The course clearly explains things like Testbed, setting up tests, lifecycle hooks, and adding dependencies. It teaches you how to write non-fragile tests, deal with standalone components, content projection, inject testing, services, components with dependencies and more.
After this, I am no longer scared of writing tests in Angular.
What is Testbed
The testbed is a powerful and easy-to-use testing environment that allows us to test components and services in isolation without depending on the rest of the application. It provides a set of APIs for creating and configuring the test environment and interacting with the tested component or service.
With the testbed, we are going to use two methods, configureTestingModule
and createComponent
.
To configure a test bed for a specific module or component, we use configureTestingModule()
the method takes an object as an argument to configure providers, declarations, imports, and other options similar to app.module.ts.
The createComponent
creates an instance of a component and returns a ComponentFixture
for that component, allowing for testing the component's behavior and DOM interactions.
We create the player-list.component.spec.ts
, adding the describe
with the test title.
describe('PlayerList Component', () => {
})
Declare the fixture variable to store the component using ComponentFixture
, and set the variable using the type PlayerListComponent
.
describe('PlayerList Component', () => {
let fixture: ComponentFixture<PlayerListComponent>
});
It's time to use the BeforeEach
and TestBed to configure our Testing module, as we learn before the configureTestingModule
help us set up our module.
Declare the PlayerListComponent
and with TestBed.createComponent
to create the instance of PlayerListComponent
.
The final code looks like this:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PlayerListComponent } from './player-list.component';
describe('PlayerList Component', () => {
let fixture: ComponentFixture<PlayerListComponent>
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [PlayerListComponent]
})
fixture = TestBed.createComponent(PlayerListComponent);
})
it('should render the component', () => {
expect(true).toBe(true)
})
})
Save the changes and run the command: npm run test
:
✔ Browser application bundle generation complete.
Chrome 108.0.0.0 (Windows 10) PlayerList Component should render the component FAILED
NullInjectorError: R3InjectorError(DynamicTestModule)[PlayerService -> PlayerService]:
NullInjectorError: No provider for PlayerService!
error properties: Object({ ngTempTokenPath: null, ngTokenPath: [ 'PlayerService', 'PlayerService' ] })
Chrome 108.0.0.0 (Windows 10): Executed 1 of 1 (1 FAILED) (0.046 secs / 0.04 secs)
TOTAL: 1 FAILED, 0 SUCCESS
We got an error because the component uses the PlayerService
in the constructor, so we need to provide the PlayerService
without making a real request.
Mock to rescue us, using the function createSpyObj
from jasmine, we can mock objects with spy methods, allowing you to track and test the behavior of specific functions without executing the actual implementation.
Declare a new variable mockPlayerService
using jasmine.createSpyObj
, it forced to declare the methods to Mock. In our case, getPlayers
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PlayerService } from 'src/app/services/players.services';
import { PlayerListComponent } from './player-list.component';
describe('PlayerList Component', () => {
let fixture: ComponentFixture<PlayerListComponent>
const mockPlayerService = jasmine.createSpyObj<PlayerService>(["getPlayers"]);
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [PlayerListComponent],
providers: [{
provide: PlayerService,
useValue: mockPlayerService
}]
})
fixture = TestBed.createComponent(PlayerListComponent);
})
it('should render the component', () => {
expect(true).toBe(true)
})
})
Everything is green! So we can start to write our test.
Learn more about Mock and Spies
Testing The Component
We have the testing module ready; we are ready for the following tasks to test the component by doing the following tasks.
[x] Fake the response data
[x] Create a test for the component get players from the service
[x] Trigger the component
change-detection
.[x] Validate the property
players
has a value from the fake response.
Let's Create a test to get players from services. We create a variable with structure from the backend to use as a fake data response:
const FAKE_API_RESPONSE: APIResponse = {
data: [{
id: 1,
first_name: 'Lebron'
}]
};
Create a test to get players from the service with it
should get players from services and use and.returnValue
with the mock to return an observable as a fake response, we must trigger the change detection using fixture.detectChanges
to start the change detection lifecycle in the component.
it('should get players from service', () => {
mockPlayerService.getPlayers.and.returnValue(of(FAKE_API_RESPONSE));
fixture.detectChanges();
})
Save and ... DAMMMM!!! We got a new error!!!
PlayerList Component > should get players from the service
Error: NG0304: 'app-player' is not a known element (used in the 'PlayerListComponent' component template):
1. If 'app-player' is an Angular component, then verify that it is a part of an @NgModule where this component is declared.
2. If 'app-player' is a Web Component, then add 'CUSTOM_ELEMENTS_SCHEMA' to the '@NgModule.schemas' of this component to suppress this message.
Why Error: NG0304: 'app-player' is not a known element?
The playlist uses a component that is not registered in our testing module. What option do I have?
The schemas
property in the @NgModule
decorator configures template validation using the NO_ERRORS_SCHEMA
constant that disables template validation completely.
Let's try the following:
....CODE COLLAPSED
TestBed.configureTestingModule({
declarations: [PlayerListComponent],
providers: [
{
provide: PlayerService,
useValue: mockPlayerService,
},
],
schemas: [NO_ERRORS_SCHEMA],
});
fixture = TestBed.createComponent(PlayerListComponent);
});
it('should get players from service', () => {
mockPlayerService.getPlayers.and.returnValue(of(FAKE_API_RESPONSE));
fixture.detectChanges();
});
});
Note: There are better approaches than ignoring the template issues; later, we will find another way to handle them.
Save, and ... Yeahhh, Green again. Let's continue validating the property's value from the fake response.
How are we sure the property player has value? The fixture exposes some ways to access the component:
componentInstance
: Returns an instance of the tested component. We can use this property to access the properties and methods of the component and check their values.debugElement
: Returns an instance ofDebugElement
the tested component. TheDebugElement
is a wrapper around the native element and allows you to interact with the component's DOM in a way that is agnostic to the underlying platform.debugElement.nativeElement
: Returns the native element of the tested component, the actual element in the DOM. We can use this property to interact with the component's DOM indirectly d check its attributes, styles, and content.
Let's use the componentInstance
to access the players property, using expect
to assert the values, the FAKE_API_REPONSE
, return a single item, and check the length of players is equal to 1.
it('should get players from service', () => {
mockPlayerService.getPlayers.and.returnValue(of(FAKE_API_RESPONSE));
fixture.detectChanges();
expect(fixture.componentInstance.players.length).toEqual(1)
});
Save and the results:
Working with Child Components
Do you remember we ignored the template? That solution was just a patch. If we want to be sure our component renders the players, then we must validate that the template works.
Similar to what we did with the service, we can mock the <app-player>
component, so remove the schemas: [NO_ERRORS_SCHEMA]
.
To mock the component, we declare a new component in the test, with the same selector app-player
, and the properties required by our test. In the template, add the CSS class player
to make it easy to find the elements.
@Component({
selector: 'app-player',
template: `<div class='player'>
<span>{{player.name}}</span>
</div>`
})
class MockPlayer {
@Input() player!: Player;
}
Next, register the MockPlayer
in the declarations section:
TestBed.configureTestingModule({
declarations: [PlayerListComponent, MockPlayer],
providers: [
{
provide: PlayerService,
useValue: mockPlayerService,
},
]
});
What did we do?
We provide a Mock component with the same selector and register in the testing module. When the component requests the <app-data>, the testing module provides our mock.
Test Render Child Components
First, Similar to our other test, we must assign the fake data and trigger the change detection.
To find and interact with the component, we will to some methods from the component fixture.
queryAll
returns an array of DebugElements that match a given predicate.
by.css
a predicate queryAll
to find elements that match a given CSS selector.
by.directive
use as a predicate queryAll
to find elements that have been instantiated with a given directive.
The debugElement
is an object that provides access to the underlying native element and component instance associated with a given DOM element in a test fixture.
Because we add a CSS class in the mock component, we use the By.css to query all elements with the class div.player
render in the DOM.
Create a new variable totalPlayers
and store the result of queryAll
in totalPlayer
it returns an array, use the totalPlayer.length
to expect the test.
it('should render the players', () => {
mockPlayerService.getPlayers.and.returnValue(of(FAKE_API_RESPONSE));
fixture.detectChanges();
const totalPlayers = fixture.debugElement.queryAll(By.css('div.player')
expect(totalPlayers).length).toBe(1);
})
Save, and our tests pass using a mock service and template.
Note: Another alternative is with By.directive
using the component class name.
expect(fixture.debugElement.queryAll(By.directive(MockPlayer)).length).toBe(1);
Done! We have already tested our component:
[x] We validate the component to get the data.
[x] We validate the date is in the DOM.
[x] We learn to Mock Services and Components
[x] We learn to Query elements in the DOM using By
Conclusion
By learning about the common pitfalls when testing components with multiple dependencies in Angular, we can ensure the robustness and reliability of our application. This involves testing components with a testbed, mocking services, and child components.
Mocking services and child components will enable you to control the inputs and outputs of those dependencies, making it easier to test the component in isolation.
These techniques can improve your testing strategy and build more reliable and maintainable applications.
If you enjoy it, please feel free to share!