Testing Animations in Angular: A Comprehensive Guide
Sonu Kapoor
Posted on April 14, 2024
Introduction
Animations are a crucial aspect of modern web applications, enhancing user experience and adding a dynamic touch to the interface. Angular provides a robust animation system that allows developers to create fluid and engaging UI transitions. However, ensuring the reliability and correctness of these animations through testing is equally important.
Testing animations in Angular can be a challenging yet essential task for developers aiming to deliver a polished and bug-free user experience. In this comprehensive guide, we will explore various strategies and techniques to effectively test animations within the Angular framework. From unit testing individual components to validating the application of keyframes, we'll dive into the tools and best practices that will empower you to build robust and reliable animations.
Whether you are a seasoned Angular developer or just starting your journey into web development, understanding how to test animations will contribute significantly to the overall quality and maintainability of your Angular applications. Let's take a look at the ins and outs of animation testing in Angular and elevate your development workflow to the next level.
Prerequisite: Creating a Basic Animation in Angular
Let's first create a new Angular project with a very simple open/close animation that will be triggered on a button click. The Angular version used in this article is 17.3.0. I will keep the standard options while creating the project.
ng new angular-animation-testing
cd angular-animation-testing
I keep my animations in a separate file. Go ahead and create a new file for that.
// src/animations/open-close.animation.ts
import {
animate,
state,
style,
transition,
trigger,
} from '@angular/animations';
export const openCloseAnimation = trigger('openClose', [
state(
'open',
style({
height: '200px',
opacity: 1,
backgroundColor: 'yellow',
})
),
state(
'closed',
style({
height: '100px',
opacity: 0.8,
backgroundColor: 'blue',
})
),
transition('open => closed', [animate('1s')]),
transition('closed => open', [animate('0.5s')]),
transition('* => closed', [animate('1s')]),
transition('* => open', [animate('0.5s')]),
transition('open <=> closed', [animate('0.5s')]),
transition('* => open', [animate('1s', style({ opacity: '*' }))]),
transition('* => *', [animate('1s')]),
]);
Open your app.component.ts
and add the new animation to your animations
array. Your component should look like this:
// app.component.ts
import { Component } from '@angular/core';
import { openCloseAnimation } from './animations/open-close.animation';
@Component({
selector: 'app-root',
standalone: true,
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
animations: [openCloseAnimation],
})
export class AppComponent {
isOpen = true;
toggle() {
this.isOpen = !this.isOpen;
}
}
Open the template html file and add a div
and apply the animation to that element:
<!-- src/app/app.component.html -->
<button type="button" (click)="toggle()">Toggle Open/Close</button>
<div [@openClose]="isOpen ? 'open' : 'closed'"
class="open-close-container">
<p>The box is now {{ isOpen ? 'Open' : 'Closed' }}!</p>
</div>
Run the application, using npm start
to ensure that your animation is working. You should see something like this:
Unit testing
Now that we have a basic animation ready to tested, let's look at some testing scenarios. Let's start with the setup of our spec file. For this article, I will use the traditional way to set up my TestBed
. I usually use SIFERS along with ATL. I wrote a separate article on this if you would like to read it.
Setup
Import the necessary modules/components in your test file:
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
Let's set up the beforeEach
with the essential modules. Since I am using standalone components, I need to add the AppComponent
to the imports
array, instead of the usual declarations
array. I have also created the fixture
and the animatedElement
as public variables so that I can easily access them in my tests.
// app.component.spec.ts
let fixture:ComponentFixture<AppComponent>;
let animatedElement: HTMLDivElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent, BrowserAnimationsModule],
}).compileComponents();
fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const element = fixture.nativeElement;
animatedElement = element.querySelector('div');
});
With the above setup, let's create our first unit test.
// app.component.spec.ts
it('should check the styles when the div is open', () => {
const computedStyle = getComputedStyle(animatedElement);
expect(computedStyle.opacity).toBe('1');
expect(computedStyle.height).toBe('200px');
expect(computedStyle.backgroundColor).toBe('rgb(255, 255, 0)');
});
Run this test using ng run test
and you will see that it passes.
Now, let's debug this test and see what happens. It triggered the real animation 🤯. That is happening because we are importing the BrowserAnimationModule
. It is not recommended to test the real animation.
Do not test real animation implementations
Slowness and Inconsistencies:
- Execution Time: Running real animations can be slow, especially during test suites with many animation tests. This can significantly increase your test execution time.
- Browser Inconsistencies: Animation behaviour can vary slightly across different browsers and even browser versions. This variability makes it difficult to write reliable tests that pass consistently everywhere.
- Visual Dependence: Tests relying on visual inspection are subjective and prone to errors. A small visual difference might be a bug or just a minor rendering inconsistency.
Limited Control and Isolation:
- Focus on Logic, not Visuals: Your primary concern is testing the component's logic related to triggering animations and handling state changes, not the visual outcome.
- Difficult to Mock Events: Testing specific animation states or timings becomes cumbersome when relying on real animations and user interaction.
MockAnimationDriver to the Rescue
Here's where MockAnimationDriver
from Angular comes into play (pun intended). This mock object replaces the entire Angular animation driver, allowing you to control animation behaviour at a more granular level. It helps test complex scenarios where you need to simulate animation execution or specific animation styles.
Under the hood, the MockAnimationPlayer
is used, which simulates the behaviour of an animation player for a specific animation trigger. It's useful for testing how your component interacts with animation state changes and verifying the used animation triggers. The player has several methods, that can help you in testing.
See the full list here
The MockAnimationDriver
extends NoopAnimationPlayer
and because of that some of the methods are noop, and don't do anything. Keep this in mind when writing your tests and you are wondering why nothing is happening.
Setting Up the Testing Module
The MockAnimationDriver
needs to be provided in our TestBed setup. This time I have also imported the NoopAnimationModule
. NoopAnimationsModule
which disables animations by default in testing, allowing us to control them with MockAnimationDriver
.
// app.component-2.spec.ts
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent, NoopAnimationsModule],
providers: [{
provide: AnimationDriver,
useClass: MockAnimationDriver
}],
}).compileComponents();
});
I also added another beforeEach
to get component references:
// app.component2.spec.ts
beforeEach(() => {
fixture = TestBed.createComponent(AppComponent);
component = fixture.componentInstance;
button = fixture.debugElement
.query(By.css('button'))
.nativeElement;
openCloseContainer = fixture.debugElement
.query(By.css('.open-close-container'))
.nativeElement;
});
- I utilized the fixture's debugging tools to obtain references to specific elements in the component's template:
component:
- This holds the instance of
AppComponent
. -
button
: This references the button element that triggers the animation toggle. -
openCloseContainer
: This references the element whose styles are affected by the animation.
- This holds the instance of
Writing Tests for Animations
Now comes the exciting part - writing tests to verify the animations!
Test 1. Verifying initial animation:
Our component has a button that toggles a box between the on and closed event. When the component loads, the initial animation * => open
will trigger. This syntax translates to animate from any state to open. You can learn more about this syntax here.
// app.component2.spec.ts
it('should start "* => open" animation on the first load', () => {
fixture.detectChanges();
let player = MockAnimationDriver.log.pop()! as MockAnimationPlayer;
expect(player.keyframes).toEqual([
new Map<string, string|number>([[ 'height', '*' ], [ 'opacity', '*' ], [ 'backgroundColor', '*' ], [ 'offset', 0 ]]),
new Map<string, string|number>([[ 'height', '200px' ], [ 'opacity', '1' ], [ 'backgroundColor', 'yellow' ], [ 'offset', 1 ]]),
]);
// We are still animating
expect(openCloseContainer.classList.contains('ng-animating')).toBeTruthy();
player.finish();
// We are done with the animation
expect(openCloseContainer.classList.contains('ng-animating')).toBeFalsy();
const computedStyle = window.getComputedStyle(openCloseContainer);
expect(computedStyle.backgroundColor).toBe('rgb(255, 255, 0)'); // yellow
});
Step-by-Step Breakdown:
-
Triggering Change Detection:
fixture.detectChanges()
ensures that any data binding or changes within the component are reflected in the DOM. This is crucial because the animation might be triggered based on the component's initial state. -
Accessing the Animation Player:
let player = MockAnimationDriver.log.pop()! as MockAnimationPlayer;
retrieves the animation player created for the initial animation usingMockAnimationDriver.log.pop!
. Theas MockAnimationPlayer
type assertion ensures the retrieved value is treated as aMockAnimationPlayer
. -
Verifying Animation Keyframes:
expect(player.keyframes).toEqual(...)
compares the actual animation keyframes of the retrieved player with the expected keyframes. These keyframes define the animation's starting and ending states for various properties like height, opacity, and backgroundColor. In this case, the animation starts from any height and opacity values (*) and transitions to a height of 200px, full opacity (1), and a yellow background color. -
Checking for Ongoing Animation:
expect(openCloseContainer.classList.contains('ng-animating')).toBeTruthy();
verifies that theopenCloseContainer
element has theng-animating
class added to its class list. Angular adds this class during animation playback. This assertion confirms that the animation is still ongoing after the keyframe verification. -
Simulating Animation Completion:
player.finish();
simulates the animation reaching its end state. This is necessary because the test needs to verify the final styles after the animation finishes. -
Checking for Animation Completion (again):
expect(openCloseContainer.classList.contains('ng-animating')).toBeFalsy();
checks once more if theng-animating
class is present on theopenCloseContainer
element. This time, we expect it to be not there because the animation should be finished after callingplayer.finish()
. -
Verifying Final Styles:
const computedStyle = window.getComputedStyle(openCloseContainer);
retrieves the computed styles of theopenCloseContainer
element.expect(computedStyle.backgroundColor).toBe('rgb(255, 255, 0)');
asserts that the final background color of the element is yellow (as defined in the animation keyframes).
Overall, this test effectively verifies that:
- The correct animation plays on initial load (* => open).
- The animation keyframes match the expected behavior.
- The animation visually transitions the element to its open state (yellow background).
Test 2. Verifying the open => closed animation
Similarly we can write a second test, that simulates the open => closed
state. It follows the same steps, except with the additional button click and the removal of the initial fixture.detectChanges
call. Here is how this test would look:
// app.component2.spec.ts
it('should start "open => closed" animation when toggled to closed', () => {
button.click();
fixture.detectChanges();
let player = MockAnimationDriver.log.pop()! as MockAnimationPlayer;
expect(player.keyframes).toEqual([
new Map<string, string|number>([[ 'height', '*' ], [ 'opacity', '*' ], [ 'backgroundColor', '*' ], [ 'offset', 0 ] ]),
new Map<string, string|number>([[ 'height', '100px' ], [ 'opacity', '0.8' ], [ 'backgroundColor', 'blue' ], [ 'offset', 1 ]]),
]);
// We are still animating
expect(openCloseContainer.classList.contains('ng-animating')).toBeTruthy();
player.finish();
// We are done with the animation
expect(openCloseContainer.classList.contains('ng-animating')).toBeFalsy();
const computedStyle = window.getComputedStyle(openCloseContainer);
expect(computedStyle.backgroundColor).toBe('rgb(0, 0, 255)'); // blue
});
Step-by-Step Breakdown:
-
Triggering the Toggle: The test initiates the animation by clicking a button using
button.click();
, assuming it triggers the closed state. -
Verifying Keyframes for Closed State: The expected keyframes match the transition to a closed state:
- Height: 100px
- Opacity: 0.8
- Background color: blue
-
Simulating Animation Completion and Final Styles: The test follows a similar pattern as the first test, simulating animation completion with
player.finish();
and asserting the final styles: Background color is now blue, confirming the closed state.
Key Points:
This test verifies the correct animation plays when toggling to the closed state. It ensures the animation keyframes align with the expected closed state values. It validates that the element visually transitions to the closed state (blue background). Together, these two tests comprehensively cover the animation behavior for both open and closed states, ensuring a smooth and visually appealing user experience.
Conclusion
In conclusion, effectively testing animations in Angular is crucial for maintaining a polished and user-friendly application. By leveraging MockAnimationDriver
and the techniques outlined in this guide, you can write unit tests that verify animation logic, keyframes, and visual outcomes without relying on real animation execution. This approach ensures faster test execution times, avoids browser inconsistencies, and promotes a more robust development workflow.
Furthermore, MockAnimationDriver
empowers testing complex animation scenarios with multiple triggers and timings. You can simulate specific animation states and timings to ensure your component interacts correctly with the animation lifecycle. While unit tests focus on component logic and animation behavior, consider incorporating visual regression testing tools for additional reassurance about the visual fidelity of animations across different environments. Finally, integrate animation tests into your continuous integration pipeline to catch regressions early on in the development process. By following these practices, you can ensure that your Angular animations function flawlessly and deliver a visually appealing user experience.
And finally, here is the code discussed in this article.
Posted on April 14, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 30, 2024