Automated testing is an essential part of modern web development, allowing developers to produce code and deploy frequently with confidence. As AI tools become more prevalent, and humans are less involved in the writing of code, it becomes even more important to have comprehensive testing in place.
This article will summarise my thoughts on how developers should approach testing, focused on frontend and full-stack applications.
Tests should be written from the perspective of the user, and know as little about implementation details as possible.
This means that tests should be written in a way that reflects how a user would interact with the application. For example:
closeModal function is called/action is dispatchedmodalMock.close function is calledThis approach ensures that tests are resilient to changes in the implementation details - the component can be refactored without having to update the tests.
If you have to mock implementation details, then you are coupling the tests to the implementation, and they will break when the implementation changes.
If you have to change tests when you change the implementation, then those tests provide no confidence that your change is safe, and are just a maintenance burden that slows down development.
Integration tests are tests that cover multiple components or parts of the application working together, rather than testing a single component or function in isolation.
It's better to write tests for the highest level component possible, than for each individual component and function.
This relates to the previous idea - This allows refactoring without having to update tests.
And if you have low-level pure function tests, whose logic is also covered by tests of a higher level component, then you should delete them - They are only a maintenance burden.
"Many people freak out at throwing away tests, but you should if they don't buy you anything. If the same thing is tested multiple ways, that's coupling, and coupling costs."
Kent Beck - https://martinfowler.com/articles/is-tdd-dead/
Tests must run quickly, otherwise developers will not run them as part of their workflow.
Running tests for a specific feature or small changes should take seconds on a local machine.
Running the full test suite should take a maximum of 10 minutes.
For this reason, it's important that the majoring of tests are integration or unit tests that do not run with a full browser/server environment.
Save end-to-end tests for critical user journeys.
It's common to categorise tests by type, such as unit tests, component tests, integration tests, smoke tests, end-to-end tests, etc.
Nobody seems to agree on the exact definition on what is a "unit" vs "component", and how high-level a test can be before it becomes an "integration test".
For example, a test for a React component which renders some other components
I think trying to agree on a name for this is a waste of time. I don't care what label you want to put on this test. It does not change how the test is written, just keep it as user-focused as possible.
Tests should be stable and not flake or fail intermittently. Flaky tests erode trust in the test suite and slow down development.
For end-to-end tests, your entire infrastructure (frontend, APIs and databases) should be able to be deployed in a docker stack, so that the tests always will be consistent.
Any dependencies that cannot be included in the stack (e.g. third party APIs) should be mocked.
Test-driven development is the process of writing tests before writing the code that implements the feature.
This is a great way to ensure that tests are written from the user's perspective, and that they do not depend on implementation details.
This also ensures high test coverage, as you are writing tests for every feature as you implement it.
Some people take the "red-green-refactor" approach too strictly, and write very small tests and then very minimal logic for that test, and don't take the time to consider the bigger picture and flexibility for future changes. It's best to write high-level tests that focus on user behaviour and outcomes, rather than individual functions and components.