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.
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.