Angular directive testing

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: 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 number

  • detect changes

  • validate there are 5 elements

  • pass 9 number

  • detect changes

  • validate there are 9 elements

  • pass undefined value

  • detect 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

gitlab.com/barcioch-blog-examples/series-an..