ユニットテスト/ TDDを使用したC ++プロジェクトの開発への移行

6か月前、私のプロジェクトでは、単体テストでコードカバレッジが約0%でした。 十分な単純なクラスがなく、それらの単体テストを作成するのは簡単でしたが、実際には重要なアルゴリズムが複雑なクラスにあったため、比較的役に立たなかった。 また、動作の観点から複雑なクラスは、他の複雑なクラスや構成クラスに結び付けられているため、ユニットテストが困難でした。 複雑なクラスのオブジェクトを作成すること、さらに単体テストでテストすることはさらに不可能でした。







少し前に、 Google Testing Blogで 「Writing Testable Code」を読みました。







この記事の重要な考え方は、単体テストに適したC ++コードは、通常のC ++コードのようにはまったく書かれていないということです。







それ以前は、ユニットテストを書く上でユニットテストフレームワークが最も重要であるという印象を受けました。 しかし、すべてが間違っていることが判明しました。 フレームワークの役割は二次的であり、最初に、単体テストに適したコードを記述する必要があります。 著者は、このために「テスト可能なコード」という用語を使用します。 または、より正確に思える「ユニットテスト可能なコード」。 その後、すべてが非常に簡単です。 テスト可能なコードの場合、すぐにUTを記述できます。その後、テスト駆動開発(TDD)が行われますが、後でコードで許可されます。 コードを使用してテストをすぐに作成し、コードの忘れられた場所や欠落している場所のカバレッジレポートを確認して、テストを補足します。







彼の記事では、著者はいくつかの原則を示しています。 私の観点から、最も重要なことに注意してコメントします。







#1 オブジェクトグラフの構築とアプリケーションロジックの混合:

絶対に重要な原則。 実際、複雑なクラスは通常、それ自体の中に他のオブジェクトのいくつかのクラスを作成します。 たとえば、コンストラクター内または構成処理中。







通常のアプローチは、クラスコードでnewを直接使用することです。 これは単体テストではまったく間違っています。 この方法でクラスを作成すると、テストできないスタッククラスオブジェクトの束になります。







UTの観点からの正しいアプローチは、クラスがオブジェクトを作成する必要がある場合、そのクラスはポインターまたはファクトリクラスインターフェイスへのリンクを受け取る必要があるということです。







例:







//     class input_handler_factory_i { virtual ~input_handler_factory_i() {} //       }; //     class input_handler_factory : input_handler_factory_i { //      }; class input_handler { public: input_handler(std::shared_ptr<input_handler_factory_i>) }; //   - class test_input_handler_factory : input_handler_factory_i { //       };
      
      





通常、ファクトリクラスメソッドからstd :: shared_ptrを返します。 したがって、単体テストで直接、保存することができます

テストオブジェクトを作成し、ステータスを確認します。 まだ。 ファクトリーでは、オブジェクトを作成するだけでなく、オブジェクトの初期化を遅らせることもできます。







#2 物事を求めて、物事を探しないでください(別名依存性注入/デメテルの法則):

クラスが相互作用するオブジェクトは、直接提供する必要があります。







たとえば、クラスコンストラクターがmeta :: class_repositoryオブジェクトへの参照を受け取るアプリケーションクラスオブジェクトのクラスへの参照を渡す代わりに、meta :: class_repositoryへのリンクをクラスコンストラクターに渡す価値があります。







このアプローチでは、単体テストでは、アプリケーションクラスオブジェクトを作成するのではなく、meta :: class_repositoryオブジェクトを作成するだけで十分です。







#6 静的メソッド:(または手続き世界での生活):

ここで著者は重要なアイデアを持っています:







テストの鍵は、継ぎ目(通常の実行フローを迂回できる場所)の存在です。

インターフェースは重要です。 インターフェイスなし-テストする方法はありません。







例。

フェールオーバーサービスの単体テストを作成する必要がありました。 作業中は、zookeeper :: config_serviceライブラリクラスに関連付けられています。 Zookeeper :: config_serviceには「継ぎ目」がありませんでした。 開発者zookeeper :: config_serviceにインターフェイスzookeeper :: config_service_iを追加し、zookeeper :: config_service_iから継承zookeeper :: config_serviceを追加するように依頼しました。







インターフェイスをそれほど簡単に追加できない場合は、プロキシオブジェクトとプロキシオブジェクトのインターフェイスを使用します。







#7 継承よりも合成を優先する

継承はクラスを結合し、特定のクラスの単体テストを困難にします。 ですから、継承はない方が良いです。







ただし、場合によっては継承が不可欠です。 例:







 class amqp_service : public AMQP::service_interface { public: uint32_t on_message(AMQP::session::ptr, const AMQP::basic_deliver&, const AMQP::content_header&, dtl::buffer&, AMQP::async_ack::ptr) override; };
      
      





これは、on_messageメソッドを子クラスで定義する必要があり、AMQP :: service_interfaceクラスからの継承なしでは実行できない例です。 この場合、amqp_service :: on_message()に複雑なアルゴリズムを追加しません。 amqp_service :: on_message()の呼び出しでは、すぐにinput_handlers :: add_message()を呼び出します。 したがって、AMQPメッセージを処理するロジックはinput_handlersに転送されます。input_handlersはユニットテストの観点から既に正しく記述されており、完全にテストできます。







#9 サービスオブジェクトと値オブジェクトの混合

重要なアイデア。 サービスオブジェクトクラスは複雑であり、それらのオブジェクトは工場で作成されます。







人件費の観点から、コードと単体テストの同時開発は開発時間を大幅に増加させます。 オプションの一部を次に示します。







1)主なシナリオをカバーしている場合。

2)さらに、カバレッジレポートでのみ表示され、通常はテスターが単純にチェックできないため、これに時間を無駄にしない「暗いコーナー」をカバーする場合。

3)負のシナリオ、まれなシナリオ、または複雑なシナリオの単体テストを追加する場合。 たとえば、処理中の空のキューと空でないキューを使用して、移動中の構成内のワーカー数の変更を確認するUT。

4)コードがテスト可能ではなかったが、リファクタリングが必要な機能と単体テストを追加して変更するタスク。







正確な見積もりは行いませんが、メインシナリオだけでなく、ポイント2と3を考慮して単体テストを実行すると、単体テストなしで単純に開発する場合と比較して、開発時間が100%増加するという印象を受けます。 コードがテスト可能でなく、単体テストを備えた機能が追加された場合、そのようなコードをテスト可能にリファクタリングすると、人件費が200%増加します。







労働に関する追加の警告。 開発者が慎重にUTを書くことに近づき、段落1、2、および3のすべてを実行し、チームリーダーが単体テストが基本的に段落1であると信じている場合、質問が可能です。

開発に時間がかかる理由。







そのようなテスト可能なコードのパフォーマンスについてはまだ疑問があります。 インターフェイスからの継承と仮想関数の使用がパフォーマンスに影響を与えるため、このようなコードを書く価値はないという意見を聞いたことがあります。 そして当然のことながら、私が持っていたタスクの1つは、AMQPメッセージの処理パフォーマンスを5倍にして1秒あたり25,000レコードにすることでした。 このタスクを完了した後、プログラムのLinuxでプロファイリングを行いました。 最上部にはpthread_mutex_lockとpthread_mutex_unlockがあり、これらはクラスアロケーターに由来します。 仮想関数呼び出しのオーバーヘッドには、目立った効果はありませんでした。 パフォーマンスに関する結論は、インターフェースの使用がパフォーマンスに影響を与えないというものでした。







結論として、単体テストを使用した開発に切り替えた後のプロジェクトのいくつかのファイルのテストカバレッジの見積もりを以下に示します。 ファイルfailover_service.cpp、input_handlers.cppおよびinput_handler.cppは、「Writing Testable Code」を使用して開発されており、ユニットテストでコードを高度にカバーしています。







 Test: data_provider_coverage Lines: 1410 10010 14.1 % Date: 2016-06-28 16:38:35 Functions: 371 1654 22.4 % Filename / Line Coverage / Functions coverage amqp_service.cpp 8.0 % 28 / 350 25.6 % 10 / 39 config_service.cpp 1.5 % 7 / 460 6.3 % 4 / 63 event_controller.cpp 0.3 % 1 / 380 3.6 % 2 / 55 failover_service.cpp 81.8 % 323 / 395 66.7 % 34 / 51 file_service.cpp 31.5 % 40 / 127 52.6 % 10 / 19 http_service.cpp 0.7 % 1 / 152 10.5 % 2 / 19 input_handler.cpp 73.0 % 292 / 400 95.7 % 22 / 23 input_handler_common.cpp 16.4 % 12 / 73 20.8 % 5 / 24 input_handler_worker.cpp 0.3 % 1 / 391 5.9 % 2 / 34 input_handlers.cpp 98.6 % 217 / 220 100.0 % 26 / 26 input_message.cpp 86.6 % 110 / 127 90.3 % 28 / 31 schedule_service.cpp 0.2 % 3 / 1473 1.6 % 2 / 125 telnet_service.cpp 0.4 % 1 / 280 7.7 % 2 / 26
      
      





追加







次のようにレポートを作成します。







 #    coverage  COV_DIR=./tmp.coverage mkdir -p $COV_DIR mkdir -p ./coverage.report find $COV_DIR -mindepth 1 -maxdepth 1 -exec rm -fr {} \; find . -name "*.gcda" -exec cp "{}" $COV_DIR/ \; find . -name "*.gcno" -exec cp "{}" $COV_DIR/ \; lcov --directory $COV_DIR --base-directory ./ --capture --output-file $COV_DIR/coverage.info lcov --remove $COV_DIR/coverage.info "/usr*" -o $COV_DIR/coverage.info lcov --remove $COV_DIR/coverage.info "*gtest*" -o $COV_DIR/coverage.info lcov --remove $COV_DIR/coverage.info "**unittest*" -o $COV_DIR/coverage.info genhtml -o coverage.report -t "my_project_coverage" --num-spaces 4 $COV_DIR/coverage.info gnome-open coverage.report/src/index.html
      
      





サプリメントNo. 2







毎分または毎時間など、特定のアクションを実行する必要があるアルゴリズムの単体テストの場合、これらのアルゴリズムの1つに時間を取得する機能を渡します。







 using time_function_t = std::function<time_t(time_t*)>; class service { public: service(time_function_t = &time); };
      
      





また、単体テストでは、時間を取得するための別の関数がすでに使用されています。 たとえば、次の時間関数を使用すると、++ minute_passedを実行して次の分に移動できます。







  std::atomic_int minute_passed{0}; time_t start_ts = time(nullptr); time_function = [&](time_t*) { auto current_ts = time(nullptr); auto diff_ts = current_ts - start_ts; return start_minute_ts + 60 * minute_passed + diff_ts; }; service test_srv(time_function);
      
      






All Articles