Почему разработчики не любят Юнит Тесты

в 21:50, , рубрики: tdd, программирование как искусство, программирование микроконтроллеров

Может они просто не умеют их «готовить»?

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

Автор: 0xFE

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js