Testing,
Although there are many forms of testing, in this article I will focus on unit testing — in TypeScript.
I believe they are the easiest to setup, easy to write/maintain and when done properly end-to-end testing may become obsolete.
The examples in this article are for testing in JavaScript (TypeScript). However, the principles can be applied to you favorite tools.
Engineers conduct tests and experiments to evaluate the performance, safety, and reliability of products or systems. This may involve using specialized equipment, conducting simulations, or performing real-world trials.
The software engineer
In the physical world, you rely on engineers to be due diligent in their work. You would not like to have a bridge collapse because it was not tested properly, right?
So how is it that I come across many software engineers who not test their code?
It is our job that the code we write works as intended, and —preferably— keeps on working as intended after changes have been made.
This is, to me, part of the engineering mindset.
Some 🚩🚩🚩🚩
Every project (-team) is different, but there are some common pitfalls that I have seen over and over again which should raise some red flags for you.
🚩 No test runner has been setup
This one is kind of obvious, but when a project has no test runner setup it does not motivate (new) team members to write tests.
Why would you bother writing tests as this project clearly does not find them important?
This is true for a local setup which enables developers to validate their code locally.
It is equally important to have a test runner in your CI/CD pipeline.
This way you can ensure no old code is broken unintentionally. Even when you or a team member forgot about running the tests locally.
🚩 Tests are treated as second-class citizens
When tests are stored away in a separate folder it makes them harder to find and easier to forget about.
There is no constant nudge to write tests.
Try placing the tests as part of the source code, next to the code they are testing.
This way the project itself tells you “we care about tests” just by working on it.
🚩 Test code is not “clean”
Why do you go to great lengths to write clean, maintainable code, but throw all of this out of the window when writing tests?
Before we start this section I would like to define what I consider as source code. Source code is all the code which is part of the project. This can be broken down into test code and production code.
Test code should adhere to the same standards of quality as the production code it is testing.
Indeed, the ratio of time spent reading versus writing is well over 10 to 1. We are constantly reading old code as part of the effort to write new code. Therefore, making it easy to read makes it easier to write.
Any fool can write code that a computer can understand. Good programmers write code that humans can understand.
Some indicators I have found that you throw out clean code principles when writing tests:
- Ignoring test files in your linting
Sure, this makes your life easier in the short term. But over the lifespan of a project you will end up with a lot of unmaintainable tests (read technical debt) that you have created yourself unnecessarily.
- Making the test (file) a puzzle by itself
Tests can be considered documentation itself when written properly.
Why make it harder for the next person, which is most likely going to be you, to understand what is going on?
Act like you are writing normal code.
This makes it easier to skim over the tests to see what is being tested and what the expected outcome is.
The Arrange, Act, Assert pattern can help you structure your tests.
- Not using
constant
,enums
,types
orinterfaces
defined in your production code
In our production code we tend use those to try and make the code more self-explanatory.
Why do we forget about this when writing tests?
We can easily use them in our tests as well. This makes the tests more robust and less likely to break when the production code changes.
- The test has too many responsibilities
When a test is doing too many things at once it becomes harder to pinpoint what is going wrong when the tests start failing.
- independent —
it(“should not rely on the outcome of another test”)
- repeatable —
it(“should be runnable in any environment”)
- self-validating —
it(“should return a clear pass or fail result”)
Yes, the test file is significantly longer than the original example but it is easier to follow the intent of each test. And as a bonus, when a test fails you can pin-point the issue much faster.
🚩 Coverage !== Confidence
Hitting a certain percentage of coverage does not mean your software is properly tested.
The following code has 80% coverage, as this seems to be the general (mandatory) coverage goal.
But what are you testing here? Does the JavaScript constructor still work?
Have a look at the next example:
And boom, you have 100% coverage! But you are missing out on testing the edge cases. What if the array is empty? What if the array only has one element?
Setting a (mandatory) coverage percentage is a stupid bad idea. More often than not this will lead to poorer tests just for the sake of increasing the coverage.
I would not go as far as to not write any unit tests, just be thoughtful about what you are testing.
Using specialized equipment
Being new in testing, or development for that matter, can be overwhelming. And although you can generate your tests with AI, I think there are other tools out there which enable you to write better tests yourself.
The list below is not exhaustive, but it will give you a good starting point.
Build for speed
Time moves on, and so do our tools. Most of us do not use chai or mocha anymore to write and run our tests. It is quite common to see jest being the work-horse nowadays.
Tests should be fast. They should run quickly. When tests run slow, you won’t want to run them frequently. If you don’t run them frequently, you won’t find problems early enough to fix them easily. You won’t feel as free to clean up the code. Eventually, the code will begin to rot.
In order to go even faster you can use Vitest as a drop-in replacement for Jest. This is expecialy useful for when your test suite grows.
Validation
A common way your code can break is when the data you receive is not what you expect. You should treat all external data as untrusted.
Given the following example
You can write a lot of safeguards and unit tests to validate the data you receive is of MyType
otherwise it should throw an error.
Or, I would advice that, you can use a schema validator like zod or yup to parse and validate the data you receive.
This will save a lot of effort in validating the data itself and, in my opinion, does not require any unit tests besides the error handling anymore.
Passing objects
Writing clean tests can become tedious when you have to pass a whole object while your tests only need one of the properties.
What is seen happening a lot is the following:
And this works just fine, untill you start changing theMyType
. A solution I used before was to make use of the builder pattern to generate the objects for the tests.
This allows you to create a MyType
object with default values and override them when needed. This can be used in the following ways:
But sometimes you just need to force something into a space. @total-typescript/shoehorn is the tool you are looking for. It will allow you to pass partial data in tests while keeping TypeScript happy.
This removes all the boilerplate code and allows you to focus on the actual test while still remaining completely type-safe.
UI testing
As you are working with Typescript, there is a big change you will need to test some frontend code as well.
You might be familiar with tools such as storybook, cypress or playwright.
These tools are powerful in their own right, but I think they are bloatware when writing unit tests.
@testing-library are “simple and complete testing utilities that encourage good testing practices” which allow you to test the DOM.
Code coverage
Although I am against setting coverage targets, it is valuable to know what parts of your code are tested and which are not.
Codecov or Sonarqube can give you these insights. You can use it to make educated decisions on the state of your codebase and determine where to focus your testing efforts.
Conducting simulations
The beauty of testing is that you can mock (simulate) almost everything. This allows you to put your system under stress and force it into states that are hard(er) to reproduce in real life.
You can go even deeper by mocking any interface or object to give you full control over the simulation.
Performing real-world trials
Testing is great, but if you are conducting unit tests they are placed in a vacuum.
Software does not exist in a vacuum, and things will break when placed in the real world. You should make sure to have some form of strategy for this.
When you get a bug report, start by writing a unit test that exposes the bug.