Write better tests with Jest and Testing Library
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.
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 replaceKarma
and 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.
- 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.”
- 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.
- 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.