DEV Community

Cover image for 💀Don't Break UI with Jest Snapshot Testing 📷
Dany Paredes
Dany Paredes

Posted on • Originally published at danywalls.com

1

💀Don't Break UI with Jest Snapshot Testing 📷

A few days ago, I was fixing a small accessibility issue and decided to remove some unnecessary wrappers around a few elements. After making those changes, all the tests passed successfully—including the E2E tests. Everything was green, so I opened a pull request, and it got approved. But after merging into develop, my friend Aitor found out that I had broken the UI 🥲.

But why did this happen?

I had tests in place to validate my code, and I was confident that the data was rendering correctly. What I didn’t consider were the child components and UI states—like :hover or :focus. These states depend heavily on the structure of the DOM and CSS rules.

If you're working on a sensitive UI—where visual states and effects are just as important as content—then structural changes can have a bigger impact than expected. I trusted my test, which confirmed that "the elements are rendered," but I didn’t realize how important the CSS rules and layout states were. So how do you know if a change in the HTML will impact the UI?

💀 This is why it’s so important that your tests clearly define their scope.

At that moment, only in the develop branch or if another teammate knows about that feature is the only way to know the small details were broken. How the team knows that is important and maybe critical for the UI experience. So the only way to avoid breaking the UI and to inform me or another teammate in the future that this is important is by protecting it using Jest Snapshot testing.

As always, I prefer to use a scenario to better explain and understand the situation. Let's look at a scenario.

Scenario

You have been without an app for more than four months, but you pick a ticket to make a small change. The app shows a list of products.

Image description

My Product Test with Testing Library

I have an HTML markup that shows a list of products, the test use data-testid to find the title and ensure the element was rendered in the component.

<div class="products-list">
  @for (p of products$ | async; track p) {
    <div class="products-list__item">
      <div class="products-list__item__title" data-testid="product-title">
        <div>
          <span>{{ p.title }}</span>
        </div>
      </div>
    </div>
  }
</div>
Enter fullscreen mode Exit fullscreen mode

In my product.spec.ts test file it find the products was render,

  it('should render products', async () => {
    await setup();
    const productsTitle = screen.getAllByTestId('product-title');
    const productTitles = productsTitle.map((title) =>
      title.textContent.trim(),
    );
    expect(productTitles).toEqual(fakeProducts.map((product) => product.title));
  });
Enter fullscreen mode Exit fullscreen mode

The full source code product.spec.ts

import { ProductsListComponent } from './products-list.component';
import { ProductModel } from '../../products/models/product.model';
import { ProductsService } from '../../products/services/products.service';
import { of } from 'rxjs';
import { render, screen } from '@testing-library/angular';

describe('ProductListComponent', () => {
  const fakeProducts: ProductModel[] = [
  //mock products. 
  ];

  const productsServiceMock = {
    products$: of(fakeProducts),
  };

  it('should render products', async () => {
    await setup();
    const productsTitle = screen.getAllByTestId('product-title');
    const productTitles = productsTitle.map((title) =>
      title.textContent.trim(),
    );
    expect(productTitles).toEqual(fakeProducts.map((product) => product.title));
  });

  const setup = async () => {
    await render(ProductsListComponent, {
      providers: [{ provide: ProductsService, useValue: productsServiceMock }],
    });
  };
});


Enter fullscreen mode Exit fullscreen mode

After run my test, looks like everything everything is working✅.

Image description

Something made noise, so I decided to remove unnecessary div elements in the HTML markup. After making the changes, I ran my test, and everything looked perfectly green. ✅🥳

Image description

I open the application and it looks like everything works as expected and the tests are ✅ green... or not?

Image description

Did you found the diferencies ?

First, I have not touched this code for a few months, I didn't notice that something "ellipsis" in the text was removed. But why didn't my test notify me that I broke the app?

Image description

First, our test is focus in the app works and render the products, but don’t take care about the UI changes (its should be a element focus or other status), to avoid that kind of issues we will play with Jest Snapshot testing.

The Jest SnapShot

In a normal workflow, when we make changes in the UI and looks as expected, the process should be to open the browser, load the full app, and compare the UI. But what happens when it is a single component, like a row cell, a small row state, a toggle, or a card? sounds tricky ?

In these scenarios, this is where the Jest Snapshot shines! It helps us ensure your UI does not change unexpectedly. It compares the changes with a reference snapshot file stored. The test will fail when the two snapshots do not match, whether the change is unexpected or the reference snapshot needs to be updated with the new version.

Read more about SnapShot Testing

When we write a test using a snapshot, the result should be committed with our code change and in the next test run, Jest compares the rendered output with the previous snapshot, if nothing has changed, the test passes but if we change something like remove the div the test fails and shows the diff between.

Let's try to use it.

Moving to Jest SnapShot

First, I will restore the div in my app and take another approach with my test to feel confident about whether I break the application UI. I need to change the setup function to return the fixture, where I will take a reference for my snapshot.

const setup = async () => {
    const { fixture } = await render(ProductsListComponent, {
      providers: [{ provide: ProductsService, useValue: productsServiceMock }],
    });

    return {
      fixture
    };
  };
Enter fullscreen mode Exit fullscreen mode

Next, I will add a new test to ensure the UI changes don't break and it renders everything. I take the fixture from the setup function, then use the expect function with toMatchSnapshot.

  it('should display the correct list of product title', async () => {
    const { fixture } = await setup();
    expect(fixture).toMatchSnapshot();
  });
Enter fullscreen mode Exit fullscreen mode

Let's run test, it’s create the snapshot with the first version of the code.

Image description

After execution, an __snapshots__ the directory was created next product-list.component.spec.ts

Image description

Okay, if another teammate comes to work with the app and says, "Let me remove the div," let's try.

Image description

Yeah, our test complains that we broke the expected behavior because if my teammate created a snapshot, it is because this part of the structure is important for the project.

That's the key. If we have a test that protects the UI render, it is because it is important and makes us confident. At that moment, the teammate will find the differences and understand why it is failing.

Mmmm.. but it will break with every change?

We need to understand that Jest makes a diff between the generated output and the previous snapshot. So, if I add a new line or element, the UI might break. That is true. But my first mistake is writing tests that take on more responsibility than expected. So, it is very important to write a test that only takes care of the output.

In my code, I keep the first test responsible for finding the product name elements, but another test only takes care of the product list's structure.

But when I make intentional UI changes, your snapshot tests will fail because the rendered output no longer matches the stored snapshot. In these cases, you need to update the snapshots.

I can review the diff to ensure that the changes are indeed intentional and that they don't introduce any unintended visual issues. Because we have a snapshot, I pay attention to added, removed, or modified elements, attributes, and text content.

If everything looks as expected, then I can update the snapshots by running Jest with -u.

 npm test -u
Enter fullscreen mode Exit fullscreen mode

Image description

Perfect! we have our snapshot updated and take care about changes in out layout! 🥳

They are not perfect 😭

If you have read up to this line, it is because you really want to know about snapshot testing. I made a big effort during the article to use the snapshot in atomic points in your application. My example focused on changing the structure and mentioned scenarios like cell, row, or atomic part in your app.

One of the most painful parts of snapshot testing is when it is used in a big component where many changes occur, and most developers skip and update the changes. We end up not caring about the snapshot because there are too many changes to read in a PR.

⚠️ So, I highly recommend please 🙏 read how to write effective snapshot and things to avoid with snapshots by kentcdodds.

Please avoid using snapshots with large components or with too many dependencies. Snapshots work best for simple components with clear, isolated functionality. That's why I mention examples like <row/> or <app-icon/>.

If you have the freedom in your I highly recommend cypress component testing, cypress visual testing or use modern testing like Vitest with Playwright Component Testing.

MODERN TESTING 🤔 VITEST.. 🧪 PLAYWRIGHT WITH ANGULAR?

If you're like me and want to learn modern testing practices in Angular, including working with Vitest, testing Observables, Forms, and Router, avoiding common mistakes with Mocks, Spies, and Fakes (like I did), and developing a solid testing strategy (which I need to improve), along with using Testing Library and Playwright Component Testing, all in one course,

I recommend checkout “Pragmatic Angular Testing” by Younes Jaaidi. He shares all the challenges of testing, lessons learned, and real-world experience with testing in Angular—all packed into one course.
Image description

Recap

After trying out snapshot testing, I now see how helpful it is to make sure our app's look and feel doesn't break by accident. We already use other tests to check if things work right, but snapshot tests help us see if things look right too. To explain snapshot testing to my son edgar, I said to him “Snapshot testing is like a picture of your UI”, remembering how a part of your app looked at one point. If it looks different later, the test will tell you.

But keep in mind the snapshot tests don't replace your other tests; they are another layer of safety for how things look and focus on testing small parts, which makes the tests easier to understand and less likely to break for the wrong reasons. When a snapshot test fails, I need to look at what changed in the "picture" to make sure it was supposed to change.

Overall, I think snapshot testing is really useful for parts of the app where how things are arranged and how they look to the user is important, and it stops unexpected visual problems before they reach our users, they are not perfect but help

I now think snapshot testing is a great way to help keep our app looking good and not breaking again 😊

Happy testing!

Heroku

Deploy with ease. Manage efficiently. Scale faster.

Leave the infrastructure headaches to us, while you focus on pushing boundaries, realizing your vision, and making a lasting impression on your users.

Get Started

Top comments (0)

AWS Security LIVE! Stream

Streaming live from AWS re:Inforce

Join AWS Security LIVE! at re:Inforce for real conversations with AWS Partners.

Learn More