Почему разработчики не любят Юнит Тесты
Может они просто не умеют их «готовить»? IntroПо долгу службы, я участвую в разработке приложений для микроконтроллеров. Но так сложилось, что различного рода тестированием (как своего так и чужого кода) я занимался больше, чем, собственно, разработкой. Далеко ни с первой попытки, мне удалось освоить TDD. Теперь объемы тестового и «боевого» кода более или менее уровнялсь :)Надеюсь, что после прочтения данной статьи вопрос «А почему ни с первого раза?» будет снят.Факты В своей профессиональной деятельности, я часто слышу заявления примерно следующего характера: «Зачем мы будем тратить время на unit-тесты, мы и так не успеваем сделать проект в срок?» «Почему тесты диктуют нам как писать код?» «Давайте просто писать код, тестировщики найдут все дефекты. Потом исправим.» «Вот тут коллеги за-имплементили новую фичу, надо покрыть ее unit-тестами» Даже сторонники гибких методологий разработки не всегда понимают ценность данного вида тестирования. Собственно, статья Agile с точки зрения программиста послужила триггером к данной публикации.Как это обычно бывает Давайте представим себе, что в процессе разработки некой системы возникла потребность в реализации связанного списка. Для простоты я ограничусь только функциями push и pop (FIFO) и целым числом в качестве payload.Без дополнительных требований к этому списку можно ожидать, что опытный разработчик Максим сначала изучит примеры, которые есть в интернете, и возьмет один из них за основу.В результате мы имеем следующий вариант реализации:
файл 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
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; } Ну что же, реализация есть. Интегрировали код в систему, «по-клацали» все работает.Тут кто-то вспоминает, что связанные списки — дело очень ответственное, адресная арифметика там… утечки памяти… И надо бы написать unit-тесты, хотя бы на этот модуль — ну что бы спасть спокойно.И я почти на 100% уверен, что заниматься этим будет другой разработчик — Андрей. Андрей — начинающий разработчик и ему просто необходимо приобретать опыт. А так как разработка системы еще не окончена, то ребятам с опытом еще есть чем заниматься.Андрей: «А как тестировать то? «Максим: «Ну смотри в код, разберись как оно реализовано, и покрывай тестами все ветки кода, что бы ничего не упустить«Андрей: «Я хочу начать тестировать с функции list_pop (). Она выделяет память для нового элемента и добавляет его в список. Но там же static и я не могу добраться до списка из тестового кода.»
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 */ } Думаю дальнейшее расширение покрытия кода новыми тестами читателю очевидно. Результат достигнут — модуль протестирован unit-тестами, покрытие 100%. Можно спать спокойно.А что тут не так? Конечно, описанная выше история может иметь и другое развитие событий. Я всего лишь пытаюсь сказать, что unit-тесты бывают разными.В данном случае, тестам присущи следующие недостатки: Тесты тестируют код (как бы странно это не звучало) Тесты вынуждают разработчика делать «костыли» Тесты требуют титанических усилий по их поддержке даже в случае рефакторинга, не говоря уже о значительных изменениях «Проваленные» тесты совсем не означают, что какая-то функциональность не работает А если писать сначала тесты, а потом код. Это поможет? К сожалению нет. Или далеко не всегда.Я не являюсь ярым приверженцем основного принципа TDD, заставляющего сначала написать тест для несуществующего кода, а потом уже писать код, для того, что бы этот тест проходил. Иногда, я пишу небольшой участок кода прежде чем тесты к нему.Главное в другом. Очень важно, на мой взгляд, рассматривать каждый модуль, как независимую систему:
Пытаться формулировать требования к этой системе, которым она должна отвечать Именно соответствие этим требованиям пытаться проверить unit-тестами Стараться не вникать в особенности реализации данной системы и использовать только её внешний API для тестирования Кто-то, наверное, заметит «так это же BDD». И скорее всего будет прав. Но, не важно, что первично в Вашей разработке: тесты, или поведение, или же сам код, которого уже очень и очень много написано. Важно, как Вы пишите unit-тесты.
Например, первый тест, для, реализованного выше, списка может быть таковым:
/* * 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 ()); } Второй тест: /* * 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 модуля впринципе работоспособны. Так же мы убедились, что то, что мы сохраняем в списке, в последствии может быть извлечено.Вторым тестом, мы проверили, что элементы извлекаются из списка в том порядке, в котором они были туда помещены.И именно такая функциональность нас интересовала изначально при проектировании всего комплекса ПО, но уж никак не способ, которым она была реализована.Приимущества При таком подходе устраняются описанные выше недостатки тестов: Тесты тестируют код
Тесты тестируют поведение модуля ничего не зная о его реализации (black-box) Тесты вынуждают разработчика делать «костыли»
при тестировании через API необходимость в этом возникает крайне редко Тесты требуют титанических усилий по их поддержке даже в случае рефакторинга, не говоря уже о значительных изменениях
в нашем примере реализация может быть изменена полностью (массив вместо связного списка, двунаправленны список вместо однонаправленно и т.д.), что никак не должно отразится на его поведении «Проваленные» тесты совсем не означают, что какая-то функциональность не работает
поскольку рефакторинг кода (если он успешен) никак не влияет на результаты тестов, остается только одна причина «провалов» тестов — что-то действительно не работаетДополнительные плюшки Кроме указанных выше преимуществ unit-тесты обладают еще одним, на мой взгляд, очень важным достоинством — они улучшают качество кода.Хотим мы этого или нет, но тестируемый код (тот который можно физически протестировать) является более гибким, более переносимым, более масштабируемым. Может еще какм-то (боюсь перехвалить).К сожалению, реализованный выше список, до сих пор так и не был протестирован, на предмет утечек памяти. Но этот момент был далеко не последним в списке опасений, который заставил команду вообще вспомнить о юнит тестах на связанный список.
Для того, что бы проверить факт отсутствия утечек, мы должны контролировать выделение/освобождение памяти. А сделать mock-и на функции стандартной библиотеки не самая простая задача.
Выход есть — добавить слой абстракции между модулем и стандартной библиотекой с таким интерфейсом:
файл 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.с #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;
}
Уже реализованные тесты никак не изменятся, за исключением добавления mock-ов: файл test_my_list.с #include «unity.h»
#include «my_list.h»
#include «mock_my_list_mem.h»
#include
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 mеmory 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 mеmory 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 (). И если в дальнейшем механизм выделения памяти будет изменен (стаический массив элементов фиксированного размера, memory_pool-ы какой-нибудь RTOS) — наш код готов к этим изменениям, а сам список и тесты на его функциональность никак не будут затронуты.Conclusions Да, … выводов, всего два1. unit-тесты это хорошо, главное правильно их писать.2., а для того, что бы это было возможно, следует думать о тестировании при разработке кода.P.S. Все совпадения с реально существующими людьми случайны.В качестве основы для реализации спсика использован материал www.learn-c.orgВсе тесты написаны с использованием средств Unity/CMock/Ceedling