This is the first post in a series that I want to do, where I study some subjects during a period and compile the best about each one here. Now, let's talk about unit tests! Today I want to bring some tips to improve your tests, in terms of performance and especially quality/maintainability.
1. Choose Jest instead of Jasmine
π
Jasmine
is the standard testing library when creating an Angular project, but Jest
has great benefits:
- Faster execution (suites run in parallel)
- Robust CLI
- Clear documentation
- Snapshot tests
- Useful reports
2. Avoid implementation details ππ»ββοΈ
Here we are on the most important topic, and to better understand the motivations, I recommend reading this post. You can read it now and then we follow here!
From the reading above, our idea is to end with 2 scenarios:
- Tests that break when the code is refactored
- Tests that pass when the application is broken
2.1. Use Angular Testing Library over TestBed
π
Now you realize how bad the implementation details can be in your tests. Testing Library was created mainly to solve these problems in the frontend, through tests based on users' interactions and what they actually see (
HTML
). It supports all the most used frameworks (React, Vue, Angular, ...), and once you learn, you're able to use it in any project.
2.2. Mock your requests with Mock Service Worker βοΈ
Complementing the previous topic,
MSW
is a great tool to mock your requests. It removes the needing to mock directly the API service (from Angular) called in a component and mock the HTTP call itself. Looking for tests more resilient to changes, that is a great approach since the mock will be totally independent of the service (enabling refactorings without broken tests) and will be closer to the real scenario, where the request is made and intercepted byMSW
. We have an example below:
class UsersApiService {
constructor(private http: HttpClient) {}
get(): Observable<User[]> {
return this.http.get<User[]>('https://jsonplaceholder.typicode.com/users');
}
}
/**
* Without MSW β
* - if the name of the service/method changes, the tests breaks
* - if the endpoint URL changes, the test continues to pass
*/
const service = TestBed.inject(UsersApiService);
jest
.spyOn(service, 'get')
.mockReturnValue(of([{ name: 'Foo' }, { name: 'Bar' }]));
/**
* With MSW β
* - if the name of the service/method changes, the test continues to pass
* - if the endpoint URL changes, the test breaks
*/
rest.get('https://jsonplaceholder.typicode.com/users', (req, res, ctx) => {
return res(ctx.json([{ name: 'Foo' }, { name: 'Bar' }]));
});
2.3. Define test id
for your elements π
As we try to ensure that the tests won't break by refactorings, set
test id
to the elements/components it's an interesting practice, so they can be found in the suites. For 2 reasons:
- They're not easily changed as placeholders, labels, CSS classes, ...
- Developers who see them will know that those elements are used in testing
Let's see an example:
// Without test id β
const searchBar = screen.getByPlaceholderText('Search...');
const submitButton = screen.getByLabelText('Submit');
// With test id β
const searchBar = screen.getByTestId('search-bar');
const submitButton = screen.getByTestId('submit-button');
3. Only load dependencies that are really needed in the testing module β
In a project that I worked on, over time, we felt a loss of performance in the execution of the tests. We made a task force to identify the causes, and the main problems were the unnecessary dependencies in the testing modules, often carried by heavy modules that were not used completely. As an example, if you use Bootstrap
in a project and want to test a component that contains a Datepicker
, there is no reason to load the entire module in the tests. Just load what is required.
// Loads all the Bootstrap module β
const screen = await render(MyComponent, {
imports: [NgbModule],
});
// Loads only the Datepicker module from Bootstrap β
const screen = await render(MyComponent, {
imports: [NgbDatepickerModule],
});
4. Don't use coverage to measure the quality of your application π
The coverage report is nothing more than a number to help you see which areas of your app have not been tested. The coverage considers the lines of code that have been executed. So don't aim for 100% coverage. Reaching that number doesn't mean that everything is working, just that all the code is executed at some point when running the tests.
Test it based on the business logic, understand what cannot fail, and write tests that really add value to the application. Don't do weird things (flows that real users wouldn't do) simply to achieve more coverage.
Conclusion π
These are the topics I had to bring up for today. I hope they have helped you in some way, and if you have any point to add, please let me know in the comments.
Top comments (2)
Thanks for that last tip. I was thinking I should aspire to get at least 90% code coverage because it was something expected to be done, but with this in mind, it makes more sense for me just to reach for what is actually going to be executed by end-users of my app.
π« No implementation details allowed
Great post, thanks for writing it!