Тестирование потоков, повторений и задержек
Материал из Smalltalk по-русски
Содержание |
[править] Проблема
Вам необходимо протестировать логику, в которой используются fork (выделение
блока в отдельный процесс), repeat (бесконечное повторение блока) или Delay
(пауза текущего процесса). Это сопряжено с определенными трудностями:
- Результата выполнения кода, выделенного в отдельный процесс, сложно "дождаться" в тесте. Допустим, вы инициируете выполнение какого-то кода из теста, и этот код создает новый процесс на таком же или меньшем приоритете. Созданный процесс получит управление только после того, как текущий закончит свою работу, будет принудительно остановлен или встанет на семафоре. Скорее всего, новый процесс получит управление уже тогда, когда тест завершиться. В качестве решения этой проблемы, можно вставить в тест некоторую задержку
(Delay forMilliseconds: 100) wait, но это, во-первых, не надежно, т.к. мы не знаем наверняка, сколько будет выполняться наш процесс (особенно, учитывая, что на это могут влиять внешние факторы, например, включившийся сборщик мусора), а во-вторых, заставит тест проходить дольше, чем он мог бы, учитывая, что задержка будет выбрана с запасом. - Бесконечное повторение блока будет невозможно остановить из теста, т.к. сам тест "повиснет", будучи не в состоянии продвинуться дальше
repeat. - Задержка (
Delay) в тестируемом коде заставит тест проходить дольше. При этом наличие задержки может быть не обязательно для успешного тестирования логики.
Для всех этих проблем есть одно решение. Оно уже обсуждалось на smalltalk.ru применительно к модальным диалогам и, в принципе, является универсальным средством контроля над выполнением того или иного кода, находящегося выше по стеку без необходимости передачи управляющих параметров. Это использование Notification.
Рассмотрим три наши проблемы на конкретных примерах. Код примеров написан на VisualWorks.
[править] fork
У нас есть класс, который умеет считать факториал заданного числа. При запросе на подсчет факториала он возвращает нам управление немедленно, и инициирует подсчет в процессе с меньшим приоритетом. По окончанию подсчета результат будет сохранен в ValueHolder, т.ч. мы сможем с помощью update'а узнать о готовности и забрать его. Если не принимать во внимание возможность осуществления нового запроса до окончания текущего и релиз по окончанию работы (это несколько усложнило бы наш пример, но не изменило ничего принципиально), то этот класс мог бы выглядеть так:
Smalltalk defineClass: #BackgroundFactorial
superclass: #{Core.Object}
indexedType: #none
private: false
instanceVariableNames: 'resultHolder '
classInstanceVariableNames: ''
imports: ''
category: ''
BackgroundFactorial>>resultHolder
^resultHolder ifNil: [resultHolder := ValueHolder new]
BackgroundFactorial>>calculate: aNumber
[self resultHolder value: aNumber factorial]
forkAt: Processor userBackgroundPriority
Написание теста, который проверяет, что факториал считается не сразу не вызывает затруднений:
Smalltalk defineClass: #BackgroundFactorialTestCase
superclass: #{XProgramming.SUnit.TestCase}
indexedType: #none
private: false
instanceVariableNames: 'backgroundFactorial '
classInstanceVariableNames: ''
imports: ''
category: ''
BackgroundFactorialTestCase>>setUp
super setUp.
backgroundFactorial := BackgroundFactorial new
BackgroundFactorialTestCase>>testDoesNotCalculateInline
backgroundFactorial calculate: 3.
self assert: backgroundFactorial resultHolder value = nil
Но как теперь написать тест, который проверит, что факториал был, в конечном счете, подсчитан? Есть вариант с использованием задержки. Реализуем его:
BackgroundFactorialTestCase>>testCalculatesFactorial backgroundFactorial calculate: 3. (Delay forMilliseconds: 100) wait. self assert: backgroundFactorial resultHolder value = 6
Но есть и другой способ. Для его реализации нам понадобится наш Notification - ForkRequest, с двумя переменными для хранения блока и приоритета:
Smalltalk defineClass: #ForkRequest
superclass: #{Core.Notification}
indexedType: #none
private: false
instanceVariableNames: 'block priority'
classInstanceVariableNames: ''
imports: ''
category: ''
ForkRequest class>>raiseForBlock: aBlock priority: aNumber
(self new setBlock: aBlock priority: aNumber) raise
ForkRequest>>setBlock: aBlock priority: aNumber
block := aBlock.
priority := aNumber
Реализуем в BlockClosure метод альтернативный forkAt: - managedForkAt:
BlockClosure>>managedForkAt: ForkRequest raiseForBlock: self priority: aNumber
Как видите, в этом методе нет никакого создания нового процесса, вместо этого мы порождаем ForkRequest. Непосредственное создание процесса реализуем так:
ForkRequest>>defaultAction block forkAt: priority
Метод defaultAction будет вызван в том случае, если никто не перехватит наш ForkRequest. Т.о. managedForkAt: будет работать так же как простой forkAt:. Перепишем наш BackgroundFactorial с использованием нового метода:
BackgroundFactorial>>calculate: aNumber
[self resultHolder value: aNumber factorial]
managedForkAt: Processor userBackgroundPriority
Чтобы убедиться, что мы ничего не сломали, запустим наши 2 теста. Если мы все сделали правильно, для них мир не изменился, и они пройдут, как и прежде. Нам осталось сделать всего одну вещь - научиться избавляться от запуска процесса,когда мы этого захотим. Реализуем еще один метод у BlockClosure:
BlockClosure>>inlineForks
self
on: ForkRequest
do:
[:request |
request block value.
request resume]
Здесь, перехватив ForkRequest, мы вместо запуска процесса выполняем блок немедленно. Для того чтобы managedForkAt: в каком-то участке кода просто выполнил блок, вместо создания процесса, достаточно окружить этот участок блоком и послать последнему inlineForks. В этом случае, ForkRequest не вызовет свой defaultAction, т.к. будет перехвачен внутри BlockClosure>>inlineForks, а блок будет выполнен немедленно в обработчике on:do:. С назначением последней строки - request resume разберемся чуть позже, а пока используем наш новый метод в тесте. Задержку можно убрать, т.к. она нам теперь не нужна:
BackgroundFactorialTestCase>>testCalculatesFactorial [backgroundFactorial calculate: 3] inlineForks. self assert: backgroundFactorial resultHolder value = 6
Убедимся, что тест проходит. Получился вполне читаемый и лаконичный код, который, в отличии от предыдущей версии, не может не сработать из-за внешних обстоятельств, а наши два теста в паре гарантируют, что мы действительно используем fork и делаем в отдельном процессе то, что нужно.
Вернемся к последней строчке BlockClosure>>inlineForks - request resume. В нашем примере ее отсутствие ничего бы не сломало, в порядке эксперимента можно ее убрать и прогнать тесты. Немного модифицируем метод BackgroundFactorial>>calculate:, добавив код после создания процесса:
BackgroundFactorial>>calculate: aNumber
[self resultHolder value: aNumber factorial]
managedForkAt: Processor userBackgroundPriority.
Transcript show: 'calculation started '.
После прогона двух тестов мы ожидаем увидеть в транскрипте две записи "calculation started", но там только одна. Дело в том, что on:do: в BlockClosure>>inlineForks прекратил выполнение блока на вызове managedForkAt: и до вывода в транскрипт дело не дошло. Если мы вернем на место request resume, то после обработки ForkRequest, выполнение блока продолжится. Сделаем это и убедимся, что теперь на каждый прогон тестов выводится две записи.
[править] repeat и Delay
Некоторый класс проверяет состояние устройства, посылая ему isReady каждую секунду. Как только устройство ответит отрицательно, класс сгенерирует исключение. Этот класс мог бы выглядеть так:
Smalltalk defineClass: #DevicePinger
superclass: #{Core.Object}
indexedType: #none
private: false
instanceVariableNames: ''
classInstanceVariableNames: ''
imports: ''
category: ''
DevicePinger>>checkDevice: anObject
[anObject isReady ifFalse: [DeviceDownError raise].
(Delay forSeconds: 1) wait] repeat
Тестируя этот метод, мы хотим убедиться как в правильности работы повторяемого кода, так и в наличии самого повторения. При этом мы бы не хотели "повесить" наш тест в ожидании окончания бесконечных повторов. Повторяемый код содержит Delay, и в тесте мы бы хотели убедиться в его наличии, при этом, не увеличивая времени прохождения теста сверх времени, необходимого на все вычисления. Т.о., нашей задачей будет научиться управлять повторами в repeat и избегать вызова wait, регистрируя при этом сам факт задержки.
Как и в случае с fork, для управления работой repeat нам понадобится Notification с переменной для хранения блока:
Smalltalk defineClass: #RepeatRequest
superclass: #{Core.Notification}
indexedType: #none
private: false
instanceVariableNames: 'block '
classInstanceVariableNames: ''
imports: ''
category: ''
RepeatRequest class>>raiseForBlock: aBlock
(self new setBlock: aBlock) raise
RepeatRequest class>>setBlock: aBlock
block := aBlock
Реализуем в BlockClosure замену методу repeat - managedRepeat
BlockClosure>>managedRepeat RepeatRequest raiseForBlock: self
Всю работу по стандартному повтору блока перенесем в RepeatRequest>>defaultAction
RepeatRequest>>defaultAction block repeat
А в DevicePinger>>checkDevice: используем наш новый managedRepeat
DevicePinger>>checkDevice: anObject [anObject isReady ifFalse: [DeviceDownError raise]. (Delay forSeconds: 1) wait] managedRepeat
К сожалению, у нас нет теста, чтобы убедиться, что мы не изменили поведение checkDevice:, но, по всей видимости, для него все осталось по прежнему - managedRepeat сгенерирует RepeatRequest, передав параметром блок для повтора; если никто не перехватит RepeatRequest, то будет вызван его defaultAction, где блоку пошлют repeat - тот же результат, что и до модификаций.
Теперь нам необходимо научиться управлять работой managedRepeat. Для этого реализуем в BlockClosure метод limitRepeats:
BlockClosure>>limitRepeats: aNumber
self
on: RepeatRequest
do:
[:request |
aNumber timesRepeat: request block.
request resume]
Здесь, перехватив RepeatRequest, вместо бесконечного повтора блока, мы выполняем его заданное кол-во раз. Для того чтобы managedRepeat в каком-то участке кода выполнил блок заданное кол-во раз, достаточно окружить этот участок блоком и послать последнему limitRepeats: с желаемым кол-вом повторов в качестве аргумента. В этом случае, RepeatRequest не вызовет свой defaultAction, т.к. будет перехвачен внутри BlockClosure>>limitRepeats:, а блок будет выполнен только несколько раз в обработчике on:do:. Необходимость последней строки - request resume - была объяснена в предыдущей части для похожего метода BlockClosure>>inlineForks.
Теперь, имея limitRepeats:, мы можем написать тесты на наш метод:
Smalltalk defineClass: #DevicePingerTestCase
superclass: #{XProgramming.SUnit.TestCase}
indexedType: #none
private: false
instanceVariableNames: 'pinger mockDevice '
classInstanceVariableNames: ''
imports: ''
category: ''
DevicePingerTestCase>>setUp
super setUp.
pinger := DevicePinger new.
mockDevice := MockObject orderedMockOn: self class
DevicePingerTestCase>>isReady
^self
DevicePingerTestCase>>testNormalWorking
mockDevice
isReady;
mockReturn: true repeat: 2;
mockActivate.
self
shouldnt: [[pinger checkDevice: mockDevice] limitRepeats: 2]
raise: DeviceDownError.
mockDevice mockVerify
DevicePingerTestCase>>testDeviceError
mockDevice
isReady;
mockReturn: true;
isReady;
mockReturn: false;
mockActivate.
self
should: [[pinger checkDevice: mockDevice] limitRepeats: 2]
raise: DeviceDownError.
mockDevice mockVerify
Метод DevicePingerTestCase>>isReady нужен нам только для корректной работы MockObject, возвращаемое им значение не играет роли. Наши два теста проверяют циклический опрос устройства с обнаружением ошибки и без нее. Осталось две проблемы: они медленно работают и не проверяют наличие задержки.
Для того, чтобы управлять работой Delay нам понадобится еще один Notification - WaitRequest и собственный аналог Delay>>wait - Delay>>managedWait
Smalltalk defineClass: #WaitRequest
superclass: #{Core.Notification}
indexedType: #none
private: false
instanceVariableNames: 'delay '
classInstanceVariableNames: ''
imports: ''
category: ''
WaitRequest class>>raiseForDelay: aDelay
(self new setDelay: aDelay) raise
WaitRequest>>setDelay: aDelay
delay := aDelay
WaitRequest>>defaultAction
delay wait
Delay>>managedWait
WaitRequest raiseForDelay: self
Все устроено аналогично managedRepeat. Используем managedWait в нашем классе:
DevicePinger>>checkDevice: anObject [anObject isReady ifFalse: [DeviceDownError raise]. (Delay forSeconds: 1) managedWait] managedRepeat
Запустим наши тесты. Они должны все так же проходить, и все так же медленно - для них ничего не изменилось. Теперь реализуем возможность игнорировать задержки там, где нам это нужно. Для этого создадим в BlockClosure метод suppressWaits
BlockClosure>>suppressWaits self on: WaitRequest do: [:request | request resume]
Поскольку пока мы хотим только избавиться от задержек в тестах, мы ничего не будем делать в обработчике WaitRequest (кроме request resume, конечно, объяснение которому дано выше) и, таким образом, внутри блока, которому послали suppressWaits, задержки будут просто проигнорированы. Используем эту новую возможность в наших тестах, и они станут работать значительно быстрее:
DevicePingerTestCase>>testNormalWorking
mockDevice
isReady;
mockReturn: true repeat: 2;
mockActivate.
self
shouldnt: [[[pinger checkDevice: mockDevice] limitRepeats: 2] suppressWaits]
raise: DeviceDownError.
mockDevice mockVerify
DevicePingerTestCase>>testDeviceError
mockDevice
isReady;
mockReturn: true;
isReady;
mockReturn: false;
mockActivate.
self
should: [[[pinger checkDevice: mockDevice] limitRepeats: 2] suppressWaits]
raise: DeviceDownError.
mockDevice mockVerify
Следует обратить особое внимание на то, что порядок вызова limitRepeats: и suppressWaits очень важен. Дело в том, что managedWait может быть вызван не из самого блока [pinger checkDevice: mockDevice], а из обработчика RepeatRequest внутри limitRepeats:, следовательно, защищать с помощью suppressWaits нужно не только блок, но и тело метода limitRepeats:. В порядке эксперимента можете заменить [[[pinger checkDevice: mockDevice] limitRepeats: 2] suppressWaits] на [[[pinger checkDevice: mockDevice] suppressWaits] limitRepeats: 2] и убедиться, что тесты опять проходят медленно, т.к. suppressWaits "не срабатывает".
Мы ускорили тесты, но они все так же не проверяют наличие задержки. Убедимся в этом: уберем задержку из DevicePinger>>checkDevice:, и проверим, что тесты не перестали проходить. Теперь, для проверки того, что задержка используется всегда, когда мы ее ожидаем в тесте, модифицируем метод suppressWaits
suppressWaits
| waited |
waited := false.
[self
on: WaitRequest
do:
[:request |
waited := true.
request resume]]
ensure: [waited ifFalse: [TestResult signalFailureWith: 'Wait expected']]
Мы требуем, чтобы внутри блока, защищенного suppressWaits, была хотя бы единожды использована задержка, в противном случае, тест упадет с сообщением Wait expected. Обратите внимание, что проверка в конце метода должна быть внутри ensure:, т.к. мы хотим, чтобы она была осуществлена даже в том случае, когда выполнение защищенного блока было прервано, например, после возникновения какой-либо ошибки.
После такой модификации suppressWaits тесты не будут проходить, пока мы не вернем задержку на место.
Итак, теперь наши тесты проверяют наличие повторения, правильность работы повторяемого кода и наличие задержки. При этом сама задержка не влияет на скорость работы тестов.
[править] Все вместе
Мы уже использовали limitRepeats: и suppressWaits вместе. Конечно, возможно их совместное использование с inlineForks. Если бы наш DevicePinger осуществлял проверку устройств в отдельном потоке, например, так:
DevicePinger>>checkDevice: anObject process := [[anObject isReady ifFalse: [DeviceDownError raise]. (Delay forSeconds: 1) managedWait] managedRepeat] managedFork
то в тестах мы бы использовали еще и inlineForks. Например:
...
self
should: [[[[pinger checkDevice: mockDevice] inlineForks] limitRepeats: 2] suppressWaits]
raise: DeviceDownError.
...
Поряд вызовов все так же важен, ведь повтор может быть инициирован не из блока [pinger checkDevice: mockDevice], а из самого метода inlineForks, и, следовательно, сам метод нужно так же защитить с помощью limitRepeats:. Чтобы не дублировать знание о порядке и немного упростить код, мы можем вырезать всех троих в один метод:
BlockClosure>>noForksAndWaitsLimitedRepeats: aNumber [[self inlineForks] limitRepeats: aNumber] suppressWaits
И тогда тесты будут выглядеть так:
...
self
should: [[pinger checkDevice: mockDevice] noForksAndWaitsLimitedRepeats: 2]
raise: DeviceDownError.
...
[править] Возможные трудности
Разумеется, предложенная реализация далека от универсальной, и в некоторых случаях работать не будет. Но я уверен, что для любой проблемы найдется модификация предложенного кода, которая исправит ситуацию. Причем, это будет, как и всегда со Smalltalk, совсем не сложно.
Для примера, рассмотрим две возможные проблемы с inlineForks:
1. Код, вызывающий forkAt:, запоминает результат вызова - Process - для, например, остановки его в release. Мы же возвращаем из вызова managedForkAt: блок, а он в качестве процесса не сгодиться - это, скорее всего, приведет к ошибке в release. Вариант поведения managedForkAt: по умолчанию исправить не трудно. Добавим возврат в managedForkAt: и методы ForkRequest
BlockClosure>>managedForkAt: aNumber ^ForkRequest raiseForBlock: self priority: aNumber ForkRequest class>>raiseForBlock: aBlock priority: aNumber ^(self new setBlock: aBlock priority: aNumber) raise ForkRequest>>defaultAction ^block forkAt: priority
Теперь, из вызова managedForkAt:, не защищенного inlineForks, будет возвращаться процесс. Но что возвращать, когда используется inlineForks? Ведь там нет никакого процесса вовсе. Все просто:
BlockClosure>>inlineForks
self
on: ForkRequest
do:
[:request |
request block value.
request resume: Process new]
Просто Process new, и результату managedForkAt: можно без опасений посылать terminate и пр.
2. Какая-то логика в BackgroundFactorial рассчитывает, что процесс выполнится не сразу, а в соответствии со своим приоритетом. Например:
BackgroundFactorial>>calculate: aNumber [self resultHolder value: aNumber factorial. self reportFinished] managedForkAt: Processor activeProcess priority - 1. self reportStarted
В случае использования "честного" forkAt:, reportStarted будет вызван всегда раньше, чем reportFinished, а с использованием inlineForks порядок будет обратным. Похоже, чтобы поведение не изменялось, нам необходимо всегда создавать настоящий процесс, но при этом каким-то образом дожидаться в тесте его окончания. Это можно устроить - модифицируем метод inlineForks
BlockClosure>>inlineForks
| semaphore |
semaphore := Semaphore new.
self
on: ForkRequest
do:
[:request |
| process |
process := [request block value.
semaphore signal] forkAt: request priority.
request resume: process].
semaphore wait
ForkRequest>>priority
^priority
Теперь всегда создается процесс на заданном приоритете, и логика, рассчитывающая на это, не сломается. В тесте же мы дожидаемся выполнения всего кода процесса (request block value) на семафоре в конце метода inlineForks. Как только весь код процесса выполнится, семафор просигналят, и мы продвинемся в тесте дальше вызова inlineForks.
Примеры к статье можно скачать в Cincom Puplic Repository, пакадж: "ProcessRepeatDelayTestingExamples"
Андрей Мужиков
