開発者が単体テストを好まない理由

たぶん彼らは彼らを「調理」する方法を知らないのでしょうか?



イントロ



勤務中は、マイクロコントローラー用のアプリケーションの開発に携わっています。 しかし、実際には、開発よりもさまざまな種類のテスト(自分のコードと他の人のコードの両方)に携わっていました。 最初の試みからはほど遠く、TDDをマスターすることができました。 これで、テストと「戦闘」コードの量は多かれ少なかれ平準化されました:)

この記事を読んだ後、「なぜ初めてではないのか」という質問が削除されることを願っています。



事実



私のプロとしてのキャリアの中で、次のような声明をよく耳にします。



柔軟な開発方法論の支持者でさえ、この種のテストの価値を常に理解しているわけではありません。 実際、 プログラマの観点から見アジャイルの記事がこの出版物を引き起こしました。



いつものように



特定のシステムを開発する過程で、リンクリストを実装する必要性が生じたと想像してみましょう。 簡単にするために、プッシュアンドポップ(FIFO)関数とペイロードとしての整数だけに制限します。

このリストに追加の要件がなければ、経験豊富な開発者のマキシムが最初にインターネット上の例を検討し、それらの1つを基礎として使用することが期待されます。



その結果、次の実装オプションがあります。

ファイルmy_list.h
#ifndef MY_LIST_H #define MY_LIST_H #ifndef NULL /* just for this example */ #define NULL 0 #endif void list_push( int val ); int list_pop( void ); #endif
      
      







ファイルmy_list.c
 #include "my_list.h" #include <stdlib.h> typedef struct node { int val; struct node * next; } node_t; static node_t * list_head; void list_push( int val ) { node_t * current = list_head; if(list_head == NULL) { list_head = malloc(sizeof(node_t)); list_head->val = val; list_head->next = NULL; } else { while (current->next != NULL) { current = current->next; } current->next = malloc(sizeof(node_t)); current->next->val = val; current->next->next = NULL; } } int list_pop( void ) { int retval = -1; node_t * next_node = NULL; if (list_head == NULL) { return -1; } next_node = list_head->next; retval = list_head->val; free(list_head); list_head = next_node; return retval; }
      
      







さて、実装があります。 コードをシステムに統合し、すべてが機能することを「嘆いた」。

次に誰かが、リンクリストは非常に責任がある問題であり、アドレス演算が存在することを思い出します...メモリリーク...そして、少なくともこのモジュールのユニットテストを作成する必要があります。

そして、他の開発者がこれに従事することはほぼ100%確信しています-Andrei。 Andreyは初心者の開発者であり、経験を積む必要があります。 また、システムの開発はまだ完了していないため、経験のある人にはまだやるべきことがあります。



アンドリュー:「それをテストするには?」

マキシム:「さて、コードを調べて、その実装方法を理解し、コードのすべてのブランチをテストでカバーして、見逃さないようにします」

アンドレイ:「list_pop()関数でテストを開始したい。 新しいアイテムにメモリを割り当て、リストに追加します。 しかし、 静的なものがあり、テストコードからリストにアクセスできません。」

 static node_t * list_head; void list_push( int val ) { node_t * current = list_head; if(list_head == NULL) { list_head = malloc(sizeof(node_t)); list_head->val = val; list_head->next = NULL; } ...
      
      





マキシム:「ああ...まあ、特にテスト用に「松葉杖」を作りましょう。 本番ビルドでは機能しませんが、役立ちます。 外部でテストを行い、それだけです。」

 #ifdef UNIT_TEST node_t * list_head; #else static node_t * list_head; #endif
      
      







そのようなテスト実装を期待するのは自然です:

ファイルtest_my_list.c
 #include "unity.h" #include "my_list.h" void setUp(void) { } void tearDown(void) { } typedef struct node { int val; struct node * next; } node_t; extern node_t * list_head; void test_1( void ) { list_push( 1 ); TEST_ASSERT_NOT_NULL( list_head ); /* Check that memory is allocated */ TEST_ASSERT_EQUAL_INT( 1, list_head->val ); /* Check that value is set*/ TEST_ASSERT_NULL( list_head->next ); /* Check that the next pointer has appropriate value */ }
      
      







新しいテストによるコードカバレッジのさらなる拡大は読者には明らかだと思います。 結果は達成されました-モジュールは単体テストでテストされ、カバレッジは100%です。 平和に眠ることができます。



何が悪いの?



もちろん、上記のストーリーには別の展開があるかもしれません。 私はユニットテストが異なると言っているだけです。

この場合、テストには次の欠点があります。





そして、最初にテストを作成し、次にコードを作成する場合。 これは役立ちますか?



残念ながらいいえ。 または常にではありません。

私は、最初に存在しないコードのテストを記述し、次にこのテストに合格することを保証するコードを記述することを強制するTDDの基本原則の熱烈な支持者ではありません。 時々、テストする前に小さなコードを書いています。



主なものは異なります。 私の意見では、各モジュールを独立したシステムと見なすことが非常に重要です。





おそらく、誰かが「だからBDDだ」と気付くでしょう。 そして、おそらく彼は正しいでしょう。 ただし、テスト、動作、コード自体など、開発の主要な要素は重要ではありません。これらは既に非常に多く記述されています。 単体テストの作成方法は重要です。



たとえば、上記で実装されたリストの最初のテストは次のようになります。

 /* * Given the list is empty * When I push 1 to the list * Then the pop function shall return 1 */ void test_simple( void ) { list_push( 1 ); TEST_ASSERT_EQUAL_INT( 1, list_pop() ); }
      
      





2番目のテスト:

 /* * Given the list is empty * When I push 1 to the list * And I push 2 to the list * Then the first call of the pop function shall return 1 * And the second call of the pop function shall return 2 */ void test_order( void ) { list_push( 1 ); list_push( 2 ); TEST_ASSERT_EQUAL_INT( 1, list_pop() ); TEST_ASSERT_EQUAL_INT( 2, list_pop() ); }
      
      





最初のテストでは、モジュールAPIが原則的に機能することを確認しました。 また、リストに保存したものを後で取得できるようにしました。

2番目のテストでは、アイテムがリストに配置された順序でリストから取得されることを確認しました。

そして、ソフトウェアパッケージ全体を設計する際に最初に興味を持ったのはそのような機能だけでしたが、実装方法はそうではありませんでした。



メリット



このアプローチにより、上記のテストの欠点がなくなります。

テストテストコード
実装について何も知らずにテストモジュールの動作をテストします(ブラックボックス)

テストは、開発者に松葉杖を作ることを強制します
APIを介してテストする場合、この必要性は非常にまれです
テストでは、重要な変更は言うまでもなく、リファクタリングの場合でもテストをサポートするための巨額の努力が必要です。
この例では、実装を完全に変更することができます(リンクリストの代わりに配列、単方向の代わりに双方向リストなど)
「失敗」テストは、一部の機能が動作しないことを意味するものではありません
コードのリファクタリング(成功した場合)はテスト結果に影響しないため、テストが失敗する理由は1つだけです。



余分なパン



私の意見では、上記の利点に加えて、単体テストには別の非常に重要な利点があります-コードの品質が向上します。

好むと好まざるとにかかわらず、テストコード(物理的にテストできるコード)は、より柔軟で、移植性が高く、スケーラブルです。 どういうわけか(賞賛するのが怖いです)。



残念ながら、上記で実装されたリストはまだメモリリークについてテストされていません。 しかし、この瞬間は恐怖のリストの最後からはほど遠いものであり、チームはリンクされたリストの単体テストについて考えることさえしました。



リークがないことを確認するには、メモリの割り当て/解放を制御する必要があります。 また、標準ライブラリの機能を模擬するのは簡単なことではありません。



解決策があります-次のインターフェイスを使用して、モジュールと標準ライブラリの間に抽象化レイヤーを追加します。

ファイルmy_list_mem.h
 #ifndef MY_LIST_MEM #define MY_LIST_MEM void * list_alloc_item( int size ); void list_free_item( void * item ); #endif
      
      







次に、リストの実装は次の形式を取ります。

ファイルmy_list.c
 #include "my_list.h" #include "my_list_mem.h" typedef struct node { int val; struct node * next; } node_t; static node_t * list_head; void list_push( int val ) { node_t * current = list_head; if(list_head == NULL) { // list_head = malloc(sizeof(node_t)); list_head = (node_t*)list_alloc_item( sizeof(node_t) ); list_head->val = val; list_head->next = NULL; } else { while (current->next != NULL) { current = current->next; } // current->next = malloc(sizeof(node_t)); current->next = (node_t*)list_alloc_item( sizeof(node_t) ); current->next->val = val; current->next->next = NULL; } } int list_pop( void ) { int retval = -1; node_t * next_node = NULL; if (list_head == NULL) { return -1; } next_node = list_head->next; retval = list_head->val; // free(list_head); list_free_item( list_head ); list_head = next_node; return retval; }
      
      







既に実装されているテストは、モックを追加することを除いて、いかなる方法でも変更されません。

ファイルtest_my_list.c
 #include "unity.h" #include "my_list.h" #include "mock_my_list_mem.h" #include <stdlib.h> static void * list_alloc_item_mock( int size, int numCalls ) { return malloc( size ); } static void list_free_item_mock( void * item, int numCalls ) { free( item ); } void setUp(void) { list_alloc_item_StubWithCallback( list_alloc_item_mock ); list_free_item_StubWithCallback( list_free_item_mock ); } void tearDown(void) { } /* * Given the list is empty * When I push 1 to the list * Then the pop function shall reutrn 1 */ void test_nominal( void ) { list_push( 1 ); TEST_ASSERT_EQUAL_INT( 1, list_pop() ); } /* * Given the list is empty * When I push 1 to the list * And I push 2 to the list * Then the first call of the pop function shall return 1 * And the second call of the pop function shall return 2 */ void test_order( void ) { list_push( 1 ); list_push( 2 ); TEST_ASSERT_EQUAL_INT( 1, list_pop() ); TEST_ASSERT_EQUAL_INT( 2, list_pop() ); }
      
      







最後に、新しいメモリ管理テスト:

ファイルtest_my_list_mem_leak.c
 #include "unity.h" #include "my_list.h" #include "mock_my_list_mem.h" #include <stdlib.h> static int mallocCounter; static int freeCounter; static void * list_alloc_item_mock( int size, int numCalls ) { mallocCounter++; return malloc( size ); } static void list_free_item_mock( void * item, int numCalls ) { freeCounter++; free( item ); } void setUp(void) { list_alloc_item_StubWithCallback( list_alloc_item_mock ); list_free_item_StubWithCallback( list_free_item_mock ); mallocCounter = 0; freeCounter = 0; } void tearDown(void) { } /* * Given the list is empty * When I push an item to the list * Then one part of mmory shall be allocated * And no part of memory shall be released */ void test_push( void ) { list_push( 1 ); TEST_ASSERT_EQUAL_INT( 1, mallocCounter ); TEST_ASSERT_EQUAL_INT( 0, freeCounter ); } /* * Given the list is empty * When get the item from the list pushed before * Then one part of mmory shall be released * And no part of memory shall be allocated */ void test_pop( void ) { list_pop(); TEST_ASSERT_EQUAL_INT( 0, mallocCounter ); TEST_ASSERT_EQUAL_INT( 1, freeCounter ); }
      
      







その結果、一方ではメモリ操作の正確性を確認し、もう一方ではmalloc()およびfree()関数のラッパーを含む追加のレイヤーを実装しました。 そして、将来メモリ割り当てメカニズムが変更された場合(固定サイズの要素の静的配列、一部のRTOS memory_pools)-コードはこれらの変更の準備ができており、リスト自体とその機能のテストは一切影響を受けません。



結論



はい、...結論、2つだけ

1.単体テストは優れています。主なことは、それらを正しく記述することです。

2.そして、これを可能にするために、コードを開発する際にテストを検討する必要があります。



PS



実在の人物との偶然の一致はすべてランダムです。

材料www.learn-c.orgはATPの実装の基礎として使用されました。

すべてのテストは、 Unity / CMock / Ceedlingツールを使用して書かれています。



All Articles