Jest a minute — a better way to structure your unit tests

Gherkin on top of an open faced sandwich

If you're anything like me, you probably find unit tests a bit haphazard. Say you go to add some new functionality to an existing repository, or fix a bug, it may be difficult to determine where you should slot in your unit test(s) and whether certain functionality has already been tested or not.

I often come across tests named something like this:

it('should call the api when the button is clicked only if the form has already been filled in and there are no validation errors', () =>{...

I don't know about you, but I stopped paying attention about half way through. In order to actually understand what this test is doing, I have to read it three or four times. Worst of all, the contents of this test tends to be an enormous jumble of code.

Let me introduce a method to help.

Hopefully you'll have already heard of the AAA test pattern — that's not exactly what I'm going to talk about here, but I'll summarise what each A stands for so that we're starting on the same page.

  • Arrange — This is where you will set up the conditions for your test. In the example above this would consist of rendering a page with a form filled in with no expected validation errors.
  • Act — this is the action that a user would take which is the operation that the test is designed to verify. In this example it would be clicking the button.
  • Assert — this is where you verify that what you expected to happen actually happened. In our example this would be a check that the api was called

This is the foundation of my approach, however I'd like to add some guide-rails to make it easier and neater to write our unit tests.

We will do this using the Gherkin syntax, ie. Given, When, Then.

Don't be afraid, I promise it will be worth it!

Using the example above, and thinking about it using Given, When, Then, I would write it like this:

  • Given the form is filled in with valid data
  • When the button is clicked
  • Then the api should be called

Already we can see that this is a lot simpler to read and understand. We can use describe blocks and the beforeEach method to simplify our code and make it easier to follow:


describe('Given the form is filled in with valid data', () => {
    beforeEach(fillFormWithValidData);
  
    describe('When the button is clicked', () => {
        beforeEach(clickButton);
      
        test('Then the api should be called', () => {
            expect(mockedApiFunction).toHaveBeenCalledTimes(1);
        });
    });
});
  
const fillFormWithValidData = () => {
    userEvent.clear(screen.getByLabelText('Name'));
    userEvent.type(screen.getByLabelText('Name'), 'Bob');
}

const clickButton = () => {
    userEvent.click(screen.getByText('Submit'));
}

As you can see, within the 'Given' block, we are setting up our test by filling in a form with valid data. In the 'When' block we are clicking the button, and finally in the 'Then' block we are making our assertion. Each of our Arrange, Act and Assert steps are clearly separated, and as an added benefit, they are easily reused.

Having our tests laid out like this helps to think through other test cases. In this example we would ask ourself “What else happens when the button is clicked?”.

Let's say our page updates when the 'Submit' button is clicked so that it shows 'Loading…' on the screen when the api call begins. We can simply add a new 'Then' like so:


describe('Given the form is filled in with valid data', () => {
    beforeEach(fillFormWithValidData);
  
    describe('When the button is clicked', () => {
        beforeEach(clickButton);
        
        test('Then the api should be called', () => {
            expect(mockedApiFunction).toHaveBeenCalledTimes(1);
        });
    
        test('Then "Loading..." should be shown on screen', () => {
            expect(screen.getByText('Loading...')).toBeInTheDocument();
        });
    });
});

If we then wanted to check some validation cases we could have a new 'Given' block as so:


describe('Given the form is filled in with invalid data', () => {
    beforeEach(fillFormWithInvalidData);
  
    describe('When the button is clicked', () => {
        beforeEach(clickButton);
        
        test('Then the api should not be called', () => {
            expect(mockedApiFunction).toHaveBeenCalledTimes(0);
        });
    
        test('Then an error message should show', () => {
            expect(screen.getByText('Name field is not valid')).toBeInTheDocument();
        });
    });
});
  
const fillFormWithInValidData = () => {
    userEvent.clear(screen.getByLabelText('Name'));
    userEvent.type(screen.getByLabelText('Name'), '1234');
}

An added benefit from this approach is that it documents the intended functionality in an easy to read manner, with no extra effort. Your test output at this point should look like this:


Given the form is filled in with valid data
    When the button is clicked
        ✓ Then the api should be called
        ✓ Then "Loading..." should be shown on screen
Given the form is filled in with invalid data
    When the button is clicked
        ✓ Then the api should not be called
        ✓ Then Then an error message should show

This is something that could very easily be passed to a non technical person if they needed to know what functionality has been implemented for a certain part of the application.

This is obviously a very simplified example, and we're not taking into account things like rendering the component or handling errors from the api. Let's say that if we get a 200 response from the api, we will show 'Success' on screen and if we get an error we will return 'Failed' on the screen. This would be my test file:


describe('MyNameForm', () => {
    describe('Given the page is rendered', () => {
        beforeEach(() => render(<MyNameForm>));
        
        describe('And the form is filled in with valid data', () => {
            beforeEach(fillFormWithValidData);
        
            describe('When the button is clicked', () => {
                const clickButton = () => {
                    userEvent.click(screen.getByText('Submit'));
                }
    
                describe('And the api returns a 200 response', () => {
                    beforeEach(() => {
                        mockedApiFunction.mockImplementation(() => {
                            return {status: 200};
                        });
                        clickButton()
                    });
                
                    test('Then the api should be called', () => {
                        expect(mockedApiFunction).toHaveBeenCalledTimes(1);
                    });
                
                    test('Then "Success" should be shown on screen', () => {
                        expect(screen.getByText('Success')).toBeInTheDocument();
                    });
                });
                
                describe('And the api returns an error', () => {
                    beforeEach(() => {
                        mockedApiFunction.mockImplementation(() => {
                            return new Error('Somethings gone wrong!')
                        });
                        clickButton()
                    });
                
                    test('Then the api should be called', () => {
                        expect(mockedApiFunction).toHaveBeenCalledTimes(1);
                    });
                
                    test('Then "Failed" should be shown on screen', () => {
                        expect(screen.getByText('Failed')).toBeInTheDocument();
                    });
                });
            });
        });
    
        describe('Given the form is filled in with invalid data', () => {
            beforeEach(fillFormWithInvalidData);
        
            describe('When the button is clicked', () => {
                beforeEach(clickButton);
            
                test('Then the api should not be called', () => {
                    expect(mockedApiFunction).toHaveBeenCalledTimes(0);
                });
            
                test('Then an error message should show', () => {
                    expect(screen.getByText('Name field is not valid')).toBeInTheDocument();
                });
            });
        });
    });
});

With my test output looking like:


MyNameForm
  Given the page is rendered
    And the form is filled in with valid data
        When the button is clicked
            And the api returns a 200 response
                ✓ Then the api should be called
                ✓ Then "Success" should be shown on screen
            And the api returns an error
                ✓ Then the api should be called
                ✓ Then "Failed" should be shown on screen
    And the form is filled in with invalid data
        When the button is clicked
            ✓ Then the api should not be called
            ✓ Then Then an error message should show

Notice that I've not actually clicked the button within the 'When the button is clicked' block like I was doing before. This is really just down to a difference in how we would talk about a sequence of events (clicking the button and having the api respond) and how the test needs to be written (we have to define a response before we make the action). I still define the action within this block so that it is scoped to this level. This is one place where this approach does not line up fully with Given = Arrange, When = Act, however I prefer the readability of this.

While writing this article, I've noticed that lining up all of the nested blocks can be a bit troublesome, however, using an IDE simplifies this massively, especially if you're in the habit of folding your code.

Another thing I would recommend if you want to start following this pattern (and you're using VS Code) is to add snippets for 'Given', 'When' and 'Then'. I've touched briefly on how to add snippets in this article:

5 simple tips to speed up your development environment

I'm pretty lazy, but lazy in a productive way, so I spend a little extra time and effort once so I can be lazy in the future.

24 Aug 2021
Feet up next to laptop with code on it

Please leave a comment and let me know if you try this approach. It's been a game changer for me!

Richard Bell

Self taught software developer with 11 years experience excelling at JavaScript/Typescript, React, Node and AWS.

I love learning and teaching and have mentored several junior developers over my career. I find teaching is one of the best ways to solidify your own learning, so in the past few years I've been maintaining a technical blog where I write about some things that I've been learning.

I'm passionate about building a teams culture and processes to make it efficient and satisfying to work in. In many roles I have improved the quality and reliability of the code base by introducing or improving the continuous integration pipeline to include quality gates.