Регистрация | Войти
Lisp — программируемый язык программирования
Предыдущая Оглавление Следующая

9. Практикум: Каркас для юнит-тестирования.

В этой главе вы вернётесь к написанию кода и разработаете простой каркас для юнит-тестирования Lisp. Это даст вам возможность использовать в реальном коде некоторые возможности языка, о которых вы узнали после главы 3, включая макросы и динамические переменные.

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

Главная особенность автоматизированного тестирования состоит в том, что каркас отвечает за проверку, все ли тесты выполнились успешно. Вам не требуется тратить время на то, чтобы пробираться сквозь результаты, сверяя их с ожидаемыми — компьютер может сделать это гораздо быстрее и аккуратнее вас. Как следствие, каждый тест должен быть выражением, которое вырабатывает логическое значение — истина или ложь, тест выполнен успешно или провалился. К примеру, если вы тестируете встроенную функцию +, следующие выражения являются вполне разумными тестами 1):

(= (+ 1 2) 3)
(= (+ 1 2 3) 6)
(= (+ -1 -3) -4)

Функции с побочными эффектами необходимо тестировать слегка по-другому — вам придётся вызвать функцию и затем проверить наличие ожидаемых побочных эффектов 2). Но в любом случае каждый тест сводится к логическому выражению: сработало или не сработало.

Два первых подхода

Если бы вы тестировали вручную, вы бы вводили эти выражения в REPL и проверяли бы, что они возвращают T. Но вам нужен каркас, который позволяет с лёгкостью организовывать и запускать эти тесты в любое время. Если вы хотите начать с самой простой работающей версии, вы можете просто написать функцию, которая вычисляет все тесты и возвращает T в случае успешного прохождения всех тестов (используя AND для этого).

(defun test-+ ()
  (and
    (= (+ 1 2) 3)
    (= (+ 1 2 3) 6)
    (= (+ -1 -3) -4)
)
)

Для запуска тестов просто вызовите test-+.

CL-USER> (test-+)
T

Пока функция возвращает T, вы знаете, что тесты проходят. Такой способ организации тестов также весьма выразителен — вам не нужно писать много кода, обслуживающего тестирование. Однако при первом же проваливающемся тесте вы заметите, что отчёт о тестировании оставляет желать лучшего: если test-+ возвращает NIL, вы знаете, что какой-то тест провалился, но не имеете понятия, какой именно.

Давайте попробуем другой простой (можно даже сказать — глупый) подход: чтобы проверить, что случилось с каждым тестом, напишем так:

(defun test-+ ()
  (format t "~:[FAIL~;pass~] ... ~a~%" (= (+ 1 2) 3) '(= (+ 1 2) 3))
  (format t "~:[FAIL~;pass~] ... ~a~%" (= (+ 1 2 3) 6) '(= (+ 1 2 3) 6))
  (format t "~:[FAIL~;pass~] ... ~a~%" (= (+ -1 -3) -4) '(= (+ -1 -3) -4))
)

Теперь каждый тест будет сообщать результат отдельно. Часть ~:[FAIL~;pass~] форматной строки FORMAT печатает FAIL если первый аргумент ложен и pass — если истинен 3) . Теперь запуск test-+ покажет подробности происходящего.

CL-USER> (test-+)
pass ... (= (+ 1 2) 3)
pass ... (= (+ 1 2 3) 6)
pass ... (= (+ -1 -3) -4)
NIL

В этот раз отчёт выглядит гораздо лучше, но сам код ужасен. Повторяющиеся вызовы FORMAT и утомительное дублирование тестовых выражений напрашиваются на рефакторинг. Дублирование выражений особо раздражает, потому что если вы опечатаетесь, то и результаты тестирования будут промаркированы неверно.

Другая проблема состоит в том, что вы не получаете единого ответа, прошли ли все тесты успешно. Для трёх тестов достаточно легко проверить, что вывод не содержит строчек FAIL, но при наличии сотен тестов это начнёт надоедать.

Рефакторинг

Что вам действительно нужно — это способ писать тесты так элегантно, как в первой функции test-+, которая возвращает T или NIL, но также отчитывается о результатах индивидуальных тестов, так, как во второй версии. Поскольку вторая версия близка по функциональности к тому, что вам нужно, лучшее, что вы можете сделать — проверить, можно ли исключить из неё раздражающее дублирование.

Простейший способ избавиться от повторяющихся похожих вызовов FORMAT — создать новую функцию.

(defun report-result (result form)
  (format t "~:[FAIL~;pass~] ... ~a~%" result form)
)

Теперь вы можете писать test-+, вызывая report-result вместо FORMAT. Не слишком упрощает жизнь, но по крайней мере если вы решите изменить вид выдаваемых результатов, то вам придётся менять код только в одном месте.

(defun test-+ ()
  (report-result (= (+ 1 2) 3) '(= (+ 1 2) 3))
  (report-result (= (+ 1 2 3) 6) '(= (+ 1 2 3) 6))
  (report-result (= (+ -1 -3) -4) '(= (+ -1 -3) -4))
)

Следующее, что нужно сделать — избавиться от дублирования тестового выражения, с присущим дублированию риском неправильной маркировки результата тестирования. Что вам нужно — это возможность обработать тестовое выражение одновременно как код (для получения результата теста) и как данные (для использования в качестве метки теста). Использование кода, как данных — это безошибочный признак того, что вам нужен макрос. Или, если посмотреть на это с другой стороны, вам нужен способ автоматизировать подверженное ошибкам написание вызовов report-result. Неплохо было бы написать что-то, похожее на

(check (= (+ 1 2) 3))

и чтобы это означало следующее:

(report-result (= (+ 1 2) 3) '(= (+ 1 2) 3))

Написание макроса для выполнения этой трансформации тривиально.

(defmacro check (form)
  `(report-result ,form ',form)
)

Теперь вы можете изменить test-+, чтобы использовать check.

(defun test-+ ()
  (check (= (+ 1 2) 3))
  (check (= (+ 1 2 3) 6))
  (check (= (+ -1 -3) -4))
)

Раз уж вы устраняете дублирование, почему бы не избавиться от повторяющихся вызовов check? Можно заставить check принимать произвольное количество аргументов и заворачивать каждый из них в вызов report-result.

(defmacro check (&body forms)
  `(progn
     ,@(loop for f in forms collect `(report-result ,f ',f))
)
)

Это определение использует общепринятую идиому — оборачивание набора форм в вызов PROGN, чтобы сделать их единой формой. Заметьте, как можно использовать ,@ для вклеивания результата выражения, которое возвращает список выражений, которые сами по себе созданы с помощью шаблона, созданного обратной кавычкой.

С новой версией check можно написать новую версию test-+ следующим образом:

(defun test-+ ()
  (check
    (= (+ 1 2) 3)
    (= (+ 1 2 3) 6)
    (= (+ -1 -3) -4)
)
)

что эквивалентно следующему коду:

(defun test-+ ()
  (progn
    (report-result (= (+ 1 2) 3) '(= (+ 1 2) 3))
    (report-result (= (+ 1 2 3) 6) '(= (+ 1 2 3) 6))
    (report-result (= (+ -1 -3) -4) '(= (+ -1 -3) -4))
)
)

Благодаря макросу check, этот вариант столь же краток, как первая версия test-+, но раскрывается в код, который делает то же самое, что вторая версия. Кроме того, вы можете внести любые изменения в поведение test-+, изменяя только check.

Чиним возвращаемое значение

Вы можете начать с изменения test-+ таким образом, чтобы его возвращаемое значение показывало, все ли тесты завершились успешно. Поскольку check отвечает за генерацию кода, который запускает тесты, вам нужно изменить его так, чтобы генерируемый код подсчитывал результаты тестов.

Для начала можно внести небольшое изменение в report-result, чтобы он возвращал результат выполняемого им теста.

(defun report-result (result form)
  (format t "~:[FAIL~;pass~] ... ~a~%" result form)
  result
)

Теперь, когда report-result возвращает значение теста, кажется, что вы можете просто изменить PROGN на AND. К сожалению, AND не будет работать так, как вам хочется в этом случае, из-за своего прерывания, как только один из тестов провалится, AND пропустит остальные. С другой стороны, если бы вы имели конструкцию, которая действует как AND, но не прерываясь, вы могли бы её использовать на месте PROGN. Common Lisp не предоставляет такой конструкции, но это не помешает вам использовать её: вы с легкостью можете написать макрос, предоставляющий такую конструкцию.

Оставляя тесты в стороне на минуту, вам нужен макрос (назовём его combine-results), который позволит вам сказать

(combine-results
  (foo)
  (bar)
  (baz)
)

и это будет значить

(let ((result t))
  (unless (foo) (setf result nil))
  (unless (bar) (setf result nil))
  (unless (baz) (setf result nil))
  result
)

Единственный нетривиальный момент в написании этого макроса - это введение переменной (result в предыдущем кусочке кода) в раскрытие макроса. Как вы видели в предыдущей главе, использование обычных имён для переменных в раскрытом макросе может заставить протекать абстракцию, так что вам нужно будет создать уникальное имя, что делается с помощью with-gensyms. Вы можете определить combine-results так:

(defmacro combine-results (&body forms)
  (with-gensyms (result)
    `(let ((,result t))
      ,@(loop for f in forms collect `(unless ,f (setf ,result nil)))
      ,result
)
)
)

Теперь вы можете исправить check, просто заменив PROGN на combine-results.

(defmacro check (&body forms)
  `(combine-results
    ,@(loop for f in forms collect `(report-result ,f ',f))
)
)

С этой версией check test-+ должен выдавать результаты своих трёх тестов и затем возвращать T, показывая, что все тесты завершились успешно 4).

CL-USER> (test-+)
pass ... (= (+ 1 2) 3)
pass ... (= (+ 1 2 3) 6)
pass ... (= (+ -1 -3) -4)
T

Если вы измените один из тестов так, чтобы он проваливался 5), возвращаемое значение изменится на NIL.

CL-USER> (test-+)
pass ... (= (+ 1 2) 3)
pass ... (= (+ 1 2 3) 6)
FAIL ... (= (+ -1 -3) -5)
NIL

Улучшение отчёта

Пока вы тестируете только одну функцию, результаты тестирования обозримы. Если какой-то тест проваливается, всё, что вам нужно сделать - это найти его в конструкции check и понять, почему он не срабатывает. Но если вы пишете много тестов, вы, возможно, захотите структурировать их, а не запихивать все больше и больше тестов в одну функцию. Например, предположим, что вы хотите добавить несколько тестов для функции *. Вы могли бы написать новую функцию тестирования.

(defun test-* ()
  (check
    (= (* 2 2) 4)
    (= (* 3 5) 15)
)
)

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

(defun test-arithmetic ()
  (combine-results
   (test-+)
   (test-*)
)
)

В этой функции вы используете combine-results вместо check, потому что и test-+, и test-* сами позаботятся о выводе результатов своих тестов. Когда вы запустите test-arithmetic, вы получите следующий результат:

CL-USER> (test-arithmetic)
pass ... (= (+ 1 2) 3)
pass ... (= (+ 1 2 3) 6)
pass ... (= (+ -1 -3) -4)
pass ... (= (* 2 2) 4)
pass ... (= (* 3 5) 15)
T

Теперь представьте, что один из тестов провалился, и вам нужно найти проблему. Для пяти тестов и двух тестовых функций это будет не так сложно. Но представьте себе, что у вас 500 тестов, разнесённых по 20 функциям. Неплохо было бы, чтобы результаты сообщали вам, в какой функции находится каждый тест.

Поскольку код, который печатает результаты тестов, собран в report-result, вам нужен способ передать в неё информацию о том, в какой тестовой функции вы находитесь. Вы можете добавить параметр, сообщающий это, в report-result, но check, который генерирует вызовы report-result, не знает, из какой функции он вызван, что означает, что вам придётся изменить вызовы check, передавая аргумент, который он будет передавать дальше, в report-result.

Это в точности та проблема, для решения которой были придуманы динамические переменные. Если вы создадите динамическую переменную, которая привязывается к имени тестовой функции, то report-result сможет использовать её, а check может ничего о ней не знать.

Для начала определим переменную на верхнем уровне.

(defvar *test-name* nil)

Теперь слегка изменим report-result, чтобы включить *test-name* в вывод FORMAT.

(format t "~:[FAIL~;pass~] ... ~a: ~a~%" result *test-name* form)

После этих изменений тестовые функции всё ещё работают, но выдают следующие результаты из-за того, что *test-name* нигде не привязывается к значению, отличному от начального:

CL-USER> (test-arithmetic)
pass ... NIL: (= (+ 1 2) 3)
pass ... NIL: (= (+ 1 2 3) 6)
pass ... NIL: (= (+ -1 -3) -4)
pass ... NIL: (= (* 2 2) 4)
pass ... NIL: (= (* 3 5) 15)
T

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

(defun test-+ ()
  (let ((*test-name* 'test-+))
    (check
      (= (+ 1 2) 3)
      (= (+ 1 2 3) 6)
      (= (+ -1 -3) -4)
)
)
)


(defun test-* ()
  (let ((*test-name* 'test-*))
    (check
      (= (* 2 2) 4)
      (= (* 3 5) 15)
)
)
)

Теперь результаты правильно помечены именами тестовых функций.

CL-USER> (test-arithmetic)
pass ... TEST-+: (= (+ 1 2) 3)
pass ... TEST-+: (= (+ 1 2 3) 6)
pass ... TEST-+: (= (+ -1 -3) -4)
pass ... TEST-*: (= (* 2 2) 4)
pass ... TEST-*: (= (* 3 5) 15)
T

Выявление абстракций

При внесении изменений в тестовые функции, вы снова получили дублирующийся код. Тестовые функции не только дважды включают своё имя — первый раз при определении, второй раз при связывании с глобальной переменной *test-name* — но обе они начинаются совершенно одинаково ( вся разница — имя функции ). Вы могли бы попытаться избавиться от дублирования просто потому, что это некрасиво. Но если рассмотреть причину, вызвавшую дублирование, более подробно, то можно извлечь довольно важный урок по использованию макросов.

Причина, по которой обе функции начинаются одинаково, в том, что они обе предназначены для тестирования. Дублирование возникает из-за того, что тестовая функция — это только одна половина абстракции. Эта абстракция существует в вашей голове, но в коде нет возможности сказать "это — тестовая функция" другим способом, кроме как написанием соответствующего паттерна.

К сожалению, неполные абстракции — плохие помошники при написании программ. Полуабстракция, описанная в коде соответствующим паттерном, гарантирует вам массовое дублирование кода со всеми сопутствующими проблемами поддержки этого кода в дальнейшем. Более того, так как подобные абстракции целиком существуют только в наших мыслях, у нас нет никакой возможности убедиться, что разные программисты ( или даже один и тот же — но в разное время ) одинаково понимают одну и ту же абстракцию. Дабы полностью абстрагировать идею, вам нужно как-то выразить фразу "это — тестовая функция" соответствующим паттерном. Другими словами, вам нужен макрос.

Так как паттерн, который вы пытаетесь написать, представляет собой вызов DEFUN — и ещё немного кода — вам нужен макрос, раскрывающийся в вызов DEFUN. Вы будете использовать этот макрос вместо DEFUN для определения тестовых функций, так что имеет смысл назвать его deftest.

(defmacro deftest (name parameters &body body)
  `(defun ,name ,parameters
    (let ((*test-name* ',name))
      ,@body
)
)
)

Используя этот макрос, вы можете переписать test-+ следующим образом:

(deftest test-+ ()
  (check
    (= (+ 1 2) 3)
    (= (+ 1 2 3) 6)
    (= (+ -1 -3) -4)
)
)

Иерархия тестов

Теперь, когда у вас есть полноценные тестовые функции, может возникнуть вопрос — должна ли функция test-arithmetic также быть тестовой? Казалось бы — если вы определите её с помощью deftest, то её связывание с *test-name* скроет связывания test-+ и test-* — и это отразится на выводе результатов тестов.

Но представьте, что у вас есть тысяча ( или даже больше ) тестов, которые нужно как-то упорядочить. На самом нижнем уровне находятся такие функции как test-+ и test-*, непосредственно выполняющие проверку. При наличии тысяч тестов их потребуется каким-либо образом упорядочить. Такие функции как test-arithmetic могут группировать схожие тестовые функции в наборы тестов. Допустим, что некоторые низкоуровневые тестовые функции могут использоваться в разных наборах тестов. Тогда вполне возможна такая ситуация, что тест будет пройден в одном контексте и провалится в другом. Если это случится, вам наверняка захочется узнать несколько больше, чем просто имя провалившегося теста.

Если вы определите test-arithmetic посредством deftest, сделав небольшие изменения при связывании с *test-name*, то сможете получить отчёты с более подробным описанием контекста выполнившегося теста:

pass ... (TEST-ARITHMETIC TEST-+): (= (+ 1 2) 3)

Поскольку процесс определения тестовых функций описан отдельным паттерном, изменить отчёт можно и не меняя код самих тестовых функций 6). Сделать так, чтобы *test-name* хранил список имён тестовых функций вместо имени последней вызванной функции, очень просто. Вам нужно всего лишь изменить связывание:

(let ((*test-name* ',name))

на такое:

(let ((*test-name* (append *test-name* (list ',name))))

Так как APPEND возвращает новый список, составленный из его аргументов, это версия будет связывать *test-name* со списком, содержащим старое значение *test-name*, с новым именем, добавленным в конец списка 7). После выхода из функции, старое значение *test-name* восстанавливается.

Теперь вы можете переопределить test-arithmetic используя deftest вместо DEFUN.

(deftest test-arithmetic ()
  (combine-results
   (test-+)
   (test-*)
)
)

В результате вы получите именно то, что хотели:

CL-USER> (test-arithmetic)
pass ... (TEST-ARITHMETIC TEST-+): (= (+ 1 2) 3)
pass ... (TEST-ARITHMETIC TEST-+): (= (+ 1 2 3) 6)
pass ... (TEST-ARITHMETIC TEST-+): (= (+ -1 -3) -4)
pass ... (TEST-ARITHMETIC TEST-*): (= (* 2 2) 4)
pass ... (TEST-ARITHMETIC TEST-*): (= (* 3 5) 15)
T

С ростом количества тестов, вы можете добавлять новые уровни — и пока они будут определяться через deftest, вывод результата будт корректен. Так, если вы определите таким образом test-math:

(deftest test-math ()
  (test-arithmetic)
)

то получите вот такой результат:

CL-USER> (test-math)
pass ... (TEST-MATH TEST-ARITHMETIC TEST-+): (= (+ 1 2) 3)
pass ... (TEST-MATH TEST-ARITHMETIC TEST-+): (= (+ 1 2 3) 6)
pass ... (TEST-MATH TEST-ARITHMETIC TEST-+): (= (+ -1 -3) -4)
pass ... (TEST-MATH TEST-ARITHMETIC TEST-*): (= (* 2 2) 4)
pass ... (TEST-MATH TEST-ARITHMETIC TEST-*): (= (* 3 5) 15)
T

Подведение итогов

Вы могли бы продолжить работу над этим каркасом, добавляя новые возможности — но как каркас для написания тестов без особого напряжения и с возможностью использовать REPL, это очень неплохое начало. Ниже код приведён полностью, все 26 строк:

(defvar *test-name* nil)

(defmacro deftest (name parameters &body body)
  "Define a test function. Within a test function we can call
   other test functions or use 'check' to run individual test
   cases."

  `(defun ,name ,parameters
    (let ((*test-name* (append *test-name* (list ',name))))
      ,@body
)
)
)


(defmacro check (&body forms)
  "Run each expression in 'forms' as a test case."
  `(combine-results
    ,@(loop for f in forms collect `(report-result ,f ',f))
)
)


(defmacro combine-results (&body forms)
  "Combine the results (as booleans) of evaluating 'forms' in order."
  (with-gensyms (result)
    `(let ((,result t))
      ,@(loop for f in forms collect `(unless ,f (setf ,result nil)))
      ,result
)
)
)


(defun report-result (result form)
  "Report the results of a single test case. Called by 'check'."
  (format t "~:[FAIL~;pass~] ... ~a: ~a~%" result *test-name* form)
  result
)

Этот пример прекрасно иллюстрирует обычный ход программирования на языке Lisp, так что давайте рассмотрим процесс его написания ещё раз.

Вы начали с постановки задачи — вычислить совокупность булевых выражений и узнать — все ли они возвращают true. Простое AND работало и синтаксически было абсолютно верно — но вывод результатов оставлял желать лучшего. Тогда вы написали немного действительно глуповатого кода, битком набитого повторениями и способствующими ошибкам выражениями, чтобы всё работало так, как вы хотели.

Естественно, что вы решили попробовать привести вторую версию программы к более ясному и красивому виду. Вы начали со стандартного приёма — выделения части кода в отдельную функцию — report-result. Увы, но использование report-result утомительно и чревато ошибками — тестовое выражение приходится писать дважды. Тогда вы написали макрос check для автоматически корректного вызова report-result.

В процессе написания макроса check, вы добавили возможность оборачивать несколько вызовов report-result в один вызов check, сделав новую версию test-+ столь же краткой, как и первоначальную с AND.

Следующей задачей было исправить check таким образом, чтобы генерируемый этим макросом код возвращал t или nil в зависимости от того, все ли тесты прошли удачно. Прежде чем переиначивать check, вы предположили, что у вас есть непрерываемая AND конструкция. В этом случае правка check — тривиальное дело. Вы обнаружили, что хотя такой конструкции и нет, написать её самим совсем не трудно. После написания combine-results исправить check было элементарным делом.

Затем, всё, что оставалось, это сделать более удобным отчёт. Начав с исправления тестовых функций, вы представили их как особый вид функций — и в результате написали макрос deftest, выделив паттерн, отличающий тестовые функции от всех прочих.

Наконец, с помощью макроса deftest, разделившего определение тестовой функции от лежащей в её основе структуры, вы получили возможность улучшить вывод результатов не меняя при этом сами тестовые функции.

Теперь, имея представление об основах — функциях, переменных и макросах; получив немного опыта по их практическому применению; вы готовы начать изучение богатой стандартной библиотеки функций и типов данных языка Common Lisp.

1)Разумеется, это только для большей наглядности — написание тестов для встроенных функций, таких, как +, может выглядеть несколько несуразно. Ведь если даже столь простые вещи не работают, трудно ожидать, что и тесты отработают так, как было задумано. С другой стороны, большинство реализаций Common Lisp написано на самом Common Lisp — и в этом случае наборы тестов для функций стандартной библиотеки уже не выглядят нелепостью.
2)Побочные эффекты также могут использоваться для сообщения об ошибках; про систему обработки ошибок в Common Lisp я расскажу в 19 главе. После прочтения этой главы вы можете подумать над тем, как объединить оба варианта.
3)Более подробно и эта, и другие управляющие команды FORMAT будут обсуждаться в 18 главе.
4)Если функция test-+ была откомпилирована — а это могло случиться и неявно в некоторых реализациях Common Lisp — вам потребуется заново определить её, чтобы изменения вступили в силу. В интерпретируемом же коде макросы обычно раскрываются каждый раз заново — при каждом выполнении функции — позволяя пронаблюдать эффект от изменения макроса сразу.
5)Просто измените один их тестов таким образом чтобы он проваливался — это проще, чем изменить поведение функции +.
6)В любом случае — если наши тестовые функции была скомпилированы, вам нужно будет перекомпилировать их после внесения изменений в макрос.
7)Как вы увидите в 12 главе, добавление в конец списка с помощью APPEND — не самый эффективный способ построения списка. Но пока нам достаточно и этого — пока глубина вложенности структуры тестов не слишком велика, это смотрится не так уж и плохо. А при необходимости — всегда можно просто чуть изменить определение deftest.
Предыдущая Оглавление Следующая
@2009-2013 lisper.ru