Introduction to Unit Test
Learn why we write unit tests and how they support writing clean and maintainable code.
Why unit tests are important
Unit tests may be the most fundamental kind of test, and they are deeply important to ensuring the stability of a system. Unit tests are crucial to a reliable and robust testing suite. This is partly because they are both the easiest to write and the easiest to understand in terms of what to test. Additionally, writing unit tests enforces certain best practices in our code.
Easy writing
As the name suggests, unit tests test a single unit of code—a single function, single conditional statement, or a single low-level display component. They allow us to focus on what one thing is supposed to be doing, ensuring that this specific thing behaves as expected. This includes not only the happy path but also edge cases and error handling. Unit tests ensure that a small block of code is doing its job very well.
Code is complex. Functions are used repeatedly throughout a codebase in many contexts and for many reasons. Testing large, elaborate sequences of code is extremely difficult to do well. The happy path may be easy, but the odds of missing an edge case or writing something that isn’t actually all that helpful are very high.
Unit tests allow us to mentally home in on one thing. After we do this well, we can reliably use that block of code elsewhere without having to retest it. If we break up our code, we can be confident in its solo capabilities and can then string it together in various complex ways. We can be sure that with a few additional tests, the larger function will work as expected.
This leads us to the next benefit. Unit testing helps to encourage best practices in our actual code base.
Encouraging best practices in code
Writing tests can be difficult, tedious, and time-consuming. When there is code to be written, it can be tough to make the time to write tests that our code requires. For these reasons, we are very lucky that certain best practices in code also serve to make writing tests easier, less tedious, and less time intensive.
DRY principle and abstraction
The DRY principle stands for “Don’t Repeat Yourself.” It is a reminder that abstracting common code into one unique, singular function, will serve us well in the long run.
This is a best practice for many reasons. It makes code more maintainable. By keeping a piece of logic in only one place, when that logic changes, we only have to change it in that very place. It also saves us time. When we abstract logic into a function, we can just call that function rather than taking the time to rewrite the logic when we need it.
When it comes to testing though, the DRY principle allows us to only test logic once. Rather than writing tests for each context in which a piece of code is used, we can write a suite of tests for the abstracted function, go all out with testing it, and then use it as much as we want without ever having to test it again.
Single responsibility principle
The single responsibility principle states that a function should do exactly one thing only. This means if we have a function called getUser,
it shouldn’t parse user data, manipulate it, or use it to determine the user’s status. It should get and return the user. Parsing the user might be encapsulated in a parseUser
function, and getting the user’s status might occur by calling getUserStatus
.
This may seem like overkill, but it makes testing much easier. Consider the example above. We won’t actually be fetching any data in our tests, so by breaking down this logic, we are able to encapsulate the request and its tests for the testing of getUser
. We can then use a stubbed expected response to ensure that parseUser
returns the expected User
object and that getUserStatus
correctly determines the user’s status given certain field values.
Separation of concerns and clean interfaces
The separation of concerns principle asserts that we should encapsulate different concepts, or concerns, into separate modules inside the code. As we said earlier, code is complex, and inevitably some concerns will rely on others.
If every time we changed one section of code, we had to update not only every test directly related to that section but also every test for a section that relies on the next section, it would become untenable very quickly.
Instead of dealing with this nightmare, we can limit test maintenance to those directly related to the changed code by separating concerns and creating clean interfaces. Clean interfaces are simple, manageable interfaces surfaced by a section of code that can be relied upon by other sections. We can change anything we want under the hood in a section—the functions, logic, and dependencies. As long as the interface stays the same, nothing that relies upon it needs to know anything.
This is important for overall code maintenance and scalability, but it’s also extremely helpful with testing. It allows us to focus on one section and one test suite when we are making changes to our codebase.