2

I've been working on a personal project for some time, and I believe that I'm at that point where, if I don't embrace unit tests (and ideally TDD) now, it may be too late, so I want to make the right call. I'll try to keep things generic, so that this answer can be useful to as many people as possible.

Something relevant to note is that I'm using C++ and my system is built on top of a third party framework, as I know that most of this problems can be quite different in other environments.

I found a proposal for achieving good isolation that has a ton of obvious benefits, but I think that, if followed, the risk of falling into the class proliferation anti-pattern is quite high. I'll focus on the impact in my application structure, not so much on the testing side, since that doesn't concern me (I'm fine with the structure there).

The proposal I found says that, for every framework class used, you have to create:

  • An interface, so that it can be mocked when doing unit testing.
  • An adapter implementing that interface with the framework functionality.

The second point is to decouple logic from the place it is used. For doing so, for each class of my domain or system, I will now have:

  • Class A containing the logic, which will operate using the framework interfaces created. Hence, it will be framework agnostic and receive its members through constructor input parameters (dependency injection principle).
  • Class B, which will make use of class A and that knows everything about the framework (well, what it needs to know about it).

With this setup, B creates an instance of an adapter for each object that A needs as input parameter to it's constructor so that A can use it for carrying out its logic.

So, to summarize, I will now have to create two classes per framework class I use, and 2 classes per class of my domain.

I'm totally sold on the benefits this approach has, but it's also clear that the amount of parts on the system will sky rocket. From my point of view, this could end up in a class proliferation anti-pattern where I have to fight with much more modules, much more files and much more classes.

I know that I could loosen this approach a bit, such as not follow the decoupling process for classes that are only for internal use inside the module they belong to, or letting some dependencies from the framework leak into the classes that implement the logic (such as basic data structures). But I fear doing this will end up making my system too heterogeneous, with modules following totally different patterns/structures and making them harder to understand by the same person (me).

  • Am I missing something here that makes me think the risk of class proliferation is higher than it really is?
  • Is there any other approach I could follow to achieve similar results with less classes/modules?
  • Or do you think that the class proliferation is worth the ability to do proper, isolated unit testing and TDD? If so, is the possibility of loosening the isolation a bit acceptable? Do you know any other way of loosening the decoupling besides the ones I mentioned that can be useful?
3
  • 2
    What? That’s not an anti pattern, that is normal, well-implemented class structures.
    – Telastyn
    Dec 3, 2022 at 17:09
  • 1
    Something like this only becomes a problem if you introduce structures that are unnecessary in enough use cases that a simpler structure is more desirable than consistency in architecture. Dec 3, 2022 at 18:08
  • I think that part of the issue with how I see this comes from the fact that I wasn't applying unit testing from the beginning. I'm glad to know that this is the way to go, an I'll apply it. Thanks for your answers! :)
    – Pablo
    Dec 3, 2022 at 19:52

1 Answer 1

3

Sounds excessive for most use cases.

Yes, introducing “seams” to isolate components for tests can make sense.

Yes, creating an “anti-corruption layer” to wrap external APIs can have value.

But sometimes, these techniques also get in the way.

First, let's be clear about two kinds of dependencies, which I'll call frameworks and libraries.

  • Libraries provide functionality that isn't part of your core business logic. Depending on context, you might use libraries for graphics rendering, tax calculation, JSON serialization, interacting with the Stack Exchange API, and so on. You can draw clear boundaries between your code and the library's functionality.
  • In contrast, frameworks provide a platform upon which you build your code. You cannot meaningfully separate your code from the framework's types. For example, the C++ standard library and Boost are often interwoven with your code. In some contexts, app development frameworks like Qt or ORMs might also become inseparable from your core business logic.

Trying to mock away framework-like functionality will be utter pain, and is highly unproductive. Sure, you might want to test your code in isolation of std::string implementation details, but that's probably not useful.

In contrast, abstracting over library functionality can be quite helpful. But the main value here is to separate your core business logic from incidental complexity that's handled by libraries. In DDD-speak, it would be good if you can test each “bounded context” by itself.

This can often be achieved via dependency inversion, by declaring which services your core business logic needs (as an interface/abstract class), and then implementing that interface in terms of the external library. Note that to achieve decoupling, interfaces are generally defined by the consumers of the interface – these interfaces do not closely follow the APIs of existing types. The interfaces should describe operations in your problem domain, not in the library's solution domain.

It is quite likely that you'll draw these lines between components on a higher level than individual methods or objects. Too fine-grained mocking is probably not that useful, except perhaps for highly critical code sections that you really need to test thoroughly in isolation. Compare also architecture-level patterns like “ports-and-adapters”, or the “onion architecture”.

As an example for describing an interface for interacting with the Stack Exchange API, it would be unproductive to define an interface of HTTP/REST-level operations. Instead, the interface would likely contain operations such as get_posts_for_tag(tag) or upvote_post(p). Sometimes, the interface only consists of a single operation. Instead of defining a new interface that you'd have to implement, it might be sufficient to take a std::function argument.

As a general testing strategy, I found Stephen Vance's concept of a “value added test” to be useful: In practice, we don't have to unit-test the complete behaviour of the system under test (SuT). We only have to write tests that demonstrate the value directly added by that SuT. Specifically, this means that our tests don't have to characterize the behaviour of all dependencies used by the SuT. Yes, we can introduce the seams to make it possible to mock those dependencies, but if the dependencies are already tested separately then this isn't integral to writing good tests: whether we keep the dependencies or mock them during the test is mostly a matter of convenience.

5
  • Thank you for your answer. So, if I understood you correctly, you think mocking framework functionality will backfire at me along the way, since the amount of work won't pay off, so I shouldn't be mocking anything framework-related. Is this correct? If that's the case, what about the compilation time? If the framework is heavy enough, the compilation time that the dependencies to the framework add can make the TDD cycle long enough that TDD is not achievable. In a case like this, do you think it's better to just forget about TDD, and only do automated testing?
    – Pablo
    Dec 4, 2022 at 10:31
  • 1
    @Pablo I don't want to make too absolute recommendations, but typically isolating yourself from framework-like dependencies is not a productive use of your time. In contrast, it often makes sense to use dependency inversion to isolate a component from the services it consumes. This is quite specific to your individual circumstances, which you haven't explained. 1/3
    – amon
    Dec 4, 2022 at 11:47
  • 1
    Specific to C++, you're right that compile times can be a problem – and sometimes, decoupling techniques like pImpl are introduced just to keep compile times under control. But merely using large dependencies doesn't really affect the speed of the incremental compiles that you'd typically have with TDD. Which build system are you using? Is it properly configured to do incremental compiles? 2/3
    – amon
    Dec 4, 2022 at 11:48
  • 1
    What matters most is the dependency graph within your project: if you modify a header, then all compilation units that include the header (directly or indirectly) must be recompiled. It's therefore useful to keep headers as simple and stable as possible, for which dependency inversion or pImpl can sometimes help. You will have very fast TDD loops if you only make changes within the cpp file, longer loops if you use a lot of templates, have lots of inline code in headers, or if you include headers were forward declarations would have been sufficient.
    – amon
    Dec 4, 2022 at 11:48
  • So, just to summarize, I could use interfaces + dependency injection (the setup explained in OP, essentially) in the public classes of my modules, so I can properly mock them when unit testing the modules that use those classes. And I should forget about mocking the framework classes, which should be directly included by my unit tests. Finally, the logic of those public classes should use the framework API directly, instead of a generic adapter. Does this sound reasonable?
    – Pablo
    Dec 4, 2022 at 12:48

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.