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