In April of 2021, Assurant Labs sponsored my attendance to the annual React Summit. This year, due to the pandemic, the entire React Summit was held virtually. During the summit, a number of speakers and their topics caught my attention, but I wanted to highlight one of them here titled “Test Kitchen: A Recipe for Good Tests” by Iris Schaffer. This blog post therefore should be credited to both Iris Schaffer as well as her influence Kent Beck.
Before we dive into what a well-written unit test looks like, we should understand why we should be writing unit tests in the first place. One primary purpose of a unit test is to ensure that you have not broken existing functionality by introducing a change. If you have been thorough with writing well-written unit tests for all potentially affected areas of your code, you can be confident if all the unit tests pass you have not broken any existing functionality. This is extremely valuable when deciding whether it is safe to merge your changes into your master branch or deploy them to production.
Another primary purpose of unit testing is with the use of Test-Driven Development (TDD) where unit tests have a special role. In Test-Driven Development, unit tests are written first and then changes to the code are made to meet the Acceptance Criteria. If the unit tests pass and are well-written, then you know your changes meet the Acceptance Criteria. If the unit tests fail, and you know the unit test is correct with no bugs, then you know that something in your code is amiss.
Unit testing also has some secondary purposes such as making code more readable as you can compare it against unit tests to better derive the code’s purpose and how it works. In a sense, well-written unit tests can serve as a form of documentation.
So, enough of why you should unit test, let’s explore what a well-written unit test should look like and how they should be used. We will explore several characteristics that well-written unit tests should have to best influence the quality and dependability of your code.
Tests Should Inspire Confidence
A well-written unit test should inspire confidence when merging your changes into your master branch or deploying them to production. Write your tests to cover all likely positive and negative cases – that is, do not just test using the input values you expect, but also test with some input values that you do not expect. If your code is supposed to return an integer representing a hex value for a color when you send it a color string like “red” or “blue”, what happens if you send it a non-accepted non-color string like “puppy”? Is it supposed to fail and throw an error? Is it supposed to respond with a default value?
If you are thorough with your unit tests, you can be more assured that any problems will show up before the code ever reaches production.
Tests Should Be Automated
Let’s face it, humans are forgetful and lazy, but computers are not. To combat this, it’s good to set up your environments to run all unit tests regularly. This not only allows you to better remember to run your unit tests, but also allows you to find any issues promptly before merges and deployments that if left unfound would cause issues in production. If faulty code does make it to production, then automating unit testing in your production environment can help find those issues quickly and therefore limit the damage. You should automate unit testing:
- While developing, in a watch mode that detects changes as you write your code
- On your branch before merging into your master branch
- On the master branch before deployment
- Regularly in your testing and production environments, especially when there are recent changes
Tests Should Be Fast
While we are pointing out humanity’s short-comings, humans are also impatient. Unit tests should be fast, otherwise you will be tempted not to run them. Furthermore, unit tests use up system resources while tests are running, which is not desired, especially in a production environment. Faster tests mean system resources are released more quickly back to the system for other uses.
How do you make tests fast? According to Iris Schaffer, four things can be done to make your unit tests run faster:
- Run tests in parallel
- Only re-run tests for files you have touched using watch mode
- Mock slow dependencies like network interactions
- Use the lowest level and fastest tools possible
Tests Should Be Written From the Consumer’s Perspective
When writing unit tests, you need to think about what needs to be tested from the consumer’s (or user’s) perspective and write your unit tests from this perspective and not from the component’s perspective. Think about the various use cases where the component will be used and write your tests to address those use cases.
For example, let’s say you have a simple React component that has a button and a label that reflects how many times that button has been clicked (e.g., “The button has been clicked 5 times!”). You want to be able to confirm that after the button’s onClick method has been called 5 times that the React component has been keeping count and is aware that the button has been clicked 5 times. You could write this test by keeping an internal count of the number of button clicks inside the React component and polling that internal count with your unit test. However, a far better way would be to test that the label’s text reflects the number of times the button was clicked, that is, it says “The button has been clicked 5 times!”. The later method of testing is what the consumer would be expecting, whereas the former is merely from the component’s perspective.
Tests Should Be Deterministic, Isolated, and Composable
A well-written unit test is deterministic, isolated, and composable. Let us explore each of these concepts individually.
Simply put, the term deterministic means that given the same input each time the unit test is run, the same output (or results) should occur each time. What is one area where non-determinism commonly happens? When your tests are not isolated.
An isolated unit test is a test that is not dependent on any other test. For example, if you have two unit tests where the second test is dependent on the side effects of the first test, then merely changing the first test will change the results of the second test. What if you reverse the test order? Even though you might not have changed anything except the order of the tests, the output will be different due to which test is called first, which brings us to a well-written test is composable.
A composable unit test can be combined in any way or order with other unit tests and still maintain the same results. In our previous example, the two tests should be made to be independent of each other. The solution? You should reset any global changes or side effects before each test. This assures that the inputs and starting point of each test is identical each time, and thus if the unit test is deterministic, it will give the same results. If you truly want the global changes that occur in the first test to be used in the second test, then the second unit test should make this happen on its own and not depend on the first test to do it. Yes, this will make the second unit test longer and would be duplication of code (which could be extracted if you really wanted to), but the benefit of the second test being able to be run without the first test is well worth the code duplication and length of the test.
Back to isolation. It’s also important to isolate unit tests by mocking required dependencies. This not only allows unit tests to be faster (the benefits of which were discussed before), but also allows them to be more deterministic. What if a dependency is a database and the data has changed between test runs? What if the database no longer exists at all? Your inputs would now be different, and the test should fail.
Tests Should Be Specific
A well-written unit test is specific. The name of the test should be specific so that you know precisely which test failed. Error messages should be specific to decipher which assert or expect statement failed should there be more than one in a unit test. Well-written unit tests should also only test one aspect of your component’s functionality per test case. For example, it may be convenient to have six somewhat related assert or expect statements in a given test, but it is far more coherent to split your test into multiple more specific tests.
Tests Should Be Readable
Well-written unit tests should be readable. Use descriptive names like “It does X when given Y”. Keep these names short for brevity, but also long enough to be descriptive – remember clarity is the objective. Make sure to use good coding techniques like proper indentation and extracting duplicated code to the bottom of the test class to make all the tests easy to read.
Tests Should Be Writable
Well-written unit tests should be writable, that is, worth the cost in time and effort spent to write the unit test relative to the cost of the code being tested. Some tests simply are not worth writing. It’s all a balance. The more important a feature is, the more time you can justify writing tests to test it. If something is very challenging to test, then ask yourself “How important is this? Is it worth it?” If the answer is “not very”, then consider not writing the test. If you feel it is important to test, but that it is too difficult to write a test in the feature’s level in the testing stack, consider moving up or down in the testing stack. Consider writing a test in a module that consumes the hard to test feature – it may be easier to test there and a well-written test there is better than no test at all.
Tests Should Be Predictive
Well-written unit tests, as mentioned before, inspire confidence. Likewise, unit tests should be predictive. That is, if the all the unit tests pass and are well-written, then you should be able to predict and trust that the code is ready and suitable for production. Unit tests should be accompanied by End-to-End testing, automated integration testing, and regression testing.
If you were to ask me which of these “should” statements is the most important, I would tell you that “unit tests should inspire confidence.” All the other “should” statements have a singular goal – to make unit tests useful and trustworthy thus inspiring confidence. Unit tests that do not inspire confidence are useless. What is the point of a unit test whose results you cannot trust?
In conclusion, unit testing is partly an art and partly a science. Hopefully, as you consider these guidelines and maybe discover more of your own, your unit tests can become more coherent and useful, to both your role as a software developer as well as to your organization.