DEV Community

Cover image for User Driven Testing (UDT)
Aleo Yakas
Aleo Yakas

Posted on • Updated on

User Driven Testing (UDT)

TL:DR; Our integration tests should mimic our users' behaviour. Our users don't care how our features are implemented, so neither should our tests.

What is it and why do we have a new acronym?

Everyone in tech loves a good acronym - or at least I hope so with how many the tech community have floating around! Why not add User Driven Testing (UDT) to the list? 👀

I had the idea to write this article after attending two workshops: Kateryna Porshnieva's Comprehensive guide to testing React applications workshop and Sandrina Pereira's Web Accessibility in React Apps workshop. I’d already been tackling the best way to structure tests for a new React application my company was working on and I kept coming to the same conclusion - our tests were just users of our application... so why don't we write our tests to behave as users?

Why did I write this article?

Note: This blog post uses a React app as an example, but I do believe that the principles of this blog are applicable when coming to testing any application.

If you were trying to figure out how to test your React application, you would probably find yourself reading React's documentation on testing. You would then probably read about React Testing Library (RTL, for the acronym aficionados). You would then probably read RTL's Guiding Principles. And you would then come across a tweet that makes so much sense that you don't know why you didn't think of it before.

At the time, I was setting up a new React app for my company. I now knew what I wanted to achieve: a test suite that mimicked how our users would interact with our app, but it wasn't obvious to me how I could achieve it.

As our app evolved, components were refactored, business logic kept being moved around, and, as a results, our tests were often being rewritten.

"But hang on... When you were doing all these refactors, did the users' behaviour with the application change?" Nope. The users still have the same experience... So why did we have to rewrite our tests?

What was the 'eureka!' moment?

If you're not familiar with RTL, RTL renders the component and all its dependencies to mimic the DOM tree rendered by the browser. That basically means that if a bug is introduced in a dependency of a component being tested, there is a chance the components tests will fail. I know what you’re thinking... "Aleo, if RTL renders a component's children, wouldn't that make the tests... integration tests?" Yes, it would! And that's when it got me thinking - we want our tests to pass if the feature in our app is working, we don’t really care which component is responsible for our feature, do we? So the solution is simple - we want to render our App component in all our tests! Right?

Well... no. If we render our App component, we could have to complete prerequisite tasks repeatedly (like navigating to the page the feature is on) before we even start testing the feature in question. Well why can't we come to a middle ground and just render the component that represents our page?

Bringing theory to practice

We've spoken a lot about the theory, but theory can only go so far - let's have a look at an example (you can find the source code on my GitHub here).

For this example, we've been asked to update our company's ticket booking app. When the app was built, the company was only running holding one event at a time. The company now wants to run multiple events and users should be able to book tickets onto all of them. We've been tasked with refactoring the code to allow users to book on as many events as they would like.

What you're starting with

The (oversimplified) app is composed of two components:

  • App
  • Event

The App component looks like so:

import React, { FC } from 'react';
import Event from '../Event';
import styles from './App.module.css';

const App: FC = () => {
  return (
    <div className={styles.container}>
      <h1>User Driven Testing</h1>
      <Event />
    </div>
  );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

and the Event component looks like so:

import React, { FC, useState } from 'react';
import purchaseTickets from '../../requests/purchaseTickets';
import joinClasses from '../../utils/joinClasses';
import styles from './Event.module.css';

const Event: FC = () => {
  const [ticketCount, setTicketCount] = useState(0);

  const increaseTicketCount = () =>
    setTicketCount((currentValue) => currentValue + 1);

  const decreaseTicketCount = () => {
    if (ticketCount > 0) {
      setTicketCount((currentValue) => currentValue - 1);
    };
  };

  const resetTicketCount = () =>
    setTicketCount(0);

  return (
    <div className={styles.container}>
      <h2>Super cool event!</h2>
      <div className={styles.btnGroup}>
        <button 
          onClick={decreaseTicketCount}
          className={joinClasses([styles.btn, styles.btnSecondary])}
        >
          Remove
        </button>
        <p>{ticketCount}</p>
        <button
          onClick={increaseTicketCount}
          className={joinClasses([styles.btn, styles.btnSecondary])}
        >
          Add
        </button>
      </div>
      <div className={styles.btnGroup}>
        <button
          onClick={resetTicketCount}
          className={joinClasses([styles.btn, styles.btnSecondary])}
          aria-disabled={!ticketCount}
        >
          Empty basket
        </button>
        <button
          onClick={() => purchaseTickets(ticketCount)}
          className={joinClasses([styles.btn, styles.btnPrimary])}
        >
          Purchase
        </button>
      </div>
    </div>
  );
};

export default Event;
Enter fullscreen mode Exit fullscreen mode

The first thing you notice is that the "Purchase" and "Empty" checkout buttons are in the Event component. You're aware you're going to have to refactor the code to allow you to add more events.

Before you start refactoring the code, you decide to look at the tests; the App test are minimal at best:

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

describe('App', () => {
  test('App loads correctly', () => {
    const { container } = render(<App />);

    expect(screen.getByRole('heading', { name: /User Driven Testing/i })).toBeVisible();
    expect(container.firstChild.children.length).toBe(2);
  });
});
Enter fullscreen mode Exit fullscreen mode

but the Event tests look quite detailed:

import { render, screen } from '@testing-library/react';
import user from '@testing-library/user-event';
import Event from '.';
import purchaseTickets from '../../requests/purchaseTickets';

jest.mock('../../requests/purchaseTickets');
const purchaseTicketsMock = purchaseTickets as jest.Mock;

beforeEach(() => {
  purchaseTicketsMock.mockReset();
});

describe('Event', () => {
  test('Event loads correctly', () => {
    render(<Event />);

    expect(screen.getByRole('heading', { name: /Super cool event!/i })).toBeVisible();
    expect(screen.getByRole('button', { name: /Remove/i })).toBeVisible();
    expect(screen.getByRole('button', { name: /Add/i })).toBeVisible();
    expect(screen.getByRole('button', { name: /Empty basket/i })).toBeVisible();
    expect(screen.getByRole('button', { name: /Purchase/i })).toBeVisible();
  });

  test('tickets can be added', () => {
    render(<Event />);

    const addBtn = screen.getByRole('button', { name: /Add/i });
    const ticketTotal = screen.getByText('0');

    user.click(addBtn);
    expect(ticketTotal).toHaveTextContent('1');

    user.click(addBtn);
    expect(ticketTotal).toHaveTextContent('2');
  });

  test('tickets can be reduced down to 0', () => {
    render(<Event />);

    const addBtn = screen.getByRole('button', { name: /Add/i });
    const removeBtn = screen.getByRole('button', { name: /Remove/i });
    const ticketTotal = screen.getByText('0');

    user.click(addBtn);
    expect(ticketTotal).toHaveTextContent('1');

    user.click(removeBtn);
    expect(ticketTotal).toHaveTextContent('0');

    user.click(removeBtn);
    expect(ticketTotal).toHaveTextContent('0');
  });

  test('empty basket button sets ticket count to 0', () => {
    render(<Event />);

    const addBtn = screen.getByRole('button', { name: /Add/i });
    const emptyBasketBtn = screen.getByRole('button', { name: /Empty basket/i });
    const ticketTotal = screen.getByText('0');

    user.click(addBtn);
    expect(ticketTotal).toHaveTextContent('1');

    user.click(addBtn);
    expect(ticketTotal).toHaveTextContent('2');

    user.click(emptyBasketBtn);
    expect(ticketTotal).toHaveTextContent('0');
  });

  test('purchase button calls purchaseTickets', () => {
    render(<Event />);

    const addBtn = screen.getByRole('button', { name: /Add/i });
    const purchaseBtn = screen.getByRole('button', { name: /Purchase/i });
    const ticketTotal = screen.getByText('0');

    user.click(addBtn);
    expect(ticketTotal).toHaveTextContent('1');

    user.click(addBtn);
    expect(ticketTotal).toHaveTextContent('2');

    user.click(purchaseBtn);
    expect(purchaseTicketsMock).toHaveBeenCalledWith(2);
  });
});
Enter fullscreen mode Exit fullscreen mode

Time for a refactor

You decide to create an EventsGroup component which will be responsible for rendering all your Event components and rendering your "Purchase" and "Empty" buttons. Your EventsGroup component ends up looking something like this:

import React, { FC, useState } from 'react';
import purchaseTickets from '../../requests/purchaseTickets';
import Button from '../../components/Button';
import Event from '../../components/Event';
import styles from './EventsGroup.module.css';

const EventsGroup: FC = () => {
  const ticketCountInit = {
    superCoolEvent: 0,
    anotherAmazingEvent: 0,
  }
  const [ticketCount, setTicketCount] = useState<Record<string, number>>(ticketCountInit);

  const totalTicketCount = Object.values(ticketCount)
    .reduce((total, current) => total + current, 0);

  const increaseTicketCount = (eventKey: string) => () =>
  setTicketCount((currentValue) => ({
    ...currentValue,
    [eventKey]: currentValue[eventKey] + 1,
  }));

  const decreaseTicketCount = (eventKey: string) => () => {
    if (ticketCount[eventKey] > 0) {
      setTicketCount((currentValue) => ({
        ...currentValue,
        [eventKey]: currentValue[eventKey] - 1,
      }));
    };
  };

  const resetTicketCount = () =>
    setTicketCount(ticketCountInit);

  return (
    <div className={styles.container}>
      <Event
        eventName="Super Cool Event!"
        ticketCount={ticketCount.superCoolEvent}
        increaseTicketCount={increaseTicketCount('superCoolEvent')}
        decreaseTicketCount={decreaseTicketCount('superCoolEvent')}
      />

      <div className={styles.btnGroup}>
        <Button
          onClick={resetTicketCount}
          isAriaDisabled={totalTicketCount <= 0}
          variant="secondary"
        >
          Empty basket
        </Button>
        <Button
          onClick={() => purchaseTickets(totalTicketCount)}
          variant="primary"
        >
          Purchase
        </Button>
      </div>
    </div>
  );
};

export default EventsGroup;
Enter fullscreen mode Exit fullscreen mode

And your Event component ends up looking like this:

import React, { FC } from 'react';
import Button from '../Button';
import styles from './Event.module.css';

interface Props {
  eventName: string
  ticketCount: number
  increaseTicketCount: () => void
  decreaseTicketCount: () => void
};

const Event: FC<Props> = ({
  eventName,
  ticketCount,
  increaseTicketCount,
  decreaseTicketCount
}) => {
  return (
    <div className={styles.container}>
      <h2>{eventName}</h2>
      <div className={styles.btnGroup}>
        <Button 
          onClick={decreaseTicketCount}
          variant="secondary"
          isAriaDisabled={ticketCount <= 0}
        >
          Remove
        </Button>
        <p>{ticketCount}</p>
        <Button
          onClick={increaseTicketCount}
          variant="secondary"
        >
          Add
        </Button>
      </div>
    </div>
  );
};

export default Event;
Enter fullscreen mode Exit fullscreen mode

Amazing! You've refactored your code and it's functionally the same! It looks the same in the browser, you can still add/remove tickets exactly the same, and we can easily add as many events as we like in future! So as far as you're concerned, you're job is done! Right?

Well... we've forgotten an important step: to run our tests. We may have forgotten, but our pipeline hasn't - and our pipeline is not happy. All our tests are failing. But why? The code is functionally the same?

But why didn't our tests pass?

"I would have gotten away with it too, if it weren't for you meddling tests!" ~some Scooby Doo antagonist

So our tests aren't passing, everyone in our team has seen, and we're extra annoyed because the app works exactly the same. How can we fix this? Or better yet, how could we have avoided this?

If we want to fix this, there is a simple solution of updating our unit tests. But should our tests really be breaking if the app works the same?

An alternative (and, by me writing this article, my obvious preferred approach) is to convert our unit tests to integration tests. These integration tests will allow us to verify app functions the way we expect and will allow us to refactor our code in future without risk of breaking our tests (ya know, provided we haven't actually broken anything...).

To do this, we can use the test cases defined in our Event test file - but instead of rendering our Event component, we can render our App container. The tests will all pass now! Yay!

Are these really integration tests?

Now I know what you're thinking, "surely that's just an end-to-end test?". Well, while we are testing the all our code in one test file, we should really be seeing it as only testing one feature of our code. In larger code bases, I would not recommend rendering the App component in your integration tests - instead, I would recommend rendering the lowest level container responsible for the feature you're testing (in this case, we actually could render EventsGroup instead, but we didn't have this component at the start).

Location, location, location

Where we store our tests in all down to personal preference. I prefer to keep my integration tests outside my src folder, some people like to have them within the src folder, and others prefer to have them next to the component they are rendering in the test.

It really is up to you, but I prefer to keep my src folder for functional code and unit tests (yes, I do think we should have unit tests on occasion too).

Conclusion

So we set out to refactor a our company's ticket app but broke our tests because they weren't written from a user's perspective. We've seen that if we had written our tests from a user's perspective, we could have made our refactor and our tests would have confidently told us we haven't broken any features of our app.

I do think it's important to still write unit tests for core pieces of logical code (say, any bespoke hooks you write), but the majority of our testing should be done on an integration level for the most return on investment.

Our primary care is how our users interacts with our app. Our users don't care how our features are implemented, so neither should our tests.

Top comments (0)