Write better tests with Jest and Testing Library

Totsawin Jangprasert
Nerd For Tech
Published in
6 min readNov 5, 2021

--

take incremental steps to write implementation detail free tests

Disclaimer

In this demo, I am using the application codes and tests being generated from Angular CLI (when create the new project) and would assume that this is the actual Production application. I believe this is sufficient enough to point out how to improve our testing strategy.

Prerequisite

Angular project

Follow Angular CLI to create new project. After creating one, try serving in local with the following command.

ng serve

In your browser, open http://localhost:4200/ to see the new application run.

Application is up and running in local

Rewrite tests#0 — start with initial tests

Angular application is generated with sample codes and tests. By running below command, you would see that all tests pass.

ng test

Take a closer look. You would see that these are implementation detail tests.

  • test#1 assert that an instance of the component is created — this tells nothing except component can be instantiated
  • test#2 assert on the value of the component property — even if the test passes but this does not guarantee that this property will be correctly bound to a view and is visible to the users
  • test#3 query for class and html tag to assert on the displayed text— this is better than the previous two. But the css selector is not visible for the end users (and they don’t care). If developer decides to restyle/restructure e.g. class name change, the test would fail but the application is still working (false negative).
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [AppComponent],
}).compileComponents();
});
//test#1
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
//test#2
it(`should have as title 'my-first-project'`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('my-first-project');
});
//test#3
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.content span')?.textContent).toContain('my-first-project app is running!');
});
});

Rewrite tests#1 — think about use cases

I come up with 2 new use cases (initially it could be more but 2 use cases are sufficient for this demo), i.e., users can see the title of the application (test#1) and users can click the button and see the auto-generated command (test#2, test#3).

  • test#1 assert on the displayed text
  • test#2 click ‘New Component’ button and assert on the displayed command in terminal
  • test#3 click ‘Angular Material’ button and assert on the displayed command in terminal
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [AppComponent],
}).compileComponents();
});
test('should render title and display angular cli command after clicking button - using Karma and Jasmine', async () => {//test#1 assert on the displayed text
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.content span')?.textContent).toContain('my-first-project app is running!');
//test#2 click ‘New Component’ button and assert on the
//displayed command in terminal
const terminal = fixture.nativeElement.querySelector('.terminal');
let buttonElement = fixture.debugElement.query(debugEl => debugEl.name === 'button' && debugEl.nativeElement.textContent === 'New Component');
buttonElement.triggerEventHandler('click', null);
fixture.detectChanges();
await fixture.whenStable();
expect(terminal.textContent).toBe('ng generate component xyz');
//test#3 click ‘Angular Material’ button and assert on the
//displayed command in terminal
buttonElement = fixture.debugElement.query(debugEl => debugEl.name === 'button' && debugEl.nativeElement.textContent === 'Angular Material');
buttonElement.triggerEventHandler('click', null);
fixture.detectChanges();
await fixture.whenStable();
expect(terminal.textContent).toBe('ng add @angular/material');
expect(terminal.textContent).not.toBe('ng generate component xyz');
});
});

It is better but… the syntax seems very complex and no one can understand them in a quick glance.

Rewrite tests#2 — write implementation detail free tests by using Angular Testing Library

In this incremental step, I replaceKarmaand Jasmine with jest and also add @testing-library/angular-testing-library.

Replace Karma and Jasmine with Jest

Follow jest-preset-angular documentation

Add Angular Testing Library

Follow its documentation

After rewriting tests, you could see that these tests don’t rely on the implementation details. We now test the application in the same way as users are using it and assert from what users can see in the screen. These utilities functions make the tests easy to understand.

import { fireEvent, render, screen } from '@testing-library/angular';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
test('should render title and display angular cli command after clicking button - using angular testing library', async () => {
await render(AppComponent);
//test#1 assert on the displayed text
expect(screen.getByText(`my-first-project app is running!`))
//test#2 click ‘New Component’ button and assert on the
//displayed command in terminal
fireEvent.click(screen.getByRole('button', { name: /New Component/i }));
expect(screen.getByText(`ng generate component xyz`));
//test#3 click ‘Angular Material’ button and assert on the
//displayed command in terminal
fireEvent.click(screen.getByRole('button', { name: /Angular Material/i }));
expect(screen.getByText(`ng add @angular/material`));
expect(screen.queryByText(`ng generate component xyz`)).toBeNull;

});
});

Rewrite tests#3 — replace fireEvent with userEvent

This is the relatively small step. I add @testing-library/user-event library — “it simulates the real events that would happen in the browser as the user interacts with it”. You could see it as it provides the higher-level APIs from fireEvent.

Add user-event

Follow its documentation

import { render, screen } from '@testing-library/angular';
import userEvent from '@testing-library/user-event';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
test('should render title and display angular cli command after clicking button - using angular testing library, userEvent', async () => {
await render(AppComponent);

//test#1 assert on the displayed text
expect(screen.getByText(`my-first-project app is running!`))

//test#2 click ‘New Component’ button and assert on the
//displayed command in terminal
userEvent.click(screen.getByRole('button', { name: /New Component/i }));
expect(screen.getByText(`ng generate component xyz`));
//test#3 click ‘Angular Material’ button and assert on the
//displayed command in terminal
userEvent.click(screen.getByRole('button', { name: /Angular Material/i }));
expect(screen.getByText(`ng add @angular/material`));
expect(screen.queryByText(`ng generate component xyz`)).toBeNull;
});
});

Rewrite tests#4 — replace toBeNull with toBeInTheDocument

Another small step, the @testing-library/jest-dom library provides a set of custom jest matchers extending jest. These will make tests more declarative, clear to read and to maintain.

Add jest-dom

Follow its documentation

Now we will change from toBeNull to toBeInTheDocument — this will make tests more declarative. toBeInTheDocument allows to assert whether an element is present in the document or not.

import { render, screen } from '@testing-library/angular';
import userEvent from '@testing-library/user-event';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
test('should render title and display angular cli command after clicking button - using angular testing library, userEvent, jest-dom', async () => {
await render(AppComponent);
//test#1 assert on the displayed text
expect(screen.getByText(`my-first-project app is running!`))

//test#2 click ‘New Component’ button and assert on the
//displayed command in terminal
userEvent.click(screen.getByRole('button', { name: /New Component/i }));
expect(screen.getByText(`ng generate component xyz`));
//test#3 click ‘Angular Material’ button and assert on the
//displayed command in terminal
userEvent.click(screen.getByRole('button', { name: /Angular Material/i }));
expect(screen.getByText(`ng add @angular/material`));
expect(screen.queryByText(`ng generate component xyz`)).not.toBeInTheDocument();
});
});

Summary

I have 3 reasons why we need to write tests and Testing Library can help me improve on all aspects.

  1. Confidence on delivering the code to Production: Let me quote Guiding Principles from Testing Library “The more your tests resemble the way your software is used, the more confidence they can give you.
  2. Safety net for refactoring / implementing additional features: Writing implementation detail free tests can avoid test failure from refactoring. We can now be sure that test failure with a reason ❎ False negatives ❎ False positives.
  3. Documentation: Testing Library provides higher-level APIs making the tests more declarative and understandable. Anyone can look at the tests and should be able to understand use cases in these tested components.

--

--

Totsawin Jangprasert
Nerd For Tech

Lead Developer at ExxonMobil. Passion in frontend development. Love minimal design, travelling, reading, watching and Wes Anderson. Support Thai democracy |||