Many great engineers secretly dislike writing tests, but testing isn’t about achieving perfection—it’s about avoiding potential disasters. It’s less about making everything flawless and more about catching critical issues early so that small problems don’t grow into major ones later.
In software development, even a tiny bug can quickly snowball into bigger issues that disrupt the user experience and overall functionality of your site or app. For example, a small visual glitch might make a button hard to click, or worse, break a key feature like checkout on an e-commerce site.
Without proper testing in place, such bugs often slip into production, where fixing them is up to 30 times more expensive than catching them during development
Testing isn’t just a nice-to-have; it’s a crucial part of making sure your code stays stable, and your users stay happy.
Test-Driven Development (TDD) is a development approach where you write tests before writing the actual code. Although it might seem like an extra step at first, the long-term benefits far outweigh the initial effort. With TDD, you start by defining what the code should do, then write a failing test, and only then write the minimal code required to pass the test. This process is repeated in small cycles.
The biggest advantage of TDD is that it leads to better code quality. Since tests guide the development process, it ensures that the code is not only functional but also thoroughly tested. This results in fewer bugs, easier refactoring, and a cleaner, more maintainable codebase.
TDD also encourages developers to think from the user’s perspective, focusing on what the code should achieve rather than how it should be written. The continuous feedback loop created by running tests ensures that small issues are caught early, preventing larger problems later. While TDD requires upfront effort, the payoff comes in the form of reduced debugging time, fewer defects in production, and more confident releases.
Jest is a powerful and flexible testing framework designed primarily for JavaScript applications. It comes with built-in features like mocking, assertion libraries, and snapshot testing, which simplify testing without needing external tools.
Jest has become a popular choice for frontend testing over Karma and Jasmine for several reasons.
First, speed is a major factor—Jest runs tests faster thanks to its ability to execute tests in parallel and only rerun tests for changed files, which is especially helpful in large projects.
The tests that took 4–5 minutes on KARMA only takes about 1–2 minutes on jest. This is particularly important when using CI-CD ( Continous Integration/Continous Delivery).Since the tests are faster the execution time of CI-CD will also be reduced.
Second, Jest provides an all-in-one testing framework, offering built-in mocking, assertion libraries, and snapshot testing, whereas Karma and Jasmine require more external configuration and tools. Additionally, Jest’s zero-config setup makes it much simpler to get started, compared to Karma, which often requires more manual setup with a test runner, browser configuration, and additional dependencies.
Another advantage is that Jest has excellent integration with modern frameworks like Angular and React, making it more adaptable to evolving frontend ecosystems.
Finally, Jest’s developer-friendly features, such as clear error messages, built-in coverage reports, and snapshot testing, streamline the entire testing process, helping developers maintain quality without much overhead.
Writing your first test in Jest is simple and can be done in just a few steps. To begin, make sure you’ve installed Jest in your project by running:
npm install --save-dev jest
Once installed, create a test file. For example, if you’re testing a simple function, say add(a, b), your test might look like this:
// add.js
export function add(a, b) {
return a + b;
}
Then, in your test file:
// add.test.js
import { add } from './add';
test('adds 1 + 2 to equal 3', () => {
expect (add(1, 2)).toBe (3);
});
This simple test checks if the add function works correctly. To run the test, just execute:
npx jest
Jest will display whether your test passed or failed, making it easy to ensure that your code behaves as expected. Starting with small, focused tests like this helps catch issues early and build confidence in your codebase as it grows.
Jest makes it easy to write structured and readable tests. Its flexible API, combined with tools like @testing-library/dom and jest-auto-spies, enables you to simulate user interactions, mock dependencies, and thoroughly test your components in Angular applications. In this guide, we’ll explore how to write effective unit tests using these tools, focusing on a practical example with real-world scenarios.
import { render, screen } from '@testing-library/angular'; // Importing @testing-library methods for Angular
import { createSpyFromClass } from 'jest-auto-spies'; // Importing jest-auto-spies for creating spies
import { MyComponent } from './my-component.component'; // The component we are testing
import { MyService } from './my-service.service'; // A service used in the component
describe('MyComponent', () => {
let mockMyService: MyService;
beforeEach(async () => {
// Creating a spy object for MyService using createSpyFromClass
mockMyService = createSpyFromClass(MyService);
// Rendering the component with mocked service
await render(MyComponent, {
providers: [{ provide: MyService, useValue: mockMyService }], // Inject the mocked service
});
});
test('should call fetchData when button is clicked', async () => {
// Arrange: Spy on fetchData method in MyService
mockMyService.fetchData.mockResolvedValue(['Item1', 'Item2']);
// Act: Find the button and simulate a click
const button = screen.getByRole('button', { name: /fetch data/i });
button.click();
// Assert: Check that fetchData was called after the button click
expect(mockMyService.fetchData).toHaveBeenCalled();
});
test('should display fetched data in the list', async () => {
// Arrange: Mock data returned by fetchData
mockMyService.fetchData.mockResolvedValue(['Item1', 'Item2']);
// Act: Trigger data fetching by simulating a button click
const button = screen.getByRole('button', { name: /fetch data/i });
button.click();
// Assert: Wait for the list items to appear and check their text content
const items = await screen.findAllByRole('listitem');
expect(items).toHaveLength(2);
expect(items[0]).toHaveTextContent('Item1');
expect(items[1]).toHaveTextContent('Item2');
});
test('should handle error when fetchData fails', async () => {
// Arrange: Make fetchData throw an error
mockMyService.fetchData.mockRejectedValue(new Error('Failed to fetch data'));
// Act: Simulate the button click to fetch data
const button = screen.getByRole('button', { name: /fetch data/i });
button.click();
// Assert: Check if error message is displayed
const errorMessage = await screen.findByText(/failed to fetch data/i);
expect(errorMessage).toBeInTheDocument();
});
test('should display a loading indicator while fetching data', async () => {
// Arrange: Make fetchData take some time
mockMyService.fetchData.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve(['Item1']), 1000))
);
// Act: Click the button to start fetching data
const button = screen.getByRole('button', { name: /fetch data/i });
button.click();
// Assert: Ensure the loading indicator is displayed during fetch
const loading = screen.getByText(/loading.../i);
expect(loading).toBeInTheDocument();
// Wait for data to be fetched and check that the loading indicator is gone
await screen.findByRole('listitem');
expect(loading).not.toBeInTheDocument();
});
});
Simple Injectable Functions Explicitly Returning State (SIFERS) is an approach for setting up the testing environment by explicitly returning a clean, mutable state for each test. This method allows much more flexibility than using beforeEach, which automatically runs before every test and can limit your ability to mock or adjust values on services and dependencies before initialization.
With SIFERS, a setup function is used that takes optional arguments to customize the testing environment as needed. This allows you to mock or adjust values before initializing the component or service under test, giving you more control over the test configuration. The setup function is called within each test and returns the state that you need for that specific test case.
Here’s an example setup function that initializes a component and service, with the flexibility to mock a service value:
function setup({ mockValue = 'default' } = {}) {
const mockApiService: Partial<ApiService> = {
fetchData: jest.fn().mockReturnValue(mockValue),
};
const component = new MyComponent(mockApiService as ApiService);
return {
component,
mockApiService,
};
}
In this example, the setup function allows the mockApiService to be customized based on the test case, before the MyComponent is initialized. Each test can then call setup with different values, enabling tailored mock setups and ensuring each test starts with a clean, configurable state. This makes SIFERS ideal for cases where different scenarios need distinct initial configurations.
Here’s how the setup function from the previous example could be used in a test:
// Example of a Jest test using SIFERS setup function
describe('MyComponent', () => {
it('should return default mock value', () => {
const { component, mockApiService } = setup();
// No mockValue passed, uses 'default'
expect(component.getData()).toBe('default');
// Calls the function that uses the mock service
expect(mockApiService.fetchData).toHaveBeenCalled();
});
it('should return custom mock value', () => {
const { component, mockApiService } = setup({ mockValue: 'custom value' });
// Passing custom value
expect(component.getData()).toBe('custom value');
// Verifying number of calls to the mock
expect(mockApiService.fetchData).toHaveBeenCalledTimes(1);
});
});
When writing tests with Jest, adhering to best practices ensures maintainability, reliability, and readability of your test suite. Below are some key practices to follow:
By following these best practices, you’ll write effective, maintainable, and scalable tests with Jest.
Testing is essential for building reliable software, but it comes with its own challenges. One major drawback is the time required to write comprehensive tests, which can slow down development. Additionally, some developers write unnecessary tests just to boost code coverage, without focusing on actual functionality. This practice leads to wasted effort and doesn’t improve code quality, creating a false sense of security. To avoid these pitfalls, it’s crucial to write meaningful tests that target critical parts of your application, rather than aiming for arbitrary coverage numbers.
Ready to take your code quality to the next level?
Let’s work together to ensure your applications stay stable and reliable. Contact us today to learn how our tailored testing solutions and best practices can help you!