React: How Tests will Boost your Development Speed
I know, you have heard this a lot: Tests are important for your application. We all know this, right? But we do not always follow best practices until we are punished for our negligence. Here is my story.
Happy Feature Coding - and no Testing?
So, I started to implement my board game app, and it evolved from searching board games to manage board games in your collection. I was very happy with my development speed to churn out new features. But then it backfired. There were these 20+ unstructured JavaScript files. There were changes that broke my application after I made the commit. There was more and more development time allocated to manually re-testing things I already tested. At this turning point, I decided to add tests.
Which Tests do I Need?
Ok, I need tests. But what should I test? What is the right granularity for my tests?
In general, you can distinguish tests into unit, integration and acceptance test. Unit tests are concerned with the individual objects and functions of your app. Integration tests show that several objects of your app will work together. And finally, the acceptance tests are about the application features that are important to your users.
In the context of an React app, these tests mean:
- Unit tests: single components, components with application logic, presentational components with UI state
- Integrations tests: Components with application state, container components with children components
- Acceptance tests: Application in the browser
Now you need to decide which test granularity is required for your application. I cannot give you a definite answer, but will just list my considerations that lead to my approach:
- I want to test important application logic, and this logic should be pure functions that are imported into my React components
- I want to test that my Redux actions and my internal API are working
- I want to test the main feature of my application, which are searching for board games, editing my profile, adding and removing games from the game collection
Therefore, I introduced unit tests for application logic and Redux reduce actions. Also, acceptance test will cover the main features. I do not need integration tests since these are (partly) covered by the acceptance tests.
First Tests
Once I made the decision to use tests, I stopped developing any new features. All commits were entirely about providing a sufficient test base.
The first part was to consider all my Redux actions and write tests for them. These tests are not complex because you can call the Redux actions and the dispatcher without additional test configuration.
Then I considered the current features, and started with the search board game function. Acceptance tests require more setup: You need to integrate the test runner with the test executor. The first test took me several hours, including learning of the test setup, browser configuration, and the details of selectors and DOM manipulations. When I finished this first acceptance test, I felt accomplishment and assured that my code works.
Acceptance Test Example
There are many test runners and test executors in JavaScript. My choice is puppeteer, because it comes bundled with a headless Chromium browser and terse syntax focusing on the interactions with the webpage. I will not detail how to write tests because there are great tutorials available, but will show an example for testing the board game search.
1 test('Search for "Alhambra", and click on button "See-More"', async () => {
2 await browser.click('a[href="/bgsearch"]');
3 await browser.focus('#boardgame');
4 await browser.keyboard.type('Alhambra', { delay: 400 });
5 await browser.click('input[value="Search"]');
6
7 await browser.waitForSelector('#seeDetails-6249');
8 await browser.screenshot({ path: 'tmp/screenshot1.png' });
9 await browser.click('#seeDetails-6249');
10 await browser.screenshot({ path: 'tmp/screenshot2.png' });
11
12 var html = await browser.$eval('#popup', elem => elem.innerHTML);
13 expect(html).toMatch('Alhambra</h3>');
14 expect(html).toMatch('Queen Games</td>');
15 }, 30000);
In this test, we see:
- Line 1: The
test
methods defines a test case. The first argument to this method is an explanation, which will be pretty-printed when the test is executed. And the second argument is a function the contains the test. - Line 2-3: The test creates a
browser
instance which accesses the app running locally athttp://localhost:3000
. From there, the test clicks a link with the CSS selectora[href="/bgsearch"]
, then focuses on the input field with the id#boardgame
. - Line 4-5: Enter the word "Alhambra" into a text field, and then click the search button.
- Line 7: The method
waitForSelector
pauses the test execution until the selector becomes available. Why? Because searching for a board game is an API request that can take some time. Once the selector is successfully applied to the current page, the tests continues. - Line 8: A nice feature is to make screenshots. This helps you in debugging your test, for example when a CSS selector does not work as you assumed.
- Line 9: Click on another link to open a popup with the board game details.
- Line 12: Select the inner HTML of the popup.
- Line 13-14: In this HTML, check that a header tag with the value "Alhambra" is included, and also check that the publisher "Queen Games" is contained.
When this test is executed, we see the test results pretty printed in the console:
PASS src/tests/redux.test.js
MyAccount: User Details
✓ should have in initial state username = unknown, loggedIn = false (3ms)
✓ should change user details (1ms)
MyAccount: Change Games in Collection
✓ should add three items from the list (1ms)
✓ should not add a game twice
✓ should update the first item
✓ should delete one item from the list (1ms)
..
PASS src/tests/api.test.js
API: User Search
✓ should provide one entry when searching for Tom (46ms)
✓ should find Users Tom and Val when searching for "Tapestry" (28ms)
API: Timeline entries
✓ should add timeline entries (56ms)
✓ should return timeline entries (8ms)
..
PASS src/tests/ac.usersearch.test.js (8.921s)
AC UserSearch Tests
✓ <Anonymous User> Search for Users (5471ms)
✓ <Anonymous User> See another user profile (2631ms)
PASS src/tests/ac.general.test.js (9.077s)
AC General Tests
✓ Homepage: Navbar shows all links (662ms)
✓ Boardgame Search: Searching for a Game (6029ms)
Benefits of having tests
Once the first tests were in place, I experienced the following effects:
- Redux Store works flawlessly: 100% coverage of all actions give me complete trust into application state changes.
- Ability to refactor: Acceptance tests provide the certainty that important user features are working. I could identify and exploit refactoring opportunities, like removing JSX conditional clutter or reusing components in different parts of the application.
- Boost development time of new features: With the tests in place, I could again focus on writing new features. The tests would validate that I did not break running code. I could skip the extensive manual testing I had before.
- (Nearly) test driven development: With some new features, I started with a test or test idea before developing a new feature. Sometimes I write the acceptance tests before any feature code. This step greatly increases the code quality because you mentally structure the code in your head before writing the first line.
Conclusion
This post explained the benefits of having tests in your app. With tests, you can be sure that new code does not break old code, you get a robust foundation to refactor the code, and it helps you to maintain a steady speed for developing new features.
Previous: Structuring React Projects