DEV Community

Cover image for Why are functional tests so much longer than unit tests?
Bonnie Schulkin
Bonnie Schulkin

Posted on

Why are functional tests so much longer than unit tests?

Photo by Nicolas Häns on Unsplash

My unit testing background has trained me to write short tests, each covering one discrete piece of code. This makes unit tests simple to read and easy to diagnose. So why can’t this approach be applied to functional tests?


Functional Tests vs Unit tests

Let’s back up a bit here to distinguish between unit and functional tests. Unit tests isolate one piece of code, often a function or React component. By definition, they are tightly associated with the code, which makes them easy to diagnose (they point right to the area in the code that’s causing the failure!) but sometimes farther away from the actual user experience (especially if the coder uses mocks to keep other parts of code from polluting the unit test).

Functional tests on the other hand, test a user flow representing some functionality of your app. These emphasize interacting with the app the way a user would (clicking buttons and entering text) and de-emphasize any focus on specific code (you may have heard that it’s better not to test “internals” — or what’s going on in the code — because this makes your tests brittle and susceptible to breaking on refactors, even though there’s nothing wrong with your app).

React testing is currently trending strongly toward functional tests and away from isolated unit tests. This is great for the reasons mentioned above and in my Enzyme vs Testing Library post, but it can lead to tests that seem uncomfortably long and meandering to an old unit tester like me.

Example of a long(ish) functional test

Say you have an app that allows people to design and order an ice cream sundae. Before they submit the order, the terms and conditions warn their ice cream order isn’t ever gonna happen (via a popover):

Mockup showing popover text on mouseover of “Terms and Conditions”

You can’t say you weren’t warned

The test

Here is the test for the popover, using React Testing Library syntax. This test is short compared to a lot of my actual functional tests (hence the “ish” in the title of this section). Still I wanted something small and easy to follow for this article.

    test('popover responds to hover', async () => {  
          render(<SummaryForm />);  

          // assertion 1:   
          // popover starts out hidden  
          const nullPopover = screen.queryByText(  
            /no ice cream will actually be delivered/i  
          );  
          expect(nullPopover).not.toBeInTheDocument();  

          // assertion 2:   
          // popover appears upon mouseover of checkbox label  
          const termsAndConditions = screen.getByText(/terms and conditions/i);  
          userEvent.hover(termsAndConditions);  

          const popover = screen.getByText(/no ice cream will actually be delivered/i);  
          expect(popover).toBeInTheDocument();  

          // assertion 3:  
          // popover disappears when we mouse out  
          userEvent.unhover(termsAndConditions);  
          await waitForElementToBeRemoved(() =>  
            screen.queryByText(/no ice cream will actually be delivered/i)  
          );  
        });
Enter fullscreen mode Exit fullscreen mode

This test has three assertions:

  1. The popover is hidden when the component is first rendered
  2. The popover appears when the mouse hovers over “Terms and Conditions”
  3. The popover disappears when the mouse leaves “Terms and Conditions”

Why not make three separate tests?

This particular test could be broken into three tests, one for each of the assertions above:

    // test #1 //
    test('popover starts out hidden', async () => {  
      render(<SummaryForm />);
     // assertion 1  
      const nullPopover = screen.queryByText(  
        /no ice cream will actually be delivered/i  
      );  
      expect(nullPopover).not.toBeInTheDocument();  
    });

    // test #2 //
    test('popover appears after mouseover', () => {  
      render(<SummaryForm />);  

      // find and mouseover the Terms and Conditions text  
      const termsAndConditions = screen.getByText(/terms and conditions/i);  
      userEvent.hover(termsAndConditions);
     // assertion 2  
      popover = screen.getByText(/no ice cream will actually be delivered/i);  
      expect(popover).toBeInTheDocument();  
    });

    // test #3 //
    test('popover disappears on mouseout', () => {  
      render(<SummaryForm />);  

      // find and mouseover the Terms and Conditions text  
      const termsAndConditions = screen.getByText(/terms and conditions/i);  
      userEvent.hover(termsAndConditions);  

      // make sure the assertion appeared  
      popover = screen.getByText(/no ice cream will actually be delivered/i);  
      expect(popover).toBeInTheDocument();
     // assertion 3  
      userEvent.unhover(termsAndConditions);  
      await waitForElementToBeRemoved(() =>  
        screen.queryByText(/no ice cream will actually be delivered/i)  
      );  
    });
Enter fullscreen mode Exit fullscreen mode

However, this is not necessarily an improvement — especially when it comes to separating tests (2) and (3). In order to set up the third test (popover disappears), we’d have to go through all the same steps we went through in the second test (getting the popover to appear, since we don’t know whether the popover disappeared unless it was actually there at some point).

Repeating the “appears” code in two separate tests feels repetitive and unnecessary.

What about a beforeEach?

Maybe we should put the “appears” code in a beforeEach that runs, well, before each test.

    describe('popover appears and disappears', () => {  
      beforeEach(() => {  
        render(<SummaryForm />);  

        // find and mouseover the Terms and Conditions text  
        const termsAndConditions = screen.getByText(/terms and conditions/i);  
        userEvent.hover(termsAndConditions);  
      });

    // test #1 //
    test('popover starts out hidden', async () => {  
      render(<SummaryForm />);
     // assertion 1:   
      const nullPopover = screen.queryByText(  
        /no ice cream will actually be delivered/i  
      );  
      expect(nullPopover).not.toBeInTheDocument();  
    });

    // test #2 //
    test('popover appears after mouseover', () => {  
       // assertion 2:   
       popover = screen.getByText(/no ice cream will actually be delivered/i);  
       expect(popover).toBeInTheDocument();  
     });

    // test #3 //
    test('popover disappears on mouseout', () => {  
       // assertion 3  
       userEvent.unhover(termsAndConditions);  
       await waitForElementToBeRemoved(() =>  
         screen.queryByText(/no ice cream will actually be delivered/i)  
       );  
     });  
   });
Enter fullscreen mode Exit fullscreen mode

Here, we’re writing one test that doesn’t do anything other than assert the popover is in the document. Then there's a second test that builds on the beforeEach by running through the "disappears" code. That would work to break this particular test into three tests.

Reasons NOT to use a beforeEach

  1. Any time you break up the test code by using beforeEach, the code is less readable and requires more effort to determine what exactly happened when debugging failing tests.

  2. This second reason is the stronger one for me.

For this fairly simple user flow test, one beforeEach would cover the setup for both of the tests that aren't for initial conditions. However, imagine a more involved user flow, where a user:

  • logs on to the site
  • selects some ice cream scoops and toppings
  • sees the order summary
  • agrees to the terms and conditions
  • sees a confirmation page

A single beforeEach will not be able to cover the setup for the half dozen or so actions we go through as part of the flow. If we wanted to isolate each action / assertion into its own test, it would require either

a. repeating a lot of the setup of the previous tests, or

b. mocking and/or setting the context value explicitly in order to set up the tests -- which is frowned upon in this type of functional, user-based testing because it's not what a user would actually do to get into that situation.


Conclusion

The above is a verbose way of saying: most functional tests go through a series of steps, each step relying on the consequences of the previous step. The only way to run these tests without a huge amount of complexity or verbosity is to go through the whole flow in a single test, asserting along the way.

Is this isolated? No. Is this testing one specific area of the code? Also no. The point here is to test the app, not the code so that the tests more accurately reflect whether or not the app is working from a user’s perspective. The down side is that these tests are harder to debug since they’re more vague about which part of the code caused the error. This is part of the cost of functional testing. If you’re interested in how unit testing can mitigate this, take a look at my article on When to Unit Test your React App.

Top comments (2)

Collapse
 
bgw8 profile image
Emil Rydén • Edited

Hey Bonnie, just bought your react query course on udemy and so far it’s awesome. I wanted to tell you that i checked your personal website on mobile and the hamburger-icon looks quite off , maybe a justify-content: space-between is missing? This comment field was the only place i could reach out. Thanks for the great course!

Collapse
 
bonnie profile image
Bonnie Schulkin

Thank you so much for taking the React Query course, and for mentioning the error with my website on mobile! That website has been long neglected and is due for a refresh. In the meantime, I will take a look at the hamburger on mobile this week. Really appreciate the heads-up!