Smalltalk по-русски
Advertisement

Проблема[]

Вам необходимо протестировать логику, в которой используются fork (выделение блока в отдельный процесс), repeat (бесконечное повторение блока) или Delay (пауза текущего процесса). Это сопряжено с определенными трудностями:

  1. Результата выполнения кода, выделенного в отдельный процесс, сложно "дождаться" в тесте. Допустим, вы инициируете выполнение какого-то кода из теста, и этот код создает новый процесс на таком же или меньшем приоритете. Созданный процесс получит управление только после того, как текущий закончит свою работу, будет принудительно остановлен или встанет на семафоре. Скорее всего, новый процесс получит управление уже тогда, когда тест завершиться. В качестве решения этой проблемы, можно вставить в тест некоторую задержку (Delay forMilliseconds: 100) wait, но это, во-первых, не надежно, т.к. мы не знаем наверняка, сколько будет выполняться наш процесс (особенно, учитывая, что на это могут влиять внешние факторы, например, включившийся сборщик мусора), а во-вторых, заставит тест проходить дольше, чем он мог бы, учитывая, что задержка будет выбрана с запасом.
  2. Бесконечное повторение блока будет невозможно остановить из теста, т.к. сам тест "повиснет", будучи не в состоянии продвинуться дальше repeat.
  3. Задержка (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"

Андрей Мужиков

Advertisement