Testing Reactive Angular Components - A Closer Look
Testing Reactive Angular Components: A Closer Look
In this article, we will take a look at how to test the reactive components in Angular, especially with some
of the nifty new features that have come along. Our case study involves a component we created for
demonstration, UsersComponent
. Together, we'll unpack its code and walk through the testing process,
highlighting on the why's and how's of each step.
Getting to Know UsersComponent
First off, let's chat about our UsersComponent
. It's a nifty standalone component designed to handle a user
list in a reactive manner, making good use of Angular's reactive features
like signal and effect() for
handling the data and keep the UI fresh.
@Component({
standalone: true,
// Component metadata...
})
export class UsersComponent implements OnInit {
private readonly usersService = inject(UsersService);
readonly dataSource: MatTableDataSource<User> =
new MatTableDataSource<User>([]);
readonly users: WritableSignal<User[]> = signal([]);
constructor() {
effect(() => {
this.dataSource.data = this.users();
});
}
async ngOnInit(): Promise<void> {
this.users.set(await lastValueFrom(this.usersService.getUsers()));
}
addUser(): void {
const iter = this.users().length + 1;
this.users.update(users => [...users, newUser]);
}
removeUser(id: string): void {
this.users.update(users => users.filter((user) => user.id !== id));
}
}
The constructor is where the magic starts. We've got an effect()
that set up the stage and ensures the
MatTableDataSource is always reflecting
the latest users' data. It keeps our UI accurately updated, no manual refreshes needed.
When ngOnInit() kicks in, it's all about fetching the async data and setting it up in the users signal. This action gets the ball rolling for the effect to do its thing and update the UI.
We have also implemented methods to add a new user or take one out. The addUser()
and removeUser()
functions tweak the users signal, which also triggers the effect()
to update the data source accordingly.
The How-To of Testing
Now, when it comes to testing these reactive Angular pieces, there's a bit of know-how involved. We need to
understand several Angular-specific methods and concepts, along with strategies to avoid calls to the real
services. We're tackling TestBed.flushEffects()
, the mechanics behind effect()
, signal.update()
,
signal.set()
and fixture.whenStable()
method, not to mention Jasmine's spyOn
to keep our service calls in
check.
Understanding TestBed.flushEffects()
The TestBed.flushEffects() method, introduced in recent Angular updates (PR #51049 and PR #53843), is super important for testing components with effects. It ensures that all the queued effects are executed in the test environment. This is especially useful for those asynchronous moments when you want to make sure everything's settled before making your assertions.
The Trio: effect(), signal.set(), and signal.update()
In our UsersComponent
, we used the power of effect()
,
signal.update(), and
signal.set() to keep things reactive and fresh.
The effect()
function allows us to create a reactive link between the users signal and
the MatTableDataSource
. This means whenever the users signal changes, the effect()
function automatically
updates dataSource.data
, ensuring the UI stays in sync with the state.
signal.set()
is our direct line to replace the signal's current value and kick the effect()
into gear,
updating the data source with the fresh list of users.
signal.set([...users, newUser]);
And for those times when you need to finesse the current signal value, signal.update()
is the go-to,
allowing tweaking the signal's value based on its current state. Perfect for when you want to add a new user
without replacing the whole users list.
signal.update(users => [...users, newUser]);
Ensuring Stability with fixture.whenStable()
Asynchronous operations are the norm when fetching data, we need to ensure that our tests wait for these operations to complete. This is where whenStable() comes into play.
await fixture.whenStable();
This function returns a promise that resolves when all pending asynchronous activities within the component are completed. It's essential for ensuring that our assertions are made after the component has finished processing the data.
Keeping it Real with Jasmine spyOn
We definitely don't want our tests making actual calls out to the real services. So, we bring
in Jasmine's spyOn method to intercept calls to
the UsersService.getUsers()
method and provide controlled, predictable responses.
beforeEach(() => {
spyOn(usersService, 'getUsers').and.returnValue(of(mockedUsers));
...
});
By doing so, we simulate the service's behavior without making actual HTTP requests. This approach ensures that our tests remain fast and are not affected by external factors such as network latency or service availability.
Firing up ngOnInit()
We used fixture.detectChanges() to work
in the beforeEach to get the component's lifecycle
rolling and make sure ngOnInit
does its thing.
beforeEach(() => {
...
fixture.detectChanges();
});
This is essential for setting up the initial state of the component, such as bind properties or fetching the initial list of users and ensures that tests have a consistent starting state, with the component fully initialized and ready to respond to simulated user interactions.
Diving into Testing UsersComponent
Armed with our understanding of Angular's reactive features and testing methods, we're now ready to delve into the unit tests for UsersComponent. Each test case is meticulously crafted to verify the component's functionality, ensuring it behaves as expected under various scenarios.
Test 1: Populating MatTableDataSource with Users
it('should populate MatTableDataSource with users', async () => {
expect(component.dataSource.data.length)
.withContext('Data Source length before fetch').toBe(0);
await fixture.whenStable();
TestBed.flushEffects();
expect(component.users()).withContext('User list after fetch')
.toEqual(mockedUsers);
expect(component.dataSource.data.length)
.withContext('Data Source length after fetch').toBe(3);
expect(component.dataSource.data).toEqual(mockedUsers);
});
In this test, we first assert that our data source is initially empty. After calling fixture.whenStable()
,
we ensure all asynchronous operations have completed. Then, TestBed.flushEffects()
is invoked to process any
effects, such as those linked to our users signal. This guarantees that our UI is synchronized with the
current state. We conclude by asserting that the data source now contains the mocked users, proving that our
effects correctly update the UI in response to the signal changes.
Test 2: Adding a New User
it('should add a new user', async () => {
const newUser = {
id: 'id_4',
username: 'user_name_4',
email: 'test4@email.com'
}
await fixture.whenStable();
await component.addUser();
TestBed.flushEffects();
expect(component.dataSource.data.length)
.withContext('Data Source length after new user addition').toBe(4);
expect(component.dataSource.data).toEqual([...mockedUsers, newUser]);
});
This test focuses on adding a new user. We initiate by awaiting component stabilization and then invoke the
async addUser()
method, which updates the users signal. Post this, TestBed.flushEffects()
is called to
trigger the effects that update the UI. Our assertions confirm that the data source now includes the new user,
demonstrating the component's dynamic response to state changes.
Test 3: Removing a User
it('should remove user', async () => {
await fixture.whenStable();
await component.removeUser('id_2');
TestBed.flushEffects();
expect(component.dataSource.data.length)
.withContext('Data Source length after user remove').toBe(2);
expect(component.dataSource.data[1].id).toBe('id_3');
});
In the final test, we validate the removal of a user. After stabilizing the component, we call removeUser()
,
updating the users signal. Following this with TestBed.flushEffects()
ensures our effects process this
change. The assertions then verify that the data source has been updated to reflect the removal of the
specified user, again highlighting the reactive nature of our component.
Putting it All Together
Here's the complete overview of our UsersComponent tests:
describe('UserListComponent', () => {
let component: UsersComponent;
let usersService: UsersService;
let fixture: ComponentFixture<UsersComponent>;
const mockedUsers: User[] = [
{"id": "id_1", "username": "user_name_1", "email": "test1@email.com"},
{"id": "id_2", "username": "user_name_2", "email": "test2@email.com"},
{"id": "id_3", "username": "user_name_3", "email": "test3@email.com"}
];
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UsersComponent, NoopAnimationsModule, HttpClientTestingModule],
providers: [UsersService],
}).compileComponents();
usersService = TestBed.inject(UsersService);
fixture = TestBed.createComponent(UsersComponent);
component = fixture.componentInstance;
});
beforeEach(() => {
spyOn(usersService, 'getUsers').and.returnValue(of(mockedUsers));
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should initialize with default values', () => {
expect(component.isLoading()).toBeTruthy();
expect(component.dataSource.data.length).toBe(0);
expect(component.displayedColumns.length).toBe(4);
});
it('should populate MatTableDataSource with users', async () => {
expect(component.dataSource.data.length)
.withContext('Data Source length before fetch').toBe(0);
await fixture.whenStable();
TestBed.flushEffects();
expect(component.users()).withContext('User list after fetch')
.toEqual(mockedUsers);
expect(component.dataSource.data.length)
.withContext('Data Source length after fetch').toBe(3);
expect(component.dataSource.data).toEqual(mockedUsers);
});
it('should add a new user', async () => {
const newUser = {
id: 'id_4',
username: 'user_name_4',
email: 'test4@email.com'
}
await fixture.whenStable();
await component.addUser();
TestBed.flushEffects();
expect(component.dataSource.data.length)
.withContext('Data Source length after new user addition').toBe(4);
expect(component.dataSource.data).toEqual([...mockedUsers, newUser]);
});
it('should remove user', async () => {
await fixture.whenStable();
await component.removeUser('id_2');
TestBed.flushEffects();
expect(component.dataSource.data.length)
.withContext('Data Source length after user remove').toBe(2);
expect(component.dataSource.data[1].id).toBe('id_3');
});
});
Wrapping Up
I hope you found our stroll through UsersComponent
testing as insightful as it was enjoyable. Testing,
especially with Angular's latest reactive features, doesn't have to be a pain in the neck. Actually, it's
quite the opposite—it's a smooth, straightforward, and enjoyable task.
We've seen how tools like signal.set()
, signal.update()
, and effect()
can be game-changers, making our
tests not just necessary but a piece of cake. With the right approach and understanding, testing becomes
less of a chore and more of an exciting part of the development process.
Incorporating these new features into our testing routine has shown us that it's not just about ensuring our components are up to snuff. It's about embracing a more dynamic and enjoyable workflow. The tests we've walked through exemplify this mindset, proving that with Angular's advanced capabilities, we're fully equipped to handle any reactive challenges that come our way, all with a smile.
So, let's shake off any old notions that testing is a drag. With Angular's reactive features, we're ushering in a new era of testing that's efficient, effective, and, dare I say, fun :)
You can find access to the working application and tests on GitHub.