I've just been learning Unit Testing and I'm trying to understand how I could incorporate it with a project with existing code. Say I wanted to write tests for a specific class in that project, but that certain class requires an instance of another class for the the methods to be run and/or tested. How should I approach this?
This is a very complex question, in a legacy systems designed without testability in mind probably there are a lot of coupling and this coupling make testing in isolation (unit testing) more difficult.
I can give concrete answer if you ask a concrete question, with code and a concrete situation, but if you ask for general advice the best thing i can do is recommend to you the fantastic book form michael feathers working effectively with legacy code (http://www.amazon.com/Working-Effectively-Legacy-Michael-Feathers/dp/0131177052).
If the code is already in production and has no tests for it, then by definition you cannot do TDD with it. At that point you have at least these alternatives:
- Discard the code and start again using TDD. This can obviously be time consuming, but judging from previous experience it would often have been faster than the other alternatives.
- Just use TDD for any new code. This leaves the old code to become a rotten core, a black box which eventually nobody will dare change and which is likely to drag down the performance, stability and security of the rest of the code for the lifetime of the project.
- Add tests to the existing code incrementally, and use TDD for any new code. Surprisingly, in my experience this doesn't seem to help much. Unless the code is refactored (see next point) it will still, eventually, become a black box.
- Use TDD for any new code, replacing bits of the old code with TDD code whenever you have to touch any of it. If alternative 1 is not going to happen, this at least gives you some chance that the project will not be chained to a ball of mud for the rest of time. But likely you'll find that pulling out one part of untested legacy code is likely to unravel it completely, and the combined effort of all the refactoring might take as much or more time than re-implementing.
It's not always easy and sometimes you just have to be pragmatic and use integration tests or end-to-end tests to begin with until you have refactored code to be more testable.
Perhaps try to refactor the class to use an interface instead of a concrete class and inject in the implementation during construction. Then use a mocking framework or roll your own mock class and inject that in instead during testing.
Some mocking frameworks are able to mock concrete classes but it depends on the language.
I'll be quite radical but please read this fully before criticising.
In short: test-driven design is a kind of myth. It's impossible for implementation even for a new code unless all specifications are already fixed and won't change (that's classic "Waterfall" case but not a "Agile" one, opposed to the usually declared TDD application area). When it works out of one-shot projects, it works only because its principles are violated, not satisfied.
To understand this, one should imagine case the specification is changed and a new requirement is added, but it's already satisfied in existing code. Should the existing code be dropped and rewritten from scratch? Classic TDD description can't answer this at all - it doesn't deal with an existing code case.
The second question is what shall be tested. If a code piece shall satisfy tests but do nothing more, could be it only listed test cases in a big switch-case? If it doesn't, why? That's what is explicitly requested (at least in classical compact explanation).
The third question is how to check all marginal cases. If a routine multiplies numbers, shall it check
INT_MIN*INT_MIN? Marginal cases knowledge is mainly discovered after some usage experience, but not a priori. (Yep, the more programmer's expertise is, the more ability to find such cases before writing the code he has. But 90% of any expertise is what one's shan't do, but not what one shall.)
My answer is that TDD fulfils two roles:
- It shows an ideal, unreachable in practice (as infinity in mathematics) but ease to understand and so approximated as close as needed.
- It provides an excuse for managers to require testing which is not postponed until "good times" but just now, before reporting a task finish.
So it shall be treated accordingly - "with enthusiasm but without fanaticism".
My personal attitude to TDD is formed with a specific experience of building complex systems which shall work "24*7*365" and an acceptance style approached to military grade. Such systems require, at developers' side, integrated testing of component chains and regular testing on the fly, at the same components and with the same algotithms, but with another tags (so, only final decision checker understands it wasn't real data). For our systems, TDD is intentionally replaced with the following approach:
- Of course, the principle that code shall do what it is intended to, shall be more important that TDD (or analog) principle that a code is as small and simple as possible. This also means that code readability and debuggability is very important. (All this is not a Captain Obvious' comment but the written rule for those guys who tend to follow instructions in the most literal way, and who, regrettably, are present in every large team.)
- Each component starts with a reasonably small test set which allows an experienced programmer to say it's checked, according to the current conditions and resources.
- A part of time (of each programmer separately, or of the whole team) is dedicated to expanding tests for already existing components. This can be, according to goal specifics, 20% to 80%, but definitely not less. This work shan't stop until the whole project development is abandoned.
- To answer the question whether a test works (which is answered by TDD in a one-shot, unscalable manner), the test itself shall have techniques to validate it. For this task, we use so-called inversions. An inversion is a change in incoming data or environment which shall break test in a known way. For example, if a function shall convert A1 to B1 and A2 to B2, its test which validates B1 is response to A1 shall fail if input value becomes A2, and, moreover, one can check failure details (generated exception type, error count, etc.) Not all tests shall have inversions; but, the more complex it is, the more principal inversions are.
P.S. For an initial guide to add tests to an existing project, you could start with M. Feather's book. It isn't based on TDD but, instead, is very practical in suggesting how to reform an existing code in a evolutionary manner.