Testing the Untestable - A step-by-step demonstration of the humble object

Posted on 2024-07-31
extreme-programming test-driven-development software-engineering humble-object
I have been working on an internal firm CLI recently. This is written in dotnet 8, and I am using Spectre Console. Coincidence that I was writing my blog about My TDD Pilgrimage, and I was hitting rock bottom trying to test code I was adding in my Program.cs. What an irony. As I started asking around and also doing my research in parallel I came across the following 2 links: The links above provide comprehensive information about the humble object. Inspired by this, I rolled up my sleeves and started refactoring my code to make it more testable. Follow along as I first explain the pattern and then demonstrate, through a simple example, how you can refactor your own code to enhance testability. Together, we'll break down each step, making this concept easy to grasp and implement in your own projects.
What is the humble object pattern?
To state simply, the humble object test pattern is used to make otherwise untestable code testable. It does so by extracting the logic from this untestable code out, hence making untestable code lean and minimal i.e. humble. The code is extracted out in one or more testable classes themselves.
Along the lines of what Michael Feather points out in his article above, it is very tempting to add code in the untestable class (which is Program.cs in my case) but the right way is to create one or more smart testable classes. Now it is easier said than done. The obvious next questions are - How would this class be testable, How would I ensure I do not shift the problem from one spot to the other, and so on. The answer is simple: approach this with TDD, and leverage design patterns like dependency injection, and abstractions
To show a demonstration of the same, I have created a sample project whose project structure is inspired from the internal CLI tool I have been working on. The repository with the code is also check-in to GitHub. Feel free to have a look, fork and play around or contribute back.
Step 1 - Let’s dive into our Program.cs
Since I am working with a sample project here, for now my Program.cs looks much in control. This is because I only have a single command which only shows the version of the console app when I use the command app.exe --version.
                
    using Spectre.Console.Cli;

    var app = new CommandApp();
    app.Configure(config =>
    {
       config.SetApplicationVersion("1.0.0");
    });
    app.Run(args);
                
            
This can easily blow out of proportion as more subcommands get added, which would, in turn, require more services to be injected. Let's add the first subcommand joke. This command would leverage API exposed by https://icanhazdadjoke.com/ and can be used print a random joke (app.exe joke random), a specific joke (app.exe joke get [id]) and search a joke given a search term (app.exe joke search [term]).
            
    using SampleHumbleObject;
    using SampleHumbleObject.command.joke;
    using SampleHumbleObject.service;
    using Spectre.Console;
    using Spectre.Console.Cli;

    var typeRegistrar = new TypeRegistrar();
    typeRegistrar.RegisterInstance(typeof(IAnsiConsole), AnsiConsole.Console);
    var jokeService = new JokeService(new HttpClient());
    typeRegistrar.RegisterInstance(typeof(IJokeService), jokeService);

    var app = new CommandApp(typeRegistrar);
    app.Configure(config =>
    {
       config.AddBranch("joke", jokeConfig =>
       {
           jokeConfig.AddCommand<RandomCommand>("random");
           jokeConfig.AddCommand<GetCommand>("get");
           jokeConfig.AddCommand<SearchCommand>("search");
       });
       config.SetApplicationVersion("1.0.0");
    });
    app.Run(args);
            
        
We can easily see for a simple sample application with just two dependency injections and one subcommand this is in total a fair amount of untested code here. Let's see what happens when the next subcommand gets added!
            
    using SampleHumbleObject;
    using SampleHumbleObject.command.joke;
    using SampleHumbleObject.command.translate;
    using SampleHumbleObject.service;
    using Spectre.Console;
    using Spectre.Console.Cli;

    var typeRegistrar = new TypeRegistrar();
    typeRegistrar.RegisterInstance(typeof(IAnsiConsole), AnsiConsole.Console);
    var jokeService = new JokeService(new HttpClient());
    typeRegistrar.RegisterInstance(typeof(IJokeService), jokeService);
    var translateService = new TranslateService(new HttpClient());
    typeRegistrar.RegisterInstance(typeof(ITranslateService), translateService);

    var app = new CommandApp(typeRegistrar);
    app.Configure(config =>
    {
       config.AddBranch("joke", jokeConfig =>
       {
           jokeConfig.AddCommand<RandomCommand>("random");
           jokeConfig.AddCommand<GetCommand>("get");
           jokeConfig.AddCommand<SearchCommand>("search");
       });
       config.AddBranch("translate", translateConfig =>
       {
           translateConfig.AddCommand<YodaCommand>("yoda");
           translateConfig.AddCommand<GrootCommand>("groot");
       });
       config.SetApplicationVersion("1.0.0");
    });
    app.Run(args);
            
        
The number of lines of untestable code is not going down if you build a fully functional console app.
Step 2 - Design and Extract a Smart Testable Class
We would like to extract a CommandManager class that would be fully testable. This class would be responsible for configuring the commands, managing command's dependencies and running the command.
class design
Next, I add my unit tests and iteratively extract code for the CommandManager. See the test code for the methods below:
            
    [Fact]
    public void Constructor_ShouldInitialiseRegistrar()
    {
       // arrange and act
       var manager = new CommandManager();

       // assert
       VerifyInstancesAreRegistered(manager.Registrar);
    }

    [Fact]
    public void Configure_ShouldConfigureSubCommands()
    {
       // arrange
       var commandApp = new Mock<ICommandApp>();
       var mockConfig = CreateMockForConfiguration(commandApp);

       var manager = new CommandManager { App = commandApp.Object };

       // act
       manager.Configure();

       // assert
       VerifyAppIsConfigured(commandApp);
       VerifyJokeCommandConfigured(mockConfig);
       VerifyTranslateCommandConfigured(mockConfig);
    }

    [Fact]
    public void Run_ShouldRunCommandAppWithExpectedArgs()
    {
       // arrange
       var commandApp = new Mock<ICommandApp>();
       var manager = new CommandManager
       {
           App = commandApp.Object
       };

       // act
       manager.Run(["joke", "random"]);

       // assert
       VerifyAppRunWithPassedArguments(commandApp);
    }
            
        
You can access the code for CommandManager in the GitHub repo here.
Step 3 - Let’s make Program.cs humble
And finally, our humble Program.cs now looks like
            
    using SampleHumbleObject;

    var commandManager = new CommandManager();
    commandManager.Configure();
    commandManager.Run(args);
            
        
Bringing it all together
Often there are classes or methods of applications which are harder or impossible to test such as GUI layer (handling touch points such as button clicks, form submission, etc), database access, File I/O operations, Network communications, etc. These parts typically handle integration with an external aspect or are external touch points for the application itself. Over time either due to temptations or by oversight, code starts accumulating in these parts. This leads to high risk areas in code more susceptible to Production Bugs. The humble object pattern provides a simple way to create smart, testable classes and brings this hard-to-test (or impossible-to-test) code under test in an effective way.