2

I have a package that provides an object with quite a lot of features owned by it.

Let us say the object is an HTTPServer, and when the user initializes it by providing config and a request handler object, the server would be serving HTTP with added request observabilty, panic handling, and other in-house logics.

Now, if I want to test whether a handler's panic is properly handled, I would need to start the HTTP server, send a dummy request, and set up the observability; all which is unrelated to the panic handling logic. So, besides unit testing a feature of the object being cumbersome, there would also be a lot of reason why a test might fail (which if I understand correctly, is not a good property of a unit test),

Seeing other threads, a common idea would be to separate a particular subfeature to another (private) object, then unit test that object instead.

However, the sub-object would still be owned by the HTTPServer object, and its implementation initialized by HTTPServer during its initialization. To HTTPServer, the sub-object would just be an implementation detail hidden away from the user, and the unit-test of the sub-object does not guarantee that the HTTPServer is initialized and calls the sub-object properly. So, it cannot be mocked and we still need to retest the same functionality?

So, how do you decompose a big object for testing, and how do you use the decomposed objects in the big object's unit test if the whole functionality is still owned by the big object?

3 Answers3

2

Test behavior.

Panic handling can be dealt with in a separate object that exhibits this behavior.

Your HTTPServer need only take this object (doesn’t have to build it) and delegate its panic behavior to it.

I am writing a library to be used by other applications. Other people's codes would be the one calling the constructor

Bimo Adityarahman

So you need it even when not passed in. I call those known good default values. Doesn't mean it can't be a separate object. Constructing Panic inside HTTPServer just means you're stuck with HTTPServer knowing its concrete implementation exists. And that just means they have to be deployed together.

You can offer more than one constructor if you feel the need to pass in a different Panic object for testing (as a mock or some such). That makes this an overridable default value. But mocking that would be for testing HTTPServer not Panic. It works, but likely not needed.

No, if all you want to test here is Panic just have your test suite build itself a Panic to test.

You can resort to a static factory/service locator for this. But that's only needed if you really want to hide the concrete Panic from HTTPServer. Instead you will fail to hide the concrete static factory/service locator from HTTPServer.

candied_orange
  • 119,268
2

This is a classic tradeoff:

  • on one hand, you want a very lean public interface for a library, a facade, which hides a whole group of interacting objects or classes behind an small number of methods with simple signature which are exposed to a user

  • on the other hand, this interface is actually too lean for creating effective unit tests.

The standard solution I usually recommend is not to take the "don't test implementation details" mantra too literal. Stay pragmatic. Since you want your tests do something you don't want to allow for "normal users", make a distinction between

  • the public interface of your library (exposed to the user)

  • the internal interface (exposed to testing code)

  • "real" implementation details (the things you keep in private and do not test directly)

The distinction between "public", "private" and "internal" is something you find - for example - in C# with exactly these keywords, but other languages/eco systems have similar mechanisms.

For example, it makes sense to split up your large HTTP server object into smaller, testable units internally without changing its public interface. The panic handler could be an internal class of its own, and in case its construction is complex, there could also be a panic handler factory. By exposing these "helper" classes internally to the test code, but not in public, you can satisfy both requirements: the lean public interface, as well as unit testability.

As an alternative, you might consider if the public interface should be a little bit broader. When a component requires a complex setup before it can be tested it, maybe with a slow initialization phase or maybe with allocating network or other resources, one could add an "official" (=public) testing mode to it. The testing mode may only require a quick and simple setup, and use almost no additional resources. One might also add methods for observing certain behaviour during a test - methods which are technically public, but only sensible in a testing context. This also a common way to make a component more testable without exposing too many internals.

Doc Brown
  • 218,378
2

What you want to do is test the contract between the two objects.

One set of unit tests checks the behavior of the sub-object in isolation. This set of tests is essentially a representation of various usage scenarios (with respect to the contract) that can originate from your "big" object - the client of your sub-object.

Then you'd have another set of unit tests that focuses on the "big" object, or rather, its part of the contract. There, you replace the sub-object with a mock (or a spy) that, for each test case, fakes test-specific responses/behaviors of the sub-object, and you check if the "big" object makes the right calls, handles the responses properly, etc.

You'd have to redesign to inject the sub-object as a dependency (or add a new constructor). You can then provide a helper function/constructor/factory that returns a prebuilt "big" object for the user's convenience. That makes it easy to get started with the library, but the more advanced users still have the freedom to construct the object "manually", and inject dependencies of their choosing.

You can also throw in a couple of integration tests (in the sense that you don't use a mock, but the real dependency) to make sure everything works together.