Angular Unit Testing Crash Course - Make Your Project Bullet Proof
Angular Unit Testing Crash Course - Make Your Project Bullet Proof

In this post you will learn how to write unit tests in Angular.

The first thing that I want to mention is that inside Angular starting with Angular 16 we are using Jest instead of Karma runner.

Jest

It doesn't really change a lot from writing tests perspective but you need to remember that. Now it is possible to use Jest inside Angular 16 but it is still experimental.

Karma

Obviously it won't be experimental forever as Karma is in deprecated state. And this is exactly what Angular used previously.

If you want to still with Karma this is totally fine. You just need to generate an Angular application and write

npm run test

Configuring Jest

Let's look how to update your project and use Jest instead. First all in in our angular.json we need to change test runner.

"test": {
  "builder": "@angular-devkit/build-angular:jest"
}

As you can see here it is build-angular:jest and not build-angular:karma.

We must also install several additional dependencies.

npm i jest jest-environment-jsdom @types/jest

All these 3 packages are needed if you want to use Jest inside Angular. Now we can start writing unit tests.

Testing component

First I want to start with something simple. For this I already prepared a small component to test.

@Component({
  selector: 'mc-error-message',
  template: '<div data-testid="message-container">{{message}}</div>',
  standalone: true,
})
export class ErrorMessageComponent {
  @Input() message: string = 'Something went wrong';
}

It's a standalone component with some small markup to render an error in the template. But the most important question is what is this attribute data-testid. This is a special attribute for our unit tests because inside our tests we want to find specific elements.

We don't really want to use some divs or classes because they are not related to tests and we may accidentally remove them.

Now let's create our test.

// errorMessage.spec.ts
import { By } from '@angular/platform-browser';
import { ErrorMessageComponent } from './errorMessage.component';
import { ComponentFixture, TestBed } from '@angular/core/testing';

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

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [ErrorMessageComponent],
    }).compileComponents();

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

This is the setup to test a component inside Angular. in beforeEach block we create a module where we import our ErrorMessageComponent. We also save a reference to fixture and component to use it later and run detectChanges.

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

This is our first test. It checks that our component was created.

tests passed

As you can see after calling npm run test our tests passed.

it('renders default error state', () => {
  const messageContainer = fixture.debugElement.query(
    By.css('[data-testid="message-container"]')
  );

  expect(messageContainer.nativeElement.textContent).toBe(
    'Something went wrong'
  );
});

Our next test is more interesting. Here we use fixture.debugElement.query to get the element by our attribute that we created. Then we can check the text content inside.

  it('renders custom error message', () => {
    component.message = 'Email is already taken';

    fixture.detectChanges();

    const messageContainer = fixture.debugElement.query(
      By.css('[data-testid="message-container"]')
    );

    expect(messageContainer.nativeElement.textContent).toBe(
      'Email is already taken'
    );
  });

The last thing to do is to check if we can pass an input to our component. Most important is that we must call detectChanges after we set a value in our component.

As you can see all our tests are green.

Testing inputs and outputs

Now let's look on more complex component.

export class PaginationComponent implements OnInit {
  @Input() total: number = 0;
  @Input() limit: number = 20;
  @Input() currentPage: number = 1;

  @Output('pageChange')
  pageChangeEvent = new EventEmitter<number>();

  pagesCount: number = 1;
  pages: number[] = [];

  constructor(private utilsService: UtilsService) {}

  ngOnInit(): void {
    this.pagesCount = Math.ceil(this.total / this.limit);
    this.pages =
      this.pagesCount > 0
        ? this.utilsService.range(1, this.pagesCount + 1)
        : [];
  }

  selectPage(page: number): void {
    this.pageChangeEvent.emit(page);
  }
}

Here we have a pagination component which accepts several inputs and render a list of pages on the screen.

<ul class="pagination">
  <li
    *ngFor="let page of pages"
    class="page-item"
    data-testid="page-container"
    [ngClass]="{ active: currentPage === page }"
    (click)="selectPage(page)"
  >
    <span class="page-link">
      {{ page }}
    </span>
  </li>
</ul>

This is our markup for pagination. The only interesting thing here is this data-testid which we added again to every single page which we render.

Now let's start to cover this component with tests.

// pagination.spec.ts
describe('PaginationComponent', () => {
  let component: PaginationComponent;
  let fixture: ComponentFixture<PaginationComponent>;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [PaginationComponent],
    }).compileComponents();

    fixture = TestBed.createComponent(PaginationComponent);
    component = fixture.componentInstance;
    component.total = 50;
    component.limit = 10;
    component.currentPage = 1;
    fixture.detectChanges();
  });
});

As you can see the initial setup in the same. The only difference is that we provide inputs in our component at the beginning because our component can't be used without them.

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

Our first test is always the same just to check if the component is there.

it('renders correct pagination', () => {
  const pageContainers = fixture.debugElement.queryAll(
    By.css('[data-testid="page-container"]')
  );

  expect(pageContainers.length).toBe(5);
  expect(pageContainers.at(0)?.nativeElement.textContent).toEqual(' 1 ');
});

Here our test is a little bit different because we use queryAll to find the array of elements. It allows us to check the total amount of elements on the screen and the content in our first element.

it('should emit a clicked page', () => {
  const pageContainers = fixture.debugElement.queryAll(
    By.css('[data-testid="page-container"]')
  );
  let clickedPage: number | undefined;

  component.pageChangeEvent.pipe(first()).subscribe((page) => {
    clickedPage = page;
  });

  pageContainers.at(0)?.triggerEventHandler('click');

  expect(clickedPage).toEqual(1);
});

This is the test for our output. Here we want to check if click triggers our output.

Output is just a stream to we can use subscribe to check if it is triggered.

Mock dependencies in Angular

Now we have a huge problem. Our PaginationComponent has a dependency UtilsService which is a problem because we want to test our component in isolation.

describe('PaginationComponent', () => {
  ...
  const mockUtilsService = {
    range: () => [1, 2, 3, 4, 5],
  };

  beforeEach(() => {
    TestBed.configureTestingModule({
      ...
      providers: [{ provide: UtilsService, useValue: mockUtilsService }],
    }).compileComponents();
  });
});

Here we create a mockUtilsService which is just an object and provided it as a value to UtilsService. It means that our component doesn't have a real dependency anymore and we will always get a mocked array when we call our range function.

As you can see all our tests are still green.

Testing Angular service

The last thing that we missed is to test our utility service.

@Injectable({
  providedIn: 'root',
})
export class UtilsService {
  range(start: number, end: number): number[] {
    return [...Array(end - start).keys()].map((el) => el + start);
  }
}

It has just a single range method inside.

import { TestBed } from '@angular/core/testing';
import { UtilsService } from './utils.service';

describe('UtilsService', () => {
  let utilsService: UtilsService;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [UtilsService],
    }).compileComponents();

    utilsService = TestBed.inject(UtilsService);
  });

  it('create service', () => {
    expect(utilsService).toBeTruthy();
  });
});

The setup to test a service is exactly the same. We must create a module and inject it inside.

describe('range', () => {
  it('returns correct result for 1-6 range', () => {
    const result = utilsService.range(1, 6);
    const expected = [1, 2, 3, 4, 5];
    expect(result).toEqual(expected);
  });

  it('returns correct result for 41-45 range', () => {
    const result = utilsService.range(41, 45);
    const expected = [41, 42, 43, 44];
    expect(result).toEqual(expected);
  });
});

We can test range function just by calling it. As we don't need to mock anything it is extremely easy to test.

And actually if you want to learn Angular with NgRx from empty folder to a fully functional production application make sure to check my Angular and NgRx - Building Real Project From Scratch course.

📚 Source code of what we've done