Проблема[]
Вам необходимо протестировать логику, в которой используются 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"