Why snapshot testing is not enough?

Why snapshot testing is not enough?

or Is it even required? well, I have some thoughts.

Introduction

I came across this question in one of the unit testing discussions so thought of sharing it with everyone else. I will not be covering what is snapshot testing in this article. Please check this link for more info about snapshot testing.

Well, snapshot testing is a good way to capture the state of the DOM (Document Object Model). It will help you identify if your code has made any DOM-level changes.

Let's take an example by creating a Todo App -

App.tsx

import React, { useState } from 'react';
import './style.css';

const App = () => {
  const [todos, setTodos] = useState<Array<string>>([]);
  const [todoInput, setTodoInputValue] = useState('');

  const addTodoItem = () => {
    if (todoInput) {
      setTodos([...todos, todoInput]);
      setTodoInputValue('');
    }
  };

  const clearTodoList = () => {
    setTodos([]);
  };

  return (
    <div>
      <h2>Todo List</h2>
      <ul className="todo-items">
        {todos.map((todo) => (
          <li>{todo}</li>
        ))}
      </ul>
      <textarea
        data-testid="input"
        value={todoInput}
        onChange={(e) => setTodoInputValue(e.target.value)}
      />
      <div className="buttons-wrapper">
        <button type="reset" onClick={clearTodoList}>
          Clear
        </button>
        <button onClick={addTodoItem}>Add</button>
      </div>
    </div>
  );
}

export default App;

checkout the code in action

Adding Snapshot Test

Let's add some unit tests to test for this component

App.test.tsx

import React from 'react';
import { render } from '@testing-library/react';
import App from './App';

describe('<App/>', () => {
  it('should match the snapshot', () => {
    const container = render(<App />);
    expect(container.container).toMatchSnapshot();
  });
});

checkout the code in action

This will generate the following snapshot in __snapshots__ directory -

// Jest Snapshot v1

exports[`<App/> should match the snapshot 1`] = `
<div>
  <div>
    <h2>
      Todo List
    </h2>
    <ul
      class="todo-items"
    />
    <textarea />
    <div
      class="buttons-wrapper"
    >
      <button
        type="reset"
      >
        Clear
      </button>
      <button>
        Add
      </button>
    </div>
  </div>
</div>
`;

checkout the code in action

the above snapshot contains all the DOM elements along with the labels and element attributes. So this is a good way to test the structure or hierarchy of the DOM. but, it will not capture the events and any other methods in your component.

how do we capture methods and events in the snapshot tests?

Although we cannot capture the code of the methods in the snapshot, we can capture the DOM changes which were caused by the execution of methods or events.

let's add one more snapshot to test the add method -

App.test.tsx

  it('should match the snapshot when a todo item is added', () => {
    const renderResult = render(<App />);
    const inputElement = renderResult.getByTestId('input');
    const addButton = renderResult.getByText('Add');
    fireEvent.change(inputElement, { target: { value : "Todo Item 1"}});
    fireEvent.click(addButton);
    expect(renderResult.container).toMatchSnapshot();
  });

checkout the code in action

This will generate the following snapshot -

exports[`<App/> should match the snapshot when a todo item is added 1`] = `
<div>
  <div>
    <h2>
      Todo List
    </h2>
    <ul
      class="todo-items"
    >
      <li>
        Todo Item 1
      </li>
    </ul>
    <textarea
      data-testid="input"
    />
    <div
      class="buttons-wrapper"
    >
      <button
        type="reset"
      >
        Clear
      </button>
      <button>
        Add
      </button>
    </div>
  </div>
</div>
`;

Now you can see the new Todo Item 1 added via a unit test in the snapshot. This tested addTodoItem method.

This is how you can test some of the internal workings of the component but at the cost of additional snapshots and this will increase with the variations of tests you want to add for e.g. Unit test to check if the clear button clears the to-do Items.

What can't be tested using snapshots?

But, there are a couple of things you may still not be able to test -

  • Functionalities which does not lead to DOM changes cannot be tested since they won't be captured in the snapshot.

  • Functionalities which change DOM elements in the parent component (or any other component which is not a part of the component under test) cannot be tested since they won't be captured in the snapshot. The workaround for this would be to write the test in the parent.

The easiest way to test this Todo App component would be to add a snapshot for the initial state of the component and other functionality can be tested using simple unit tests -

App.test.tsx

import React from 'react';
import { fireEvent, render } from '@testing-library/react';
import App from './App';

describe('<App/>', () => {

  // snapshot test for capturing the initial state
  it('should match the snapshot', () => {
    const renderResult = render(<App />);
    expect(renderResult.container).toMatchSnapshot();
  });

  // other unit tests not using snapshot testing
  it('should add a todo item when clicked on add', () => {
    const renderResult = render(<App />);
    const inputElement = renderResult.getByTestId('input');
    const addButton = renderResult.getByText('Add');
    fireEvent.change(inputElement, { target: { value : "Todo Item 1"}});
    fireEvent.click(addButton);
    expect(renderResult.getByText("Todo Item 1"));
  });

  it('should clear all todos when clicked on clear', () => {
    const renderResult = render(<App />);
    const inputElement = renderResult.getByTestId('input');
    const addButton = renderResult.getByText('Add');
    const clearButton = renderResult.getByText('Clear');
    fireEvent.change(inputElement, { target: { value: "Todo Item 1" } });
    fireEvent.click(addButton);

    fireEvent.click(clearButton)
    expect(renderResult.queryByText("Todo Item 1")).toBeEmptyDOMElement;
  });
});

Summary

Now let's summarise the downsides of going ONLY for snapshot testing -

  • Events & handlers methods code is not captured until they result in DOM changes in the same component.

  • Additional files or snapshots are added to the repo if multiple variations of the test are needed.

  • Merge conflicts will be a headache since every time we get a conflict we will have to resolve the merge conflict first, then regenerate the snapshot via tests, and then add it to the commit. so waste of time and energy.

  • Code coverage if we are heavily relying on the snapshot tests - the code coverage may be good but it will not give enough confidence since internal workings on the component are not captured in the snapshot.

  • Failures in snapshots are not paid much attention by the devs and they tend to directly update which is the biggest downside. additionally, it is difficult for the devs to figure out what went wrong.

  • External Library updates - can cause the snapshots to change which will result in test failures and it will get difficult for the devs to identify the fix.

  • Increased number of Test Reruns - Especially to update the snapshots.

because of all the above reasons, I feel you should only go for snapshot tests when the code results in a DOM change otherwise prefer normal unit tests. Additionally, In big projects, it is better not to have snapshot tests at all.

Feel free to share your thoughts in the comment section below.

Thanks for reading!

Did you find this article valuable?

Support Anoop Jadhav by becoming a sponsor. Any amount is appreciated!