Testing Animations in Angular: A Comprehensive Guide
Published: 2024-04-14 09:00:00

## 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. 

```bash
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.

```ts
// 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:

```ts
// 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:

```html
<!-- 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:

![Open Closed Animation](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/z27n1xdu05nckbsyv5xt.gif)

## 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](https://dev.to/this-is-angular/unit-testing-in-angular-170l) on this if you would like to read it.

### Setup

Import the necessary modules/components in your test file:

```ts
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. 

```ts
// 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. 

```ts
// 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. 

![Karma](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0agbnkgyzpzs5s6xmhnd.png)

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. 

![Animation Execution](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/l75m4u8kbu4zy09lqjug.gif)

### 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. 

- init ([is noop](https://github.com/angular/angular/blob/5bd188a394d30053099e2c83fe79136d590e5399/packages/animations/src/players/animation_player.ts#L157))
- hasStarted
- play
- pause ([is noop](https://github.com/angular/angular/blob/5bd188a394d30053099e2c83fe79136d590e5399/packages/animations/src/players/animation_player.ts#L176))
- restart ([is noop](https://github.com/angular/angular/blob/5bd188a394d30053099e2c83fe79136d590e5399/packages/animations/src/players/animation_player.ts#L177))
- finish
- destroy
- reset
- etc..

See the full list [here](https://angular.io/api/animations/browser/testing/MockAnimationPlayer)

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`. 

```ts
// 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:

```ts
// 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.

## 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](https://angular.io/guide/transition-and-triggers).

```ts
// 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:**

1. ***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.
2. ***Accessing the Animation Player:*** `let player = MockAnimationDriver.log.pop()! as MockAnimationPlayer;` retrieves the animation player created for the initial animation using `MockAnimationDriver.log.pop!`. The `as MockAnimationPlayer` type assertion ensures the retrieved value is treated as a `MockAnimationPlayer`.
3. ***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.
4. ***Checking for Ongoing Animation:*** `expect(openCloseContainer.classList.contains('ng-animating')).toBeTruthy();` verifies that the `openCloseContainer` element has the `ng-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.
5. ***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.
6. ***Checking for Animation Completion (again):*** `expect(openCloseContainer.classList.contains('ng-animating')).toBeFalsy();` checks once more if the `ng-animating` class is present on the `openCloseContainer` element. This time, we expect it to be not there because the animation should be finished after calling `player.finish()`.
7. ***Verifying Final Styles:*** `const computedStyle = window.getComputedStyle(openCloseContainer);` retrieves the computed styles of the `openCloseContainer` 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:

```ts
// 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:**

1. ***Triggering the Toggle:*** The test initiates the animation by clicking a button using `button.click();`, assuming it triggers the closed state.
2. ***Verifying Keyframes for Closed State:*** The expected keyframes match the transition to a closed state:
    - Height: 100px
    - Opacity: 0.8
    - Background color: blue
3. ***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.

Github: https://github.com/sonukapoor/animation-testing