DIを使用してテスト可能なコードを取得しようとする趣味のプロジェクトを開発しています。これまで、コードの可読性、使いやすさ、およびテスト容易性の両方が向上していることがわかりました。しかし、今では使い勝手が非常に悪く、DIには欠けている重要な部分があると思う状況にあります。
アプリケーションの初期セットアップフェーズで、プロジェクトファイルをロードする必要があります。 Applicationクラスでは、コードは非常にシンプルで読みやすくなっています。
_void Application::run(int argc, char** argv)
{
m_project.load(argc, argv);
// other stuff following, e.g., run main loop
}
_
そのコンストラクターは、void load(int argc, char** argv)
関数を含むICmdLineProject
インターフェースを受け取ります。具体的な実装では、コマンドライン引数からファイル名を読み取り、ファイルを解析して、保存された値を適用します。
_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);
}
_
FooProject::load()
の単体テストを実装すると、関数が実行する処理が多すぎて、確認する必要のある多くのエラーが発生することがわかりました。
window_width
_および_window_height
_の値がiniファイルにない場合は、スローする必要があります。さらに、create_window()
が正しい引数で呼び出されていることを確認する必要があります。この最後のテストは、gmockフレームワークを使用すると簡単でした。ただし、このテストが失敗した場合、テストはどの部分が失敗したかを示しません。コマンドライン引数の解析が失敗しましたか? iniファイルの解析に失敗しましたか?幅と高さを入れ替えましたか?
責任を分割してテストを簡略化するために、クラスを分割することにしました。
CmdLineToFileProject
:cmd行のargsからファイル名を読み取り、コンストラクターで渡されたIFileProject
に対してload(file_name)
を呼び出します。FileToStreamProject
:ファイルをストリームとして開き、IStreamProject
でload(stream)
を呼び出します。StreamToIniProject
:ストリームからIniConfig
を作成し、一部のIIniProject
でload(ini)
を呼び出します。IniToFooConfigProject
:iniからFooConfig
を作成し、一部のIFooProject
でload(config)
を呼び出します。今では、すべてのクラスが正確に1つのことを行います。各ステップで成功と失敗の両方のケースをテストするのは非常に簡単です。 FooProject
は非常にシンプルになりました:
_void FooProject::load(FooConfig const& f_config)
{
m_window_system.create_window(f_config.m_width, f_config.m_height);
}
_
残念ながら、アプリケーションの初期化は今でははるかに複雑になりました。以前は、次のように簡単でした。
_FooProject project;
Application app(project);
app.run(argc, argv);
_
ただし、今度はすべてのプロジェクトクラスをチェーンする必要があります。
_FooProject p0;
IniToFooConfigProject p1(p0);
StreamToIniProject p2(p1);
FileToStreamProject p3(p2);
CmdLineToFileProject p4(p3);
Application app(p4);
app.run(argc, argv);
_
これは本当に不便で、FooProject
とApplication
のユーザーが苦しんでいると思います。私は小さなメソッド(5行)を改善したかっただけで、4つの新しいクラスとそのインターフェイスを作成する必要がありました。このクラスチェーンをどのように回避できますか?これは、DI手法を適用する場合の一般的な問題です。これに対する解決策はありますか?アプリケーション全体の設定が間違っていましたか?
あなたのコードは混乱しています。
あなたは2つのことをすることで改善できるようです
これにより、次のようにコードを設定できます
c = new container();
c.RegisterType<IFileReader, FileReader>();
c.RegisterType<ICmdLineReader, CmdLineReader>();
c.RegisterType<IFooConfigParser, FooConfigParser>();
... etc
c.RegisterType<FooProject>();
project = c.Resolve<FooProject>()
project.load(argc, argv)
FooProjectのコンストラクターはすべての依存型を受け取りますが、それらの型はデータをコンストラクターとして受け取るのではなく、メソッドを使用して入力を変換します。これは、FooProjectがメソッドを呼び出し、データ自体を渡すことができることを意味します。タイプの注入可能性を維持しながら、アプリケーションレイヤーで行う必要はありません。
何かがメソッドのみを必要とする場合、「1つのことを行う」メタファーに従うだけのクラスにする必要はありません。 「1つのことを行う」とは、すべての関数をクラスに変換する必要があるという意味ではありません。何かに複数の関数、特に小さな静的関数が必要な場合は、関数として保持します。
モックを目的としてクラスに関数を実際に注入する必要がある場合は、コンストラクターパラメーターに_std::function
_(C++ 11)を使用して、追加のポリモーフィッククラスなしで関数オブジェクトを直接注入できます。
_read_file_name_from_cmd_line
_はスタンドアロンの静的メソッドのように見えますが、テスト目的で公開することはおそらく許容されます。必要なインターフェイスの一部ではないFooProject
にパブリックメソッドを使用したくない場合は、_read_file_name_from_cmd_line
_を別のクラス、おそらくコマンドライン解析用の一般的なヘルパークラスに移動できます。
std::ifstream file(file_name);
のようなコンストラクター呼び出しはテストを必要としません。これは標準ライブラリの一部であり、作成者がテストする必要があります。ただし、ここで「シーム」を作成すると(load
を直接使用するistream
メソッドのように)、「実際の」ファイルストリームの作成を「メモリ」で置き換えることができます。テスト用のストリームの作成」。
_parse_ini_config
_はIniConfig
のパブリックメンバーにすることができます。これにより、追加のヘルパークラスなしでテストを簡単に行うことができます。コンポーネントIniConfig
は通常、モックやDIなしでテストできます。
したがって、_FooProject::load
_のすべてのステップに単体テストがあるので、_FooProject::load
_自体に単体テストが必要か、(実際のファイルを使用した)統合テストで十分かは議論の余地があります。しかし、単体テストの場合でも、「外部I/O」だけをモックアウトすることをお勧めします。これは、ファイルアクセスと_m_window_system.create_window
_を意味します。中間ステップごとにモックを作成する必要はありません。
後者の単体テストが一部の入力で失敗し、根本的な原因がすぐにわからない場合は、テストを手動でデバッグonceし、失敗したステップまたはコンポーネントを特定します。次に、その特定のコンポーネントのユニットテストのリストに新しいユニットテストを追加して、その失敗を正確に検証する必要があります。通常、_FooProject::load
_の単体テストまたは統合テストで特定の障害のあるコンポーネントを示す必要はありません。これは、個々のコンポーネントのテストの責任です。