How to test Angular components using Jest nice and easy
Wagner Caetano
August 26, 2023
How to test Angular components using Jest nice and easy
1. How angular tests are structured
In the context of Angular and Jest, "shallow testing" and "unit testing" refer to two different approaches to testing components and their behavior.
The difference between shallow testing and unit testing
Let's break down the differences between these two concepts:
-
Unit Testing: It involves testing individual units of code, often isolated from their dependencies, to ensure they work as intended. Mocks or stubs are often used to isolate dependencies and create controlled testing environments.
For example, when unit testing an Angular component, you might:
- Test the logic of a specific method within the component.
- Verify that properties are set correctly after certain actions.
- Ensure that event handlers or input/output bindings work as expected.
-
Shallow Testing: It involves testing a component while rendering itself as a whole and their immediate child components as "shallow" placeholders. Shallow testing is particularly useful when you want to test the html template.
For example, in the context of an Angular component, shallow testing might involve:
- Testing how the component renders certain data and responds to user interactions.
- Verifying that the component's template structure and bindings are correct.
- Ignoring the actual behavior of child components by replacing them with shallow representations.
Showing you why 99% of tests don’t need TestBed
If you create a test file or component folder using Ng or other generation tool for Angular files, you will notice that it creates the test with a huge setup structure.
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MyComponent } from './my.component';
describe('MyComponent', () => {
let component: MyComponent;
let fixture: ComponentFixture<MyComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ MyComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(MyComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
This is the default setup for Angular tests. It is using TestBed to create the component and all its dependencies. This is a very heavy setup and it is not needed for most of the tests. So a way to create the same test without using TestBed is:
import { MyComponent } from './my.component';
describe('MyComponent', () => {
let component: MyComponent;
beforeEach(() => {
component = new MyComponent();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
Notice that this test has the same setup outcome as the before, and now we are just instantiating a new component and testing it.
The mindset behind a good test scenario
What is a good test scenario?
We can use the concept of Gherkin as a direction, basically it's split in 3 parts: given, when, then.
- Given: The initial context or state of the system. Here's where we setup everything that we need outside of the test scenario, like services mocks, data mocks, etc.
- When: The action or event that occurs. Here's where we triggers that we want to test, it can be a function call, a click, a key press, etc.
- Then: The expected outcome. Here's where we check if the expected outcome is the same as the actual outcome, or if the calls are called in a expected amount of times, etc.
This idea helps you setup your test initially so later you just need to write the code to do what it needs to do. For example:
it('should validate getCustomer', () => {
//given: mock the return of the moethod getCustomer
//when: triggers the component method that calls the service
//then: expect that any logic or treatment is done correctly
//then: expect that the service is called only once
//then: expect that the component now has the value returned and treated stored in a variable
});
It can be a little bit over do for some cases, but it helps you to think about the test before you write it, especially if you are starting now to write tests scenarios.
Beyond that we should also finish our scenarios with a cleanup
- Cleanup: The cleanup of the test. Here's where we clean the calls of everything we setup in the given part, like services mocks, data mocks, etc.
Using jest, we can call two kinds of methods for that:
// Cleans the calls and the returns of all mocks
jest.clearAllMocks();
// Cleans the mock implementation itself
jest.resetAllMocks();
How to mock all methods properly
The idea is to bring the mock the closest to the real scenario as possible, so we can have a better control of the test and the expected outcome is acurate in reality.
Using jest as a facilitator
Jest has a method called 'fn()' that creates a mock function, and it has a lot of methods that helps us to control the mock and the expected outcome. For example:
// example of a http call
public getCustomers(): Observable<Customer[]> {
return this.http.get<Customer[]>(`${this.url}/customer`);
}
In that case a empty mock could be:
const customersService = {
getCustomers: jest.fn().mockReturnValue(of([])),
}
As you might see, we also used the method 'mockReturnValue' to return a value when the method is called, because the method 'getCustomers' returns an Observable, so we need to return a value to the mock.
Notice that the method fn() returns a series of auxiliar methods that we can use to control the mock, like:
- mock.calls: Returns an array of the calls done to the mock, each call is an array of the arguments passed to the mock.
- mock.results: Returns an array of the results of the calls done to the mock, each result is an object with the value and the status of the call.
- mockImplementation: Allows you to set the implementation of the mock.
- mockImplementationOnce: Allows you to set the implementation of the mock for a specific call.
- mockReturnValue: Allows you to set the return value of the mock.
- mockResolvedValue: Allows you to set the resolved value of the mock.
- mockRejectedValue: Allows you to set the rejected value of the mock.
and many more, you can check the full list in the jestjs.iodocsenmock functions.
How to validate something in chain
Sometimes we need to validate something in chain, like a method that returns a object that we need to call a method to get a value, and we need to validate that value. To create that mock we can:
const customersService = {
getCustomers: jest.fn().mockReturnValue(of([{ name: 'John' }])),
}
const serviceChooser = {
getService: jest.fn().mockReturnValue(customersService),
}
How to create a mock for a dinamic scenario
Imagine the scenario, where depending on the input of a method you return a different object or it do a different logic, to create a mock like that we can:
const customersService = {
getCustomers: jest.fn().mockImplementation((input) => {
if (input === 'John') {
return of([{ name: 'John' }]);
} else {
return of([{ name: 'Mary' }]);
}
}),
}
As you can see we are using the auxiliar method 'mockImplementation' to set the implementation of the mock, and we are using the input of the method to return a different value.
We could also add 'Once' to it, that way it would work as a mock just once, and then you would need to mock again.
That's also useful for cache testing, like imagine a scenario where the first time it shouldn't return something, but then the next one it should return something, we could do:
const customersService = {
getCustomers: jest.fn()
.mockImplementationOnce(() => of([]))
.mockImplementationOnce(() => of([{ name: 'John' }])),
}
How to know for sure that your async test is running correctly
Using fakeAsync
The function fakeAsync is a function that allows you to run a test in a synchronous way, so you don't need to use the async/await syntax, and you can use the tick() function to simulate the time passing.
it('should validate getCustomer', fakeAsync(() => {
//when: triggers the component method that calls the service
tick(); // this will simulate the time passing
//then: expect that any logic or treatment is done correctly
}));
You can also use other methods like:
- flush(): Simulates the passage of time until all pending asynchronous activities finish.
- flushMicrotasks(): Simulates the passage of time until all pending microtasks finish.
- tick(millis): Simulates the passage of time until all pending asynchronous activities finish. The microtasks queue is drained at the very start of this function and after any timer callback has been executed.
- tickMacroTasks(millis): Simulates the passage of time until all pending asynchronous activities finish. The microtasks queue is drained at the very start of this function and after any timer callback has been executed.
- discardPeriodicTasks(): Discard all remaining periodic tasks.
- flushPeriodicTasks(): Flushes all pending periodic tasks.
Using async/await
The async/await syntax is a way to write asynchronous code in a synchronous way, so you don't need to use the fakeAsync function, and you can use the await keyword to wait for the promise to resolve.
it('should validate getCustomer', async () => {
//when: triggers the component method that calls the service
await fixture.whenStable(); // this will wait for the promise to resolve
//then: expect that any logic or treatment is done correctly
});
But can we use it always ?
No, we can't, because the async/await syntax is a way to write asynchronous code in a synchronous way, so it doesn't work with all asynchronous code, like setTimeout, setInterval, etc.
// won't work
public getWaitToShowSomething(): void {
interval(1000).subscribe(() => {
this.showSomething = true;
});
}
it('should validate getWaitToShowSomething', async () => {
const response = await component.getWaitToShowSomething();
expect(response).toBe(true);
});
// will work
public getWaitToShowSomething(): Promise<boolean> {
return new Promise((resolve) => {
interval(1000).subscribe(() => {
this.showSomething = true;
resolve(true);
});
});
}
it('should validate getWaitToShowSomething', async () => {
const response = await component.getWaitToShowSomething();
expect(response).toBe(true);
});
Notice that when you use a observable, and you don't want to convert it into a promise, async/await won't work, because it's not a promise, so you need to use the fakeAsync function.
Using done as a callback
The done callback is a function that you can use to tell the test that it's done, so it can continue to the next test, it's useful when you have a asynchronous code that you can't convert into a promise or observable.
it('should validate getCustomer', (done) => {
customersService.getCustomers().subscribe(() => {
//then: expect that any logic or treatment is done correctly
done();
});
});
In that case we are using the done callback to tell the test that it's done, so it can continue to the next test.If the done callback is not called, the test will fail after 5000ms.
This scenario ensures that the classic false positive of the test is not happening, where the test passes because it is not waiting for the asynchronous code to finish.
Make your expectations as clear as possible
Its better to have a toHaveBeenCalledTimes(1) than a toHaveBeenCalled()
The idea is to make your expectations as clear as possible, so you can know exactly what is happening in your test, and you can know exactly what is failing.
The toHaveBeenCalled() method is a method that checks if the mock was called, but it doesn't check how many times it was called, so it's better to use the toHaveBeenCalledTimes(1) method, that way you know exactly how many times it was called.
it('should validate getCustomer', () => {
//given: mock the return of the moethod getCustomer
customersService.getCustomers = jest.fn().mockReturnValue(of([]));
//when: triggers the component method that calls the service
component.getCustomer();
//then: expect that any logic or treatment is done correctly
expect(customersService.getCustomers).toHaveBeenCalled();
});
In that case the test will pass, but if we change the mock to return a value, the test will fail, because the mock was called more than once.
it('should validate getCustomer', () => {
//given: mock the return of the moethod getCustomer
customersService.getCustomers = jest.fn().mockReturnValue(of([{ name: 'John' }]));
//when: triggers the component method that calls the service
component.getCustomer();
//then: expect that any logic or treatment is done correctly
expect(customersService.getCustomers).toHaveBeenCalledTimes(1);
});
We can also use it for when we don't want the mock to be called, we can use the toHaveBeenCalledTimes(0) method.
it('should validate getCustomer', () => {
//given: mock the return of the moethod getCustomer
customersService.getCustomers = jest.fn().mockReturnValue(of([{ name: 'John' }]));
//when: triggers the component method that calls the service
component.getCustomer();
//then: expect that any logic or treatment is done correctly
expect(customersService.getCustomers).toHaveBeenCalledTimes(0);
});
Its better to have a toHaveBeenCalledWith('John') than a toHaveBeenCalled()
The toHaveBeenCalled() method is a method that checks if the mock was called, but it doesn't check with what arguments it was called, so it's better to use the toHaveBeenCalledWith('John') method, that way you know exactly with what arguments it was called.
it('should validate getCustomer', () => {
//given: mock the return of the moethod getCustomer
customersService.getCustomers = jest.fn().mockReturnValue(of([{ name: 'John' }]));
//when: triggers the component method that calls the service
component.getCustomer();
//then: expect that any logic or treatment is done correctly
expect(customersService.getCustomers).toHaveBeenCalledWith('John');
});
That's it for now, I hope you enjoyed it, and I hope it helps you to write better tests.
Sources:
medium.com@PushpikaWanangular unit testing isolate and shallow jasmine d...
medium.com@getsafwhy shallow rendering is import in angular unit te...