Unit tests, by definition!, are about behavior inside a unit (typically a single class): to do them properly, you try your best to isolate the unit under test from its interactions with other units (e.g. via mocking, dependency injection, and so on).
OCP is about behavior across units ("software entities"): if entity A uses entity B, it can extend it but cannot alter it. (I think the emphasis of the wikipedia article exclusively on source code changes is misplaced: the issue applies to all alterations, whether obtained through source code changes or by other runtime means).
If A did alter B in the process of using it, then unrelated entity C which also uses B might be adversely affected later. Proper unit tests would typically NOT catch the breakage in this case, because it's not confined to a unit: it depends on a subtle, specific sequence of interactions among units, whereby A uses B and then C also tries to use B. Integration, regression or acceptance tests MIGHT catch it, but you can never rely on such tests providing perfect coverage of feasible code paths (it's hard enough even in unit tests to provide perfect coverage within one unit/entity!-).
I think that in some ways the sharpest illustration of this is in the controversial practice of monkey patching, permitted in dynamic languages and popular in some communities of practitioners of such languages (not all!-). Monkey patching (MP) is all about modifying an object's behavior at runtime without changing its source code, so it shows why I think you can't explain OCP exclusively in terms of source code changes.
MP exhibits well the example case I just gave. The unit tests for A and C can each pass with flying colors (even if they both use the real class B rather than mocking it) because each unit, per se, does work fine; even if you test BOTH (so that's already way beyond UNIT testing) but it so happens that you test C before A, everything seems fine. But, say, A monkeypatches B by setting a method, B.foo, to return 23 (as A needs) instead of 45 (as B documentedly supplies and C relies on). Now this breaks the OCP: B should be closed for modification, but A doesn't respect that condition and the language doesn't enforce it. Then, if A uses (and modifies) B, and then it's C's turn, C runs under a condition in which it had never been tested -- one where B.foo, undocumentedly and surprisingly, returns 23 (while it always returned 45 throughout all the testing...!-).
The only issue with using MP as the canonical example of OCP violation is that it might engender a false sense of security among users of languages that don't overtly allow MP; in fact, through configuration files and options, databases (where every SQL implementation allows ALTER TABLE
and the like;-), remoting, etc, etc, every sufficiently large and complex project must keep an eye out for OCP violations, even were it written in Eiffel or Haskell (and much more so if the allegedly "static" language actually allows programmers to poke whatever they want anywhere into memory as long as they have the proper cast incantations in place, as C and C++ do -- now THAT's the kind of thing you definitely want to catch in code reviews;-).
"Closed for modification" is a design goal -- it doesn't mean you can't modify an entity's source code to fix bugs, if such bugs are found (and then you'll need code reviews, more testing including regression tests for the bugs being fixed, etc, of course).
The one niche where I've seen "unmodifiable after release" applied widely are interfaces for component models such as Microsoft's good old COM -- no published COM interface is ever allowed to change (so you end up with IWhateverEx
, IWhatever2
, IWhateverEx2
, and the like, when fixes to the interface prove necessary -- never changes to the original IWhatever
!-).
Even then, the guaranteed immutability only applies to the interfaces -- the implementations behind those interfaces are always allowed to have bug fixes, performance optimization tweaks, and the like ("do it right the first time" just doesn't work in SW development: if you could release software only when 100% certain that it has 0 bugs and the maximum possible and necessary performance on every platform it will ever be used on, you'd never release anything, the competition would eat your lunch, and you'd go bankrupt;-). Again, such bug fixes and optimizations will need code reviews, tests, and so forth, as usual.
I imagine the debate in your team comes not from bug fixes (is anybody arguing for forbidding those?-), or even performance optimizations, but rather from the issue of where to place new features -- should we add new method foo
to existing class A
, or rather extend A
into B
and add foo
to B
only, so that A
stays "closed for modification"? Unit tests, per se, don't yet answer this question, because they might not exercise every existing use of A
(A
might be mocked out to isolate a different entity when that entity gets tested...), so you need to go one layer deeper and see what foo
exactly is, or may be, doing.
If foo
is just an accessor, and never modifies the instance of A
on which it's called, then adding it is clearly safe; if foo
can alter the instance's state, and subsequent behavior as observable from other, existing methods, then you do have a problem. If you respect the OCP and put foo
in a separate subclass, your change is very safe and routine; if you want the simplicity of putting foo
right into A
, then you do need extensive code reviews, light "pairwise component integration" tests probing all uses of A
, and so forth. This does not constrain your architectural decision, but it does clearly point at the different costs involved in either choice, so you can plan, estimate and prioritize appropriately.
Meyer's dicta and principles are not a Holy Book, but, with a properly critical attitude, they're very worthwhile to study and ponder in the light of your specific, concrete circumstances, so I commend you for so doing in this case!-)