When I started my career as a software developer over 30 years ago the waterfall model was the main game in town and my role was module design, code and unit test. The design was “Structured”, the coding language Z80 Assembler and C, but the unit tests? Well to be honest, unit testing was just a piece of time allocated on the project plan for you to get your code working. The correlation between code units and test units was often nebulous and it didn’t matter too much as long as the job got done.
A typical test method of repeated runs within a debugger changing register values at the start of each run soon became tedious and instead dedicated code was written to drive the tests. Although these test harnesses were tailored to a particular code component, the size of a component could vary considerably, from program to subroutine. The term unit testing was applied where the components were relatively small, albeit not necessarily the smallest within the target program – individual unit testing of every function or procedure did not happen.
At this stage the main function of the unit test harness was still to verify that some newly written code did what it was supposed to. But what was it supposed to do? How was the harness designed? How was it used? Often the harness would require user input to drive the unit tests, in which case a unit test plan was needed. This was itself derived from the system specification, which may or may not have contained sufficient detail for the task.
By now an infrastructure was starting to build up which needed to be kept safe – within the project deliverables even. It was now obvious to widen the scope of unit testing to later in the life cycle. After an enhancement or bug-fix, the unit test plan can be re-run in order to verify that no adverse side effects have been introduced by the change. Well-designed unit tests have as much value within such regression testing as within the initial development.
A potential difficulty remained that of determining exactly what each unit was supposed to do. In general terms the high-level specification determined the system behaviour, but in reality the low-level program design was much more useful. Design principles such as encapsulation made tests easier to formulate and a unit test plan could be more comprehensive. But the physical overhead of writing and running the resulting tests could be arduous, and let’s be honest, sometimes didn’t happen.
Help was at hand however in the form of unit testing frameworks such as JUnit. This made it easier to write the unit tests and allowed them to be run automatically. But although the activity sequence was still “code and unit test” that was about to change with the introduction of test driven development. This approach emphasises the writing of the test before the code, focusing attention on the requirements including all exception conditions. As development proceeds, code may be re-factored and unit tests re-run. Automation is key and can be extended to continuous integration where unit tests are run at regular intervals on a central build server.
There are potential complications with unit testing. For example units with external dependencies may be difficult to test in isolation from other components. It can be done however, by writing more test code, including techniques such as dependency injection and objects to simulate behaviour or mocks. In this way all our code components can be covered by a unit test regime, not just the simple or loosely coupled.
But like many wonderful ideas, such as database normalisation, object inheritance, and Theakston’s Old Peculier, unit testing can be taken to excess. A heavy unit test infrastructure may be difficult to maintain. A more subtle difficulty is whether the unit test can actually verify that the code is working as it should. This may need interactions with actual components and real data, which means integration testing. Test resources are finite and unit testing should be proportionate.
Looking back over 30 years, unit testing is now much more than an afterthought to the more interesting coding activity. By considering their unit test cases before coding, developers have more involvement in specification issues, and write better quality code. The automation of regression testing means changes to shared components such as libraries can be validated very quickly. But unit testing is still complementary to other test activities such as, integration, system, performance and volume testing. The challenge is to build more tools to assist these latter test phases, as already exist for unit testing.