Introduction
In this article, I would like to share the experience I gained over the years with unit testing in Angular. In a nutshell I will be speaking about the followings:
- Why you should unit test?
- Why you should mock and what are the advantages/disadvantages?
- What are SIFERS and why you should care?
- What is the Angular Testing Library (ATL)?
- Testing using SIFERS
- Querying DOM elements and dispatching events
- What are jest-auto-spies and observer-spy?
Why?
I have seen many applications that do not contain any unit tests. Let me clarify why we need to write unit tests. Unit tests are a necessary aspect of any application. They provide us with assurance and confirmation about how the code should behave. They also serve as documentation to understand what the code is doing. Writing good tests helps us in understanding the design of the code. Not being able to write a unit test, indicates a bad design and usually tells us to refactor the code.
The more your tests resemble the way your software is used, the more
confidence they can give you.
Mocks
To make sure you can concentrate on the code that has to be tested, you must properly mock external dependencies. For instance, you should mock any services or other components that the component you are testing utilizes. It's not advised to import the real implementations (more on why below). Feel free to import pure components, though, if your component uses them as dependents. You may also import a shared module that contains all of its dependencies.
Disadvantages of not mocking
- You will be using the real implementation and are forced to mock all of its properties, methods, etc. You will end up in a rabbit hole, where you are suddenly mocking classes that several layer down the dependency tree.
- You will have to declare the nested components and provide all of its dependencies
- It takes longer for your tests to execute since the complete dependency tree must be resolved first.
- The state of your tests might not be correct.
- Your tests will suddenly start to fail if a dependency downstream changes.
- It becomes very difficult to debug the tests when an error occurs.
SIFERS
Let's start with the setup. Instead of using the beforeEach
to set up the testing environment, I use SIFERS.
Simple Injectable Functions Explicitly Returning State (SIFERS) is a way to capture what the tests should do when setting up the testing environment as well as returning a mutable clean state.
SIFERS use a setup
function that can receive optional arguments to set up the testing environment. This is the biggest benefit compared to beforeEach
. beforeEach
gets called automatically before each unit test, hindering us to set any mocked values on the dependencies that are needed during the initialization of the component/service.
Using SIFERS allows us to be much more flexible with the testing environment to mock values before the component/service is initialized. The setup
function is then called in every test and can return a state for your test (Classes, Properties etc..).
One thing I like in my SIFERS is to try to keep the number of arguments small. If you need multiple arguments or your list is growing over a certain number of parameters, you can use an interface. This will keep your code organized and easy to read.
A simple setup
function can look like this:
function setup({ value = false }) {
const mockService: Partial<RealService> = {
someFunction: jest.fn()
.mockReturnValue(value ? 'foo' : 'bar'),
};
const service = new MyService(mockService);
return {
service,
mockService,
};
}
Using the above example, the tests can look like this:
it('returns foo when feature flag is enabled', () => {
// Pass true into the setup to ensure that
// someFunction returns foo
const { service } = setup(true);
expect(service.someFunction()).toEqual('foo');
});
it('returns bar when feature flag is disabled', () => {
// Pass false into the setup to ensure that
// someFunction returns bar
const { service } = setup(false);
expect(service.someFunction()).toEqual('bar');
});
I am not going into the full details of SIFERS here as it's already very well explained by the author Moshe Kolodny.
Angular Testing Library (ATL)
I am a big fan of the ATL library and try to use it in all of my projects. ATL is a very lightweight solution to test Angular components. ATL is described as:
Angular Testing Library provides utility functions to interact with Angular components, in the same way as a user would.
Tim Deschryver
Let's start with the setup of module. Instead of using TestBed.configureTestingModule
, you need to use the render
method. Keep in mind that the render
method should only be used if you are testing components. Services can be tested without ATL and the render
method.
There are many examples of how to use the ATL here. They contain everything from Components, Forms, Input/Output, NGRX, Directives, Angular Material, Signals etc. Tim Deschryer also has a very detailed article with lots of examples that I recommend reading.
Here is an example using a SIFER
, and the render
method. You will also notice that I am using the createSpyFromClass
method to mock the classes, which automatically mocks all functions, properties and even observables for us automatically. More on that is covered later in the article.
import { render } from '@testing-library/angular';
import { createSpyFromClass } from 'jest-auto-spies';
// ... other imports
async function setup({ enableFlag = false }) {
const mockMySomeService = createSpyFromClass(MyService);
mockMySomeService.doSomething.mockReturnValue(enableFlag);
const { fixture } = await render(AppComponent, {
imports: [...],
providers: [{
provide: MyService,
useValue: mockMySomeService
}],
});
}
Setting declarations
Similar to TestBed
, you can pass in a collection of components and directives using declarations. The syntax is the same.
However if you are importing a module, that already contains the component, then you need to set the excludeComponentDeclaration to true
.
Some other useful properties that you may need to use in your tests from the ATL API. See the full API for examples.
Setting providers
Use the componentProviders to set the providers for your component.
Set @Input/@Output
Setting the @Input
and @Output
properties of the component can be achieved using componentProperties. This allows you to set them both at the same time.
If you need more control over those properties you can use the componentInputs or componentOutputs. In a TestBed
based test, you would probably just set the input through the component instance itself.
Testing Services
To test services, you do not need to use the ATL or TestBed
. Instead, you can pass the mocked dependencies directly into the constructor of the service to be tested. The below example mocks the LogService
and TableService
.
// some.service.ts
@Injectable({ providedIn: 'root' })
export class SomeService {
constructor(
private readonly logService: LogService,
private readonly tableService: TableService) {}
}
// some.service.spec.ts
async function setup() {
const mockLogService = createSpyFromClass(LogService);
const mockTableService = createSpyFromClass(TableService);
const service = new SomeService(
mockLogService,
mockTableService
);
return {
service,
mockLogService,
mockTableService,
};
}
Testing Components
A component should always test the behaviour of the public API. Private APIs are never tested explicitly. To test the components, use the DOM as much as possible. This is the same behaviour you would expect from your user and you want your test to mimic this as much as possible. The ATL helps us with this. This is also called a shallow testing.
Do not treat all public methods of your component as the public API that you can test directly from your unit tests. They are only public for your template. The public methods are called from the DOM (i.e. a button click) and should be tested in the same manner.
Example of a component to be tested:
// app-foo.component.ts
@Component({
selector: 'app-foo',
template: `
<input
data-testid='my-input'
(keydown)='handleKeyDown($event)' />`
})
export class FooComponent {
constructor(private readonly someService: SomeService) {}
handleKeyDown(value: string) {
this.someService.foo(value);
}
}
Your SIFER setup
could look like this:
// app-foo.component.spec.ts
async function setup() {
const mockSomeService = createSpyFromClass(SomeService);
const { fixture } = await render(FooComponent, {
providers: [{
provide: SomeService,
useValue: mockSomeService
}],
});
return {
fixture,
mockSomeService,
fixture.componentInstance
}
}
Do not do this. The test is directly accessing the public API, bypassing the template entirely. You could remove the DOM input
element entirely and the test would still pass. This is a false positive test and does not serve any purpose.
it('emits a value', async () => {
const { mockSomeService, component } = await setup(...);
component.handleKeyDown(value);
expect(mockSomeService.foo)
.toHaveBeenCalledWith(value);
})
This is the correct way to test the function. Here I am using screen
to get access to the input
element and userEvent
to emit DOM events.
import { screen } from '@testing-library/angular';
import userEvent from '@testing-library/user-event';
it('emits a value', async () => {
const { mockSomeService, component } = await setup(...);
const textbox = screen.queryByTestId('my-input');
userEvent.type(textbox, 'foo,');
userEvent.keyboard('{Enter}');
expect(mockSomeService.foo)
.toHaveBeenCalledWith(value);
})
Query DOM Elements using screen
The screen
API provides several powerful functions to query the DOM. Functions like waitFor
or findBy
return a promise and can be used to find elements that toggle their visibility dynamically based of some conditions.
It is recommended to query the elements in the following order. See the API for the full priority list and their descriptions.
- getByRole
- getByLabelText
- getByPlaceholderText
- getByText
- getByDisplayValue
- getByAltText
- getByTitle
- getByTestId
Dispatching DOM Actions
ATL comes with two APIs to dispatch events through the DOM:
userEvent
is preferred over fireEvents
(provided by the Events
API). The difference as provided in the docs is:
fireEvent dispatches DOM events, whereas user-event simulates full interactions, which may fire multiple events and do additional checks along the way.
jest-auto-spies
To Mock classes, I use jest-auto-spies
. jest-auto-spies
return a mocked type safe class without having to manually define all of its functions and properties. Besides saving a lot of time, it also provides helper functions for observables, methods, getters, and setters. The below example is using the createSpyFromClass
method to create a spy class.
If you need to provide dependencies into your module directly, you can use provideAutoSpy(MyClass)
, which is a shortcut for {provide: MyClass, useValue: createSpyFromClass(MyClass)}
.
Keep in mind that this should only be used if you don't need to mock any functions of that class. If you need to mock something from that class, then you should provide the mocked instance.
Here are a few examples:
Create a simple spy on a class
const mockMyService = createSpyFromClass(MyService);
Create a spy on class and emit a value on an observable
const mockMyService = createSpyFromClass(MyService, {
observablePropsToSpyOn: ['foo$'],
});
mockMyService.foo$.nextWith('bar');
Create a spy on a class and function
const mockMyService = createSpyFromClass(MyService, {
methodsToSpyOn: ['foo'],
});
mockMyService.foo.mockReturnValue('bar');
See jest-auto-spies
for more helper functions and its usage.
Use observer-spy instead of subscribe / avoid the done
callback
To test asynchronous code, I use subscribeSpyTo
from the observer-spy
library instead of subscribing to the observable. This also helps to get rid of the done
callback.
The done
function was introduced to test async code. However, it is very error prone and unpredictable. Because of that you might get false positives making your tests green, while other times you might get a timeout.
Note that there is a lint rule that you can use to prohibit the usage of the done
callback.
You also do not need to unsubscribe from the observable, as there is an auto unsubscribe hook
in place. This gets called automatically in afterEach
.
Here are a few examples on how to use the library taken from their readme. See more examples in the readme.
const fakeObservable = of('first', 'second', 'third');
const observerSpy = subscribeSpyTo(fakeObservable);
// No need to unsubscribe, as the have an auto-unsubscribe in place.
// observerSpy.unsubscribe();
// Expectations:
expect(observerSpy.getFirstValue()).toBe('first');
expect(observerSpy.receivedNext()).toBeTruthy();
expect(observerSpy.getValues()).toEqual(fakeValues);
expect(observerSpy.getValuesLength()).toBe(3);
expect(observerSpy.getValueAt(1)).toBe('second');
expect(observerSpy.getLastValue()).toBe('third');
expect(observerSpy.receivedComplete()).toBeTruthy();
Resources
- Angular Testing Library
- Angular Testing Library Examples
- Angular Testing Guide
- NgRx Testing
- A Guide to Robust Angular Applications
- Good testing practices with 🦔 Angular Testing Library
Summary
The article explored my experience with unit testing in Angular, emphasizing its importance for code quality and maintainability. It covered the necessity of proper mocking to isolate units and avoid reliance on real implementations. SIFERS were highlighted as a flexible approach for setting up the testing environment and enhancing test readability. The Angular Testing Library (ATL) was introduced as a valuable tool for component testing, mimicking user interactions as close as possible to real users. Additionally, the article mentioned useful tools like jest-auto-spies for efficient class mocking and provided resources for further exploration of testing practices in Angular.
Top comments (2)
Hi KSonu Kapoor,
Your tips are very useful
Thanks for sharing
Thank you. I am glad that you find them useful.