31 дек. 2011 г.

CUnit - фреймворк для юнит-тестирования программ на C

Наконец-то я получил все зачеты и автоматы и могу не тратить некоторую часть своего времени на официальную учебную деятельность, а весь день заниматься тем, что мне интересно.

Когда я писал на Clojure, мне очень нравилась встроенная в него в система для проведения юнит-тестирования. До этого я не пользовался юнит-тестами и идея писать некоторые функции, которые проверяют работоспособность моего кода на некотором наборе входных значений, а также, по совместительству, являются готовыми примерами использования, мне весьма приглянулась.
Юнит-тестированием для кода, написанного на Си, я еще ни разу не занимался и не знал что там принято использовать. Задав вопрос в своем G+ я получил совет попробовать CUnit - http://cunit.sourceforge.net. О моем небольшом опыте использования этого фреймворка для юнит-тестирования и пойдет речь в моем сегодняшнем посте...

Для начала нужно установить CUnit. Пакета для Slackware я не нашел - пришлось собирать все из сорцов (скачать исходники можно отсюда: http://sourceforge.net/projects/cunit/). Обычная сборка выполняется как обычно: ./configure && make && make install.
Чтобы собрать готовый пакет для Slackware следует выполнить несколько иную последовательность действий:
  1. ./configure --prefix /tmp/cunit
  2. make
  3. make install
  4. cd /tmp/cunit
  5. makepkg ../cunit-2.1.2.tgz
    На все вопросы отвечаем 'yes'.
  6. sudo installpkg ../cunit-2.1.2.tgz
Теперь немного теории. Все юнит-тесты в CUnit'е объединяются в наборы (suite), которые в свою очередь все объединяются в один большой реестр (registry). Таким образом, можно объединять вместе юнит-тесты, которые совпадают например по проверяемой функциональности - тесты, проверяющие функции чтения и записи в канал, будут в одном наборе, а проверяющие функции работы с псевдотерминалом - в другом наборе. И все эти наборы будут объединены в один большой реестр.
Выглядит все это примерно так (схема взята из официальной документации):

                      Test Registry
                            |
             ------------------------------
             |                            |
          Suite '1'      . . . .       Suite 'N'
             |                            |
       ---------------             ---------------
       |             |             |             |
    Test '11' ... Test '1M'     Test 'N1' ... Test 'NM'

Каждый юнит-тест представляет собой процедуру вида void unittest_func1(void) внутри которой происходит проверка некоторой, одной функции программы. Проверка может осуществляться при помощи разнообразных, "контролирующих" операторов, которые описаны здесь: http://cunit.sourceforge.net/doc/writing_tests.html#assertions.

Рассмотрим все вышесказанное на небольшом примере. Допустим, у нас есть функции readn() и writen(), которые гарантированно (по возможности) читают N байт из дескриптора. Напишем пару юнит-тестов, в которых проверяется, действительно ли эти функции записали/прочитали столько байт, сколько было нужно.
В первом тесте мы будем тестировать функцию writen(), которая будет писать некоторые слова в канал, а во втором тесте, соответственно, мы будем тестировать функцию readn(), которая будет читать из канала и проверять - совпадают ли прочитанные слова с тем, что мы записывали в первом тесте.

Метод тестирования функции writen() прост - проверяем, открыт ли канал и пишем в него различные символы, проверяя при этом, действительно ли мы записали столько символов, сколько хотели. При проведении тестирования CU_ASSERT вернет ошибку, если условие, записанное внутри него, будет ложным.

Тест для функции readn() выглядит похоже, за исключением того, что мы проверяем прочитанное из канала при помощи CU_ASSERT_NSTRING_EQUAL.

Как видно из вышеприведенных примеров, мы читаем и пишем в канал для проверки наших функций. Но откуда этот канал взялся, ведь внутри юнит-тестов нет вызовов pipe() или подобных функций?
Фреймворк cunit позволяет для каждого из наборов задавать функции выполняющиеся перед запуском тестов из набора и после окончания всех этих тестов. В этих функциях можно открывать файлы с тестовым набором данных, создавать каналы и так далее. И именно в этих функциях в нашем примере создается канал и уничтожается по окончании тестирования.

Теперь, объединим все эти разрозненные функции вместе. Нам нужно создать реестр тестов, включить в него наш набор тестов для функций readn/writen, задать для этого набора вышеприведенные функции инициализации и завершения работы, добавить в набор нашу пару юнит-тестов и наконец - запустить все это на выполнение. Стоит отметить, что юнит-тесты выполняются в порядке добавления в набор - то есть сначала выполнится test_writen(), а затем test_readn(), как нам и необходимо.
CUnit может выводить результаты в XML-файл, на консоль или же использовать curses или графический интерфейс. Я буду использовать самый базовый способ - вывод на консоль.

Теперь, для удовлетворительной работы теста, осталось подключить к нему заголовочный файл CUnit/Basic.h и скомпилировать его, не забыв прилинковать библиотеку libcunit.so. Я делаю это Makefile'ом следующего содержания:

После запуска файла io_test мы увидим ход выполнения тестов на консоли:

Если во время тестирования будут обнаружены ошибки, то это будет отмечено в столбце "Failed". Как видно, в данном тесте у нас не обнаружена ни одна ошибка - значит, можно спокойно привносить новые.

За дополнительной информацией по этому фреймворку для юнит-тестирования крайне рекомендую обратиться к официальной документации, она крайне простая, понятная и весьма короткая: http://cunit.sourceforge.net/doc/index.html.
Также, рекомендую посмотреть на пример использования CUnit: http://cunit.sourceforge.net/example.html

20 комментариев:

  1. читаю Чистый код поэтому превращаюсь в source nazi

    я понимаю,что давно не писал на С, но строка 

    >if ((pipefds[READ_END] != -1) &&
    >(pipefds[WRITE_END] != -1)) {
     
    меня очень смущает, возможно потому что нужно тестировать 1 функцию а не 2, либо вставь фейл в случаи не открытия соеденения   

    ОтветитьУдалить
  2. +
    если мне не изменяет память, люде реализовали tcp тунель через icq, толи на хабре толи на лоре была инфа

    ОтветитьУдалить
  3. Эээ, так тут как раз таки проверяется открыто ли соединение и если нет - то выдается соответствующий фейл.

    ОтветитьУдалить
  4. возможно там был jabber, я не про то, а про то что уже есть готова реализация, в которой достаточно поменять библеотеку обмена сообщениями -- это конечно путь, для тех кто хочет результат а не путь

    ОтветитьУдалить
  5. если ты проверяешь отправку байтов - ТЫ ДОЛЖЕН ПРОВЕРЯТЬ ТОЛЬКО ОТПРАВКУ БАЙТОВ

    ОтветитьУдалить
  6. твой божек не хочет писать мои ник с маленькой буквы

    ОтветитьУдалить
  7. Ориентировка на путь, а не на результат позволяет получить больше полезных навыков при достижении результата.

    ОтветитьУдалить
  8. Но в случае, когда некуда отправлять байты, смысл вообще что-то проверять? Не лучше ли сразу же выйти, оповестив пользователя о некорректной работе самого модуля тестирования?

    ОтветитьУдалить
  9. получение навыка -- ориентирование на путь, дедлайн через 50 часов - ориентиование на результат)

    ОтветитьУдалить
  10. Видимо ты молился ему недостаточно усердно)

    ОтветитьУдалить
  11. 1) функция проверки соединения
    2) функция проверки отправки
    3) функция проверки приема

    если соединения нету, то программа перестанет выполнятся на 1 шаге, и не дойдет до 2 + есть шанс,что в будущем методика проверки соединения будет другой + я бы вынес её в отдельную функцию

    ОтветитьУдалить
  12. А смысл писать отдельный тест для проверки соединения, если мы создаем свой "соединяющий" канал внутри теста, а не внутри проверяемой программы?

    ОтветитьУдалить
  13. странное ощущение мне подсказывает что
    1) тесты должны проверять ВЕСЬ функционал
    (значит нужно отдельно проверять и создание соединения)
    2) на одну рабочую функцию должна приходится 1 тестовая, работающая по принципу -- ввели входные, получили исходящие, сверили с ожидаемыми

    тест проверяющий отправку сообщений, не должен проверять залогинен ли пользователь, тест проверяющий менеджера потоков не должен проверять, инициализирован ли драйвер, тест проверяющий отправку данных, должен проверять корректно ли отправляются данные а не соединение. ты когда отправляешь данные, не обязян знать тонкости реализации соединения.

    почитай Роберта Мартина - хорошо пишет

    ОтветитьУдалить
  14. Согласен. Но установление соединения не входит в функционал проверяемой программы. Рабочей функции, которая занимается установкой соединения нет.
    Проверка внутренняя по отношению к тесту, она никоим образом не относится к проверке каких-то внутренностей рабочей программы.

    ОтветитьУдалить
  15. >Рабочей функции, которая занимается установкой соединения нет<
    TDD ?  сначала пишешь ВСЕ тесты, потому пишешь код, пока все тесты не будут выполнятся ?
    + если потом функция появится -- прийдеться переписывать тест ?

    ОтветитьУдалить
  16. Писать новый тест для этой функции, разве не?
    TDD всего лишь парадигма, мне она пока-что не нравится...

    ОтветитьУдалить
  17. >Писать новый тест для этой функции, разве не?<
    Ходят слухи,что если писать правильно то хватает 1 раза...

    ОтветитьУдалить
  18. Но ведь так и есть - один раз написать новый тест для новой функции и все...

    ОтветитьУдалить
  19. Привет! Пытаюсь установить как у тебя но пишет что не знает "makepkg". Может ты знаешь в коком пакете находится сие чудо? у меня linux 12.04.

    ОтветитьУдалить