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.
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.
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.
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.
Want to conquer your next JavaScript interview? Download my FREE PDF - Pass Your JS Interview with Confidence and start preparing for success today!
📚 Source code of what we've done