Unit Tests, Integration Tests and Test Driven Design
Many developers find themselves in a situation on a project where they ask “Why should I bother with unit tests, there’s no time!” This is a very common thing, particularly in the production world where all of your managers and their managers will be asking you that exact question and demanding the product get out faster and with less effort, or demanding that all efforts produce visible improvements. Clearly, automated tests do not give you any kind of visible development effort, even with reporting tools. So, we have to answer them. Why do we need tests and what do they do for us?
Testing allows us to verify that the product we have to offer works in a very specific manner. We know and can prove — with a simple script — that when given this input, it will guarantee you this output. It is a verification of the specifications we were given when we were told to develop the product itself. If a mechanical engineer is told to design and create a part, it will come with specs, tolerances, and other things that all have to not only be verified but that have to be shown as verified. Unit tests are how to do that with software. This keeps us honest by showing exactly what our product will do, and keeps the clients honest by not giving them false expectations. They will know exactly what the product will do.
There is a secondary reason to have them: You may not always be the person who comes in next to change, refactor, or otherwise upgrade this code base. 2 years from now when a new coder comes on the project and is asked to add this new feature and refactor existing code if necessary, the unit tests will provide them with a guide to make certain that anything they change will not break the existing functionality. It does this by clearly outlining the expected behavior of the project and flagging things with failures if that behavior is changed. Maybe they need it to change, and as such have to update the tests to reflect that, but it will still let them know that it changed, and it will let them know what all is going to be affected by that change.
Test Driven Development
This leads us to Test Driven Development (TDD). TDD is the idea that you will take your requirements, and then develop a plan and write unit tests first. You write the tests that will satisfy those requirements, then write the code that will satisfy those tests. TDD is a wonderful way to deal with a distributed coding team when you have many developers working all on different schedules and times on different parts of the project. Having the test defined first mean you don’t have to wait for part 1 before starting on part 2, because you know exactly what part 1 is supposed to do.
TDD is also an ideal, almost naïve idea that your requirements are fixed and immutable. As many of you already know, this is not the case. The client will revise, the client will come to you halfway through and ask you to change this, that, or the other. In some cases, the client will not even initially have requirements, asking you to start work on the fly tightly tied to their development team and requirements will be generated as the work is being done. In such a situation, TDD actually generates ongoing technical debt and additional overhead that everyone on the project will look at with despair. TDD is a wonderful way to work, but it requires you to commit to that additional debt, especially if the requirements are going to be fluid. If you cannot do that, it may be better to avoid it. But always remember to build in time to write the tests later, because you do still need them at the end of the day.
There is also a need to discuss the difference between a unit test and an integration test. The first clear difference being that an integration test is NOT a unit test… but it will look a lot like one. A unit test is intended to test and define the logic of a single function. Any call that references code outside the function should be imitated using one of the many mocking tools available so that you can guarantee the behavior of that test. In contrast, an integration test is intended to test and define the logic of an entire feature, often defined as an API endpoint test. These are usually only written for a top-level feature such as a single CRUD operation.
There are 2 primary manners of implementing integration tests. The first is to use a running system. You let the code itself run and provide access to the running API. Putting real commands into the live system and then verifying the returns and effects. This usually requires, particularly for web applications, a testing environment, and a database so that the tests can be run. The second method is to once again use a mocking library of some sort, and then to carefully mock out any external call, that being defined as any call that leaves your code base with the expected returns but also to only mock out external calls. This allows the test to run the entire code feature from top to bottom, then verifies the return. The second method will look almost exactly like a very complicated unit test, but it is different because it tests ALL the code in a given feature and not simply a single method.
It is possible to use integration tests of the second type in place of unit tests. This is simply a second approach to it. By writing the integration tests you do effectively unit test all the code as well, but it can make it harder to detect small changes in a single sub-function, which would cause the entire top-level test to fail but not always tell you why. It is not the recommended approach.
Mocking and the Testing Mindset
There are a host of mocking libraries available out there, but they all require the same basic mindset. You have to define exactly and explicitly what the external function call looks like, and what and how it will return. This might sound silly, “I’m telling my code exactly what to do, how is this a test?” The reality is, you are not telling your code what to do, you are instead telling your code what it will get back when it makes this call. The real test is verifying what your code is going to DO with that return. This allows you to define things like:
- When I make this call and it throws an error, what does my code do?
- When I make this call and it returns an invalid value, what does my code do?
- When I make this call and it returns an edge case, what does my code do?
- When I make this call and it returns exactly what it’s supposed to, what does my code do?
These are all important questions, the very questions that your unit test needs to answer no matter which mocking library you are using. For the more complicated functions, you might need to break it down even farther or have multiple steps. Every If statement in your function needs its own test, after all, to make certain that every path is covered. This requires knowing your code, in great detail. You must know exactly how your code is going to react, to write these tests correctly and have them all pass. You might, while writing the tests, discover that your code does not react quite like you thought it did. This is a good thing because it makes you go back and ask, “Is this really what it’s intended to do?” and “Why does it not work as I meant it to?” which is what the tests are there to verify in the first place.
The truth is, you do not actually need any tests in order to write code or even to release a product. But you should have them, because they let you define your project’s behavior, prove that it works as intended, and defend against future unintended changes.