DRY in test suites is an antipattern

One of the first things that new software engineers learn is the DRY princliple (Don’t repeat yourself), the principle states that in software engineering we should try to reduce repetition by extracting common functionality in a better abstraction (sometimes even abstraction is not needed e.g. 2 identical utility functions that do the same thing written by 2 different engineers, just because the 2nd one didn’t know that the functionality already exist somewhere in the codebase). While the principle makes a lot of sense in production code for many reasons that is out of the scope of this article, and makes even more sense if you develop for the web and you try to keep the size of your bundle that you ship to your users as small as possible), I would argue that when it is followed in a test suite in can become quite harmful. Let’s see it with an example:

You have a UI testing suite that renders a page and tests a few things, you don’t want to render your page in every test case so you decide to render it once before all the tests run and re-use the same rendering in all the tests.

describe('Products', () => {
  let container;

  beforeAll(() => {
    const utils = render(<Products />);
    container = utils.container;
  });

  it('should show the basket clicks on the basket icon', () => {
    const buyButton = container.querySelector('button.buy');
    fireEvent.click(buyButton);
    const basket = container.querySelector('.basket');
    expect(basket).not.toBeNull();
  });

  it('should show the basket after user adds a product to it', () => {
    const buyButton = container.querySelector('button.buy');
    fireEvent.click(buyButton);
    const basket = container.querySelector('.basket');
    expect(basket).not.toBeNull();
  });
});

The 1st test case as a result of the click even had already transitioned the UI into the desired state (show the basket), this state is the same one the 2nd test expects, so the 2nd test case is not rely reliable, the system may or may not do what is intented (‘should show the basket after user adds a product to it’), we cant rely tell, unless we comment out the first test case or our test suite runs the test cases in a random order. The fix ofcourse is simple, make the test less dry:

describe('Products', () => {
  it('should show the basket clicks on the basket icon', () => {
    const { container } = render(<Products />);
    const buyButton = container.querySelector('button.buy');
    fireEvent.click(buyButton);
    const basket = container.querySelector('.basket');
    expect(basket).not.toBeNull();
  });

  it('should show the basket after user adds a product to it', () => {
    const { container } = render(<Products />);
    const buyButton = container.querySelector('button.buy');
    fireEvent.click(buyButton);
    const basket = container.querySelector('.basket');
    expect(basket).not.toBeNull();
  });
});

The goal of test cases shouldnt be to be DRY but to be concise and complete. Consise test cases are the test cases that do not have irrelevant information that can be distracting for the reader. Complete test cases are the test cases that have everything needed to in order to run in isolation and help the reader to understand how the transitioned the system to the desired state (being UI or anything else).

One note here is that by not being DRY in test suites I dont mean repeat helper functions. If you have a helper that generates mock data is perfectly fine to define it ones. For example:

describe('Products', () => {
  function generateProducts(){
    ...
    ...
    return products;
  }

  it('should show the basket clicks on the basket icon', () => {
    const { container } = render(<Products products={generateProducts()} />);
    const buyButton = container.querySelector('button.buy');
    fireEvent.click(buyButton);
    const basket = container.querySelector('.basket');
    expect(basket).not.toBeNull();
  });

  it('should show the basket after user adds a product to it', () => {
    const { container } = render(<Products products={generateProducts()}  />);
    const buyButton = container.querySelector('button.buy');
    fireEvent.click(buyButton);
    const basket = container.querySelector('.basket');
    expect(basket).not.toBeNull();
  });
});

It would be not smart to define the generateProducts() twice, by “Do not trying to be DRY in test suites”, I mean do not try to dry up things that would make your test cases depend on each other.

Published 14 Nov 2020

Software Engineering Lead, Certified AWS Solutions Architect. Opinions are my own and not necessarily the views of my employer.
Avraam Mavridis on Twitter