10 Rules of Front-End TDD to Abide By

Frosina Stamatoska
The Startup
Published in
10 min readDec 15, 2020

--

Passed tests
The most beautiful thing to see when writing tests

It has been one year since I’ve joined my current team. It is a team consisting of three members, me and two other backend developers.

My team members are pretty experienced and they have been practicing TDD extensively over the years. It wasn’t quite long when I started hearing the phrase “Always start with a test!” and it was a phrase that I started to listen to quite often. Prior to starting to work as a React developer, I haven’t had an opportunity to practice testing my code, apart from testing a backend API and some integration testing and Angular tests with Jasmine and Karma. Thus I felt pretty uncomfortable submitting a PR and writing a test about it. I have to be honest, at first I was cheating, I was not following TDD principles at all and all my tests were some simple checks whether some element is present or not. But then again, I guess, something is better than nothing.

In a way, I always had a feeling that frontend TDD is like you are trying to code blindfolded.

Our team eventually grew and as we’ve started working from home we started to have pair programming sessions more frequently, so we’ve started practicing TDD more and more. At the beginning it was really hard, but in time it started to become surprisingly more and more interesting.

What I’ve learned during this year is that the learning curve is indefinite, you can never say ‘Now I think I know everything’ or ‘I think now I do it best’. As we gain more knowledge on how to test, we should improve our tests. Just a few days ago we managed to improve a test that was increasing the test execution time significantly, just by simply mocking all the components that were not directly used in the test. Imagine how much unnecessary renders were done and how much code was executed by using the components as they were.

During this post I will include mostly React + Jest examples, but they are pretty similar to any front-end testing framework, so please don’t be downhearted if you are not using them, you can learn something new anyway.

Here are a couple of rules I’ve learned during this adventure:

  1. The setup of the test should be well-devised

I think the setup of the testing environment is one of the most important things when writing integration tests. Believe me, it is really hard to make the right setup, especially if you are not starting from a clean codebase. At a certain point, it has happened to spend hours and hours figuring out what is actually wrong with my test, when the obvious problem was me not having a good setup. My advice is, first try to identify all dependencies for that group of components you want to test. Then decide which dependencies should be used as-is and which of them should be mocked.

TDD is not hard to set up when you are trying to test a single component, but once you start to test multiple components together, believe me, that is a whole different story.

Within my team, we are using setup functions per test suite which can receive different parameters. Then depending on the test we send different parameters to the setup function. Make your setup function configurable as much as you can, so you will not need to repeat the same setup code in each test or write several different setup functions.

You should thoroughly check what your test really needs. If it depends on context, include the context. If it depends on the router, include the router.

Below you can find a couple of different examples of setup functions. See how my setup changes when my component has different dependencies?

Example 1:

const userList = [{name: ´John doe´}];const setup = () => render(<MemoryRouter>   <UsersTable users={userList} /></MemoryRouter>);

The code above is an example of a users table that accepts a list of users as a parameter. Let’s say that you can click on each row on the table to see the details of a user. That means that you need the router, right? So we should wrap our component in a Router in order for our tests to be successful.

Example 2:

fetchMock.mock(´api/users/´, {method: ´GET´, status: 200, body: {  users: [{name: ´John doe´}]}})const setup = () => render(<MemoryRouter>    <UsersTable/></MemoryRouter>);

Now, let’s say that your UsersTable component doesn’t accept users list as a parameter, but makes an api call to fetch the list, then you should mock the api call associated with this component.

Example 3:

const setup = () => render(<MemoryRouter>    <UsersContext.Provider value={{     users: [{name: ´John doe´}]    }}>       <UsersTable />    </UsersContext.Provider></MemoryRouter>);

If your component depends on a context for getting the users, then you should include the context in your setup. The example above is how to mock the UsersContextProvider and provide a mocked list of users, but you can also use the default Provider and just mock the api call behind it. It would look like this:

fetchMock.mock(´api/users/´, {method: ´GET´, status: 200, body: {   users: [{name: ´John doe´}]}})const setup = () => render(<MemoryRouter>   <UsersContextProvider>     <UsersTable />   </UsersContextProvider></MemoryRouter>);

Of course in all of these cases, you can make the list configurable and pass the users as a parameter, so in each test you will have a custom list of users. Here is an example:

const setup = (userList) => render(<MemoryRouter>   <UsersContext.Provider value={{users: userList }}>      <UsersTable />   </UsersContext.Provider></MemoryRouter>);

A wide range of different scenarios right? Which one you should choose depends heavily on your setup and what you want to test.

2. Mock each subcomponent/class that you do not directly use in the test

Everything you do not use directly in the test — mock it. This is also valid for components whose inner logic has not been tested, but only whether they appear or not. You can easily mock these components with some text wrapped in a HTML element e.g div or span and assert whether the text is there or not.

For example, when you do not directly depend on the router or the route and query parameters, you can mock them. But if your component behavior depends on them and they will change in the test you should not mock them because as that you will not get the expected behavior.

Example:

Const mockUserEditForm =  () => ´<div>This is an edit form</div>´;jest.mock(´users/components/EditForm´, () => mockUserEditForm);const setup = () => render(<UsersDashboard />);
describe(´When the users dashboard is displayed, () => { describe(´And a user edit button is clicked, () => { it(´It should display the edit form´, () => { const { getByText, getAllByRole } = setup(); const editButton = getAllByRole(´button´)[0]; userEvent.click(editButton); expect(getByText(´This is an edit form´)).toBeInTheDocument(); }); });});

3. Keep your data mocks closer to the test

You need this, believe me! The project I’m currently working on relies heavily on redux, but we’ve started slowly to implement the Context API. When I started working on the project the only tests that we had were snapshot tests and one big mocked redux store which was really hard to maintain. Not only that, it was really hard to find what exactly is your test data. As we started moving to Context API we were able to mock the API calls that were used in the context and switch to data objects per API call. Now, not only are we testing more real scenarios, but we are also one click away from the test data and do not need to wonder where something came from.

4. Always start with a test

I never thought I would say this! Start with a test. Even if it is only an assertion whether the element you expect to see is there, write it before the actual code. And make the test fail on purpose — as part of the TDD core rules. Then you will refactor the failed test with minimal code just in order to make it pass. And again you fail the test and refactor with minimal code and you are repeating this cycling process until all your criteria are satisfied. Of course, each criteria should be covered with a separate test. The same is valid for the corner cases. When you take a corner case into consideration you first write a test about it, then implement it.

5. Make the test fail for the right reason

This is also one of the most important things along with the setup. You must know why your test fails. You cannot write a test that will assert if some element is there and all of sudden you see some random error that something else is undefined. That means that you are probably missing something in the setup. You can, of course, continue to write your code, but your test will never pass.

6. Mock third-party library dependencies that you do not directly need

Each library has its own behavior and you do not want to test that. That is the library’s job, right?

So mock everything that you do not need from that library or you can even mock the entire library if your tests do not depend on it.
Let’s say you use a library for validation and you have all your fields validated. You will not test whether the form default errors from the library are displayed, you will test some custom validation that you probably may have. Sometimes you will not even have to check this, but use the form. You may get some errors that the library wants to interact with the component after the component was destroyed. And that is fine because at that moment you are not testing the library behavior. For these situations you can just mock the part(s) of the library that do this.

7. Do not over snapshot

Avoid making a lot of snapshots. You do not need to take a snapshot of everything. Snapshots can be helpful while you’re writing your test, so you do not need to rely on the browser in order to test or for some custom classes behavior.

You will end up just updating the snapshots and not checking what and why was changed. You can easily find yourself in a situation to misinterpret an error if you rely only on snapshots.

8. Use mocked API calls when possible

If you want to test as much as to the real behavior I suggest mocking the API calls and using data mocks directly in the store/context/component props as little as possible. Like this you will test the whole process of fetching the data, whether all the API calls are made and everything behaves as expected.

With using any mocking library you can easily mock api calls and use the correct method and status code. For example, if you use the same url for a get and post, you can mock them separately and configure only the response and status. I suggest that you mock your api call exactly the number of times you expect it to be used in your test/test suite. We do not want to mock an API call 100 times while we expect it to be called only 10 times. If we mock only the expected number of times we are preventing any further errors and we can easily discover if our component behaves as expected.

I recommend resetting the mocking api library history after each test so you can be sure that what you are testing has happened exactly at that point of the testing process. We do not want false positives right?

9. Add tests as you add new features/rules

As I’ve mentioned above, each of your rules should be first covered with a test and afterwards implemented. With this you will strictly follow TDD.

One simple case scenario, imagine that we want to have one input field and a button. The value of the button needs to be between 1 and 10000 and we want to check whether the button behaves correctly. We first write a test to check ‘if the value is between 1 and 10000, the button is enabled’. The test should fail and then we should go and implement an input field and a button.

After we’ve implemented this, we write another test case about the values that are below 1. The button should be disabled for these values. The test will fail because we haven’t implemented something like this. We go back and implement that logic. We go back and run the tests and they should all pass.

So afterwards, we continue to write the other tests for values above 10000 and we repeat the process until the end.

10. Try to wrap the particular test with multiple describe blocks (as much as possible) to make it more understandable

When you’re writing the test imagine you are giving a book to someone where your code behavior is documented. So you want this to be a good book, where everything is explained in detail and well documented. I will give you one bad and one good example.

Let’s say that we have users’ form with a button that becomes disabled at a certain point in time (or after some condition is fulfilled).

Bad:

describe(´Users form´, () => {  it(´Disabled button´, () => {    // the test  });});

Good:

describe(´Given the users form´, () => {   describe(´When the user tries to update the username field´, () => {
describe(´And he enters an invalid username´, () => {
it(´The save button should become disabled´, () => { // the test }); }); });});

In the first example, the person who will read the test and try their best to understand what is happening in your code (this can be you in the future) has no idea when the button should be disabled. He just knows that it can be disabled in some cases. He will then need to dive into the code and maybe try to refactor the test and even do a more thorough check to have an overall better understanding.

In the second example it is so obvious why and when the button should be disabled. There is no need of reading the test code into details to figure out what is happening — just read the description blocks, that’s it.

And for the end, one more tip, bear in mind that two pairs of eyes are always better than one. We’ve all been there. Try to explain to your colleague, your friend or even your duck when you have an issue and can not figure out what is wrong. Maybe they’ve experienced a similar situation or maybe they will have an idea what you are missing in your test or just simple talking with your rubber duck might give you the solution you are aiming for.

If you are still here that means that you’ve read the whole post. Thank you for considering the post helpful, it means a lot to me. I hope that I’ve contributed to your better understanding of the frontend TDD and some of its possibilities or at least encouraged you to adopt TDD in your everyday coding.

--

--

Frosina Stamatoska
The Startup

Software developer @ Twill by Maersk | Based in Netherlands | Loves cooking, cycling, travelling | https://frosinas.github.io/