Может они просто не умеют их «готовить»?
Intro
По долгу службы, я участвую в разработке приложений для микроконтроллеров. Но так сложилось, что различного рода тестированием (как своего так и чужого кода) я занимался больше, чем, собственно, разработкой. Далеко ни с первой попытки, мне удалось освоить TDD. Теперь объемы тестового и «боевого» кода более или менее уровнялсь :)
Надеюсь, что после прочтения данной статьи вопрос «А почему ни с первого раза?» будет снят.
Факты
В своей профессиональной деятельности, я часто слышу заявления примерно следующего характера:
- «Зачем мы будем тратить время на unit-тесты, мы и так не успеваем сделать проект в срок?»
- «Почему тесты диктуют нам как писать код?»
- «Давайте просто писать код, тестировщики найдут все дефекты. Потом исправим.»
- «Вот тут коллеги за-имплементили новую фичу, надо покрыть ее unit-тестами»
Даже сторонники гибких методологий разработки не всегда понимают ценность данного вида тестирования. Собственно, статья Agile с точки зрения программиста послужила триггером к данной публикации.
Как это обычно бывает
Давайте представим себе, что в процессе разработки некой системы возникла потребность в реализации связанного списка. Для простоты я ограничусь только функциями push и pop (FIFO) и целым числом в качестве payload.
Без дополнительных требований к этому списку можно ожидать, что опытный разработчик Максим сначала изучит примеры, которые есть в интернете, и возьмет один из них за основу.
В результате мы имеем следующий вариант реализации:
#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
#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
Закономерно ожидать такую реализацию теста:
#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-и на функции стандартной библиотеки не самая простая задача.
Выход есть — добавить слой абстракции между модулем и стандартной библиотекой с таким интерфейсом:
#ifndef MY_LIST_MEM
#define MY_LIST_MEM
void * list_alloc_item( int size );
void list_free_item( void * item );
#endif
Тогда, реализация списка примет вид:
#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-ов:
#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() );
}
И наконец, новые тесты на упраление памятью:
#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
Автор: 0xFE