Angular directive testing

Table of contents
In this article, I'll show how to test Angular directive. To test it I'll create appCloneElement
directive. Its purpose is to duplicate HTML elements given n
times.
Setup
If unsure how to set up Angular with Jest please refer to the article: https://barcioch.pro/angular-with-jest-setup
Clone element directive
The directive works as follows (everything happens in ngOnChanges
method):
- clear the view of created elements
this.viewContainer.clear();
- check if the passed value is a valid number
if (this.appCloneElement == null) {
return;
}
if (!Number.isFinite(this.appCloneElement)) {
return;
}
if (this.appCloneElement < 1) {
return;
}
- render the elements
for (let i = 1; i <= this.appCloneElement; i++) {
this.viewContainer.createEmbeddedView(this.templateRef)
}
Full implementation:
import { Directive, Input, OnChanges, SimpleChanges, TemplateRef, ViewContainerRef } from '@angular/core';
@Directive({
selector: '[appCloneElement]'
})
export class CloneElementDirective implements OnChanges {
@Input() appCloneElement: number | null | undefined;
constructor(
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef
) {
}
ngOnChanges(changes: SimpleChanges): void {
this.viewContainer.clear();
if (this.appCloneElement == null) {
return;
}
if (!Number.isFinite(this.appCloneElement)) {
return;
}
if (this.appCloneElement < 1) {
return;
}
for (let i = 1; i <= this.appCloneElement; i++) {
this.viewContainer.createEmbeddedView(this.templateRef)
}
}
}
Testing
To test a directive you need a component that passes properties to the directive. In the following example, it's TestComponent
. The directive itself is declared in a separate module CloneElementModule
so it has to be imported. The testing scenarios are quite simple:
pass the value to the component
detect changes
validate the number of displayed, cloned elements
The first scenario to check is the default view - without passing any values.
describe('when component with directive is initialized', () => {
it('should not render any element', () => {
expect(getClones().length).toBe(0);
});
...
The directive takes the number of elements to clone. If the number is less that 1 it shouldn't render any element. Since you can pass some values that are not valid numbers but the compiler won't complain about them, we're also going to test them. These values are: null
, undefined
, NaN
, Infinity
.
describe.each([
{input: -1, inputText: '-1'},
{input: -581, inputText: '-581'},
{input: 0, inputText: '0'},
{input: null, inputText: 'null'},
{input: undefined, inputText: 'undefined'},
{input: Infinity, inputText: 'Infinity'},
{input: NaN, inputText: 'NaN'},
])('and $inputText value is passed', ({input}) => {
beforeEach(() => {
component.cloneNumber = input;
fixture.detectChanges();
});
it('should not render any element', () => {
expect(getClones().length).toBe(0);
});
});
Also, you have to verify that multiple changes to the directive input will produce a valid number of cloned elements. The scenario will look like this:
pass
5
numberdetect changes
validate there are 5 elements
pass
9
numberdetect changes
validate there are 9 elements
pass
undefined
valuedetect changes
validate there are no elements
describe('and 5 value is passed', () => {
beforeEach(() => {
component.cloneNumber = 5;
fixture.detectChanges();
});
it(`should render 5 elements`, () => {
expect(getClones().length).toBe(5);
});
describe('and 9 value is passed', () => {
beforeEach(() => {
component.cloneNumber = 9;
fixture.detectChanges();
});
it(`should render 9 elements`, () => {
expect(getClones().length).toBe(9);
});
});
describe('and undefined value is passed', () => {
beforeEach(() => {
component.cloneNumber = undefined;
fixture.detectChanges();
});
it(`should not render any element`, () => {
expect(getClones().length).toBe(0);
});
});
});
Full implementation:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, DebugElement } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CloneElementModule } from './clone-element.module';
import { By } from '@angular/platform-browser';
describe('CloneElementDirective', () => {
let fixture: ComponentFixture<TestComponent>;
let component: TestComponent;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
CommonModule,
CloneElementModule
],
declarations: [TestComponent],
});
fixture = TestBed.createComponent(TestComponent);
component = fixture.componentInstance;
});
describe('when component with directive is initialized', () => {
it('should not render any element', () => {
expect(getClones().length).toBe(0);
});
describe.each([
{input: -1, inputText: '-1'},
{input: -581, inputText: '-581'},
{input: 0, inputText: '0'},
{input: null, inputText: 'null'},
{input: undefined, inputText: 'undefined'},
{input: Infinity, inputText: 'Infinity'},
{input: NaN, inputText: 'NaN'},
])('and $inputText value is passed', ({input}) => {
beforeEach(() => {
component.cloneNumber = input;
fixture.detectChanges();
});
it('should not render any element', () => {
expect(getClones().length).toBe(0);
});
});
describe.each([
{input: 1},
{input: 99},
{input: 19},
])('and $input value is passed', ({input}) => {
beforeEach(() => {
component.cloneNumber = input;
fixture.detectChanges();
});
it(`should render ${ input } element(s)`, () => {
expect(getClones().length).toBe(input);
});
});
describe('and 5 value is passed', () => {
beforeEach(() => {
component.cloneNumber = 5;
fixture.detectChanges();
});
it(`should render 5 elements`, () => {
expect(getClones().length).toBe(5);
});
describe('and 9 value is passed', () => {
beforeEach(() => {
component.cloneNumber = 9;
fixture.detectChanges();
});
it(`should render 9 elements`, () => {
expect(getClones().length).toBe(9);
});
});
describe('and undefined value is passed', () => {
beforeEach(() => {
component.cloneNumber = undefined;
fixture.detectChanges();
});
it(`should not render any element`, () => {
expect(getClones().length).toBe(0);
});
});
});
});
const getClones = (): DebugElement[] => {
return fixture.debugElement.queryAll(By.css('.clone'));
}
});
@Component({
selector: 'app-test',
template: `
<div class="clone" *appCloneElement="cloneNumber">I'm a clone</div>
`,
})
export class TestComponent {
cloneNumber: number | null | undefined;
}
Run the tests and you should see the results:
PASS src/app/directives/clone-element.directive.spec.ts
CloneElementDirective
when component with directive is initialized
✓ should not render any element
and -1 value is passed
✓ should not render any element
and -581 value is passed
✓ should not render any element
and 0 value is passed
✓ should not render any element
and null value is passed
✓ should not render any element
and undefined value is passed
✓ should not render any element
and Infinity value is passed
✓ should not render any element
and NaN value is passed
✓ should not render any element
and 1 value is passed
✓ should render 1 element(s)
and 99 value is passed
✓ should render 99 element(s)
and 19 value is passed
✓ should render 19 element(s)
and 5 value is passed
✓ should render 5 elements
and 9 value is passed
✓ should render 9 elements
and undefined value is passed
✓ should not render any element
Test Suites: 1 passed, 1 total
Tests: 14 passed, 14 total
Source code
https://gitlab.com/barcioch-blog-examples/series-angular-testing-06-angular-directive-testing
Subscribe to my newsletter
Read articles from Bartosz Szłapak directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
