Organizational context matters
Unfortunately, in some projects, the only responsible way to release changes is pairing them with heavy efforts on unit and end-to-end testing coverage. I’ve experienced it. Moving slower can be the only way to ship with confidence. But moving slower can never be a viable solution for the business. It’s just a short-term tactic to compensate underlying technology and company culture problems that need to be addressed. In healthy codebases, testing is helping to deliver quickly, with stability, and saving time not needing to come back to fix preventable issues.
Compared to working in a large organization, as a freelancer I have much more freedom to implement projects from the start in a way that will encourage quick and quality development. When picking up an existing project, I dedicate some time to perform refactors and add tooling to enable that, usually after a few iterations delivering business value so the customer sees progress.
Better static analysis means less tests needed. TypeScript is a game changer
I believe that’s a modest result. If you’re a frontend engineer, you know that a refactor on common components without TypeScript is time-consuming and error-prone, compared to counting on TypeScript to indicate type mismatches and flag other components need to be updated.
Besides, using typed parameters on functions reduces immensely the amount of things that can go wrong and eliminates the need to add input validation checks everywhere. That’s why TypeScript’s type
unknown should generally not be used, it’s almost the same as not using types!
Write some unit tests for complex logic
Writing unit tests for business logic is so fulfilling. I love doing it. Not only it encourages abstracting and making pure functions, with the benefit of reducing the chance for unintended side effects, but also it helps me catch edge cases. It’s part of my daily development practice.
Write some unit tests for reusable components
Even when working solo, I mentally divide myself into 2 developers:
The dev making common reusable pieces for the application.
The dev using them to build the user experience.
As the first one, it’s important to offer stable and tested common components. The second dev will be grateful things just work as they should! Some examples are custom date pickers and React hooks with logic reused across the application.
Prevent a bug or handle an edge case in a reused component, and save yourself from a generalized issue later, spending time patching it and testing all other views where it’s used. Also, applying TDD or at least writing tests simultaneously leads to more rounded implementations. Ideally, we’d do this always, but it’s often only worth it on these common components of the application.
Write some frontend integration tests to gain confidence
Tests exist to give developers confidence that the code does what is expected to, initially and after changes. The code does what is expected when the application works as expected for the end user. So we should test more as the end user.
With Testing Library, one can easily render high level views, like a dashboard or a settings panel, and interact with the UI simulating real user interactions. The view might have internally 1, 2 or 20 components. It doesn’t matter because our test is focused on testing the application as the user would: reading text, filling out forms, tapping buttons, etc.
Coming back to the Testing Trophy philosophy, these frontend integration tests give the highest confidence that the frontend application behaves as it should, and they are relatively fast to write and execute, provided we focus on the main use cases and errors. Every production application should have some of these tests!
Write end-to-end tests for complex systems
End-to-end tests not only test the frontend application, but also how all the system works as a whole, with backend, database, etc. These are the most time-consuming tests to write and execute, and harder to maintain. But, provided they aren’t flaky, they grant a level of confidence that no other tests can achieve, especially on large complex systems, like a web application interacting with various microservices.
Cypress is easy to set up and maintain for a small amount of tests. Things get tricky when the application integrates with 3rd party services, like with any other end-to-end testing tool, but there are always workarounds or tradeoffs available.
I recommend end-to-end tests with Cypress only in these situations:
There’s evidence that such tests would help prevent issues that the application frequently suffers from.
As a preparation step before shipping large backend refactors.
When multiple teams work over a shared codebase.
When working in projects solo, I rarely need end-to-end testing to ensure quality releases, and it will suffice with a few minutes of manual testing per release. But if I’m working with a larger team, I’m wrapping up the project and I want to leave it in good state for future developers, or the underlying system has many pieces, I make sure to cover the few key user flows for the business with Cypress.
How to prevent the need for a lot of tests: Keep The Product Simple
Measuring code complexity is the best tool to predict defect probability. Understanding complexity as the number of possible execution paths that code can follow, the rule is simple: the more possibilities, the higher defect probability.
What can lead to a complex codebase with high defect probability? A complex product. A product with many user flows, with lots of configuration variables and different results depending on them. But when is many too many? It depends on the business. Too many might be when there’s no measurable economic value in adding that complexity, or when the tradeoff for adding that complexity is the need for heavy end-to-end and manual testing.
So my advice is always Keep The Product Simple.
It leads to different guidelines that should be generally followed, like not offering multiple ways to do the same thing, limiting the amount of variables and user settings affecting a particular user’s experience, and removing functionality of the product that doesn’t serve the business goals.
What about tests when prototyping?
When an application is in prototype stage, without a user base and work-in-progress flows, spending time writing tests might not be worth it.
As a freelance developer, it’s important to know what stage the product is, so one can ship even more quickly than usual at the prototype phase.
Summary: when to write tests in the frontend
Unit tests for complex business logic, enclosed in pure functions.
Unit tests for common components of the application.
Frontend integration tests for user flows within a view.
End-to-end tests for core user flows when the system is complex or there’s a large team contributing to it.
And always advocating for keeping the product as simple as it can be for the business goals.