I am developing a hobby project where I try to use DI to get testable code. Until now, I found that it improved both the readability, usability, and testability of the code. However, now I have a situtation where I find that the usability suffers a lot and I think there is some key part of DI that I am missing.
In the initial setup phase of my application, I need to load a project file. In the Application class, the code is quite simple and readable:
void Application::run(int argc, char** argv)
{
m_project.load(argc, argv);
// other stuff following, e.g., run main loop
}
Its constructor receives a ICmdLineProject
interface with a void load(int argc, char** argv)
function. The concrete implementation now reads a file name from the command line arguments, parses the file, and then applies the stored values:
void FooProject::load(int argc, char** argv)
{
std::string file_name = read_file_name_from_cmd_line(argc, argv);
std::ifstream file(file_name);
IniConfig ini(file);
FooConfig config = parse_ini_config(ini);
m_window_system.create_window(config.m_width, config.m_height);
}
When implementing the unit tests for FooProject::load()
, I found that the function does too many things and has many error cases that need to be checked:
- It should throw if the cmd line args can not be parsed.
- It should throw if the file does not exist.
- It should throw if the file can not be parsed as ini file.
- It should throw if the
window_width
andwindow_height
values are missing in the ini file.
Additionally, I need to check that create_window()
is called with the correct arguments. This last test was easy with the gmock framework. However, if this test fails, the test does not show what part went wrong: Did parsing the cmd line args go wrong? Did parsing the ini file go wrong? Did I swap width and height?
In order to split the responsibilities and simplify testing, I decided to split the class:
CmdLineToFileProject
: Reads a file name from the cmd line args and callsload(file_name)
on someIFileProject
that was passed in the constructor.FileToStreamProject
: Opens the file as stream and callsload(stream)
on someIStreamProject
.StreamToIniProject
: Creates aIniConfig
from the stream and callsload(ini)
on someIIniProject
.IniToFooConfigProject
: Creates aFooConfig
from the ini and callsload(config)
on someIFooProject
.
Now every class does exactly one thing. It is very easy to test both success and failure cases for each step. The FooProject
is now very simple:
void FooProject::load(FooConfig const& f_config)
{
m_window_system.create_window(f_config.m_width, f_config.m_height);
}
Unfortunately, the application initialization now became a lot more complicated. Previously, it was as simple as this:
FooProject project;
Application app(project);
app.run(argc, argv);
Now however, I need to chain all the project classes together:
FooProject p0;
IniToFooConfigProject p1(p0);
StreamToIniProject p2(p1);
FileToStreamProject p3(p2);
CmdLineToFileProject p4(p3);
Application app(p4);
app.run(argc, argv);
I think this is really inconvenient and the users of FooProject
and Application
are suffering. I just wanted to improve a small method (5 lines) and needed to create 4 new classes and their interfaces. How can you avoid this class chain? This must be a common problem when applying DI techniques. Are there any solutions to this? Did I get the whole application setup wrong?
FooProject::load
basically sets up the entire program. You want to test the "set up program" function, is this correct? It's almost like trying to unit-testmain
.