In Part-4, I'll show how to integrate Parsley into FlexUnit by using the [RunWith]
and [Rule]
hook mechanisms to make FlexUnit do the heavy lifting i.e. context initialization and dependency injection.
The 'test' code re-re-revisted...
As mentioned there are two approaches,[RunWith]
and [Rule]
. I had previously held-back from exploring the [Rule]
option as it comes with FlexUnit 4.1, which up until very recently, had only been available in beta. I explore this second option later in this post, but first...
The [RunWith]
approach
package com.darrenbishop.crm.directory.application { import com.darrenbishop.crm.directory.GetPersonCommandComponentTestContext;GetPersonCommandComponentTestContext; import com.darrenbishop.support.flexunit.parsley.ParsleyRunner;ParsleyRunner; import org.flexunit.asserts.fail; import flash.events.ErrorEvent; import org.flexunit.assertThat; import org.hamcrest.object.equalTo; import com.darrenbishop.support.create; import com.darrenbishop.crm.directory.domain.Person; import com.darrenbishop.support.flexunit.async.AsyncHelper; import org.spicefactory.parsley.core.context.Context; import flash.events.EventDispatcher; import org.spicefactory.parsley.core.messaging.MessageProcessor; [RunWith('com.darrenbishop.support.flexunit.parsley.ParsleyRunner')] [Context('com.darrenbishop.crm.directory.GetPersonCommandComponentTestContext')] public class GetPersonCommandParsleyRunnerComponentTest extends AsyncHelper { [MessageDispatcher] public var dispatcher:Function; [Inject] public var context:Context; [Inject] public var service:StubDirectoryService; [Inject] public var command:GetPersonCommand; public var dodgyPerson:Person; public var soundPerson:Person; private var eventDispatcher:EventDispatcher; [Before] public function primeService():void { eventDispatcher = new EventDispatcher(); dodgyPerson = create(Person, {'id': 1, 'firstname': 'John001', 'lastname': 'Smith001', 'phone': 6803225}); soundPerson = create(Person, {'id': 2, 'firstname': 'John002', 'lastname': 'Smith002', 'phone': 6809168}); service.add(dodgyPerson, soundPerson); service.dodgyPerson = dodgyPerson.id; service.soundPerson = soundPerson.id; } [MessageError(type='com.darrenbishop.crm.directory.application.PersonEvent')] public function rethrowIt(processor:MessageProcessor, error:Error):void { eventDispatcher.dispatchEvent(toEvent(error)); } [MessageHandler(selector='PersonEvent.found')] public function passItOn(event:PersonEvent):void { eventDispatcher.dispatchEvent(event); } [Test(async,description='Test GetPersonCommand result-handler does not mangle the person found.')] public function resultDoesNotManglePersonFound():void { dispatcher(PersonEvent.newLookup(soundPerson.id)); waitFor(eventDispatcher, PersonEvent.FOUND, 1000); thenAssert(function(event:PersonEvent, data:*):void { assertThat(event.type, equalTo('PersonEvent.found')); assertThat(event.person.fullname, equalTo('John002 Smith002')); }); } [Test(async,description='Test GetPersonCommand result-handler verifies the id of the person found.')] public function resultVerifiesPersonFound():void { dispatcher(PersonEvent.newLookup(dodgyPerson.id)); waitFor(eventDispatcher, ErrorEvent.ERROR, 1000); thenAssert(function(event:ErrorEvent, data:*):void { assertThat(event.text, equalTo(sprintf('Found person (%d) does not match lookup person (%d)', soundPerson.id, dodgyPerson.id))); }); } } }I declare the
ParsleyRunner
class as the default attribute to the [RunWith]
metadata tag; I import and reference the same (at the top of the excerpt) to ensure the runner is linked in. This highlights one of the fundamental problems with this approach: you must repeat you intention too many times - once should be enough.
The same goes for the GetPersonCommandComponentTestContext
; the runner needs to be told somehow which Parsley context to initialize. Perhaps there are better ways to communicate to the runner which context to use, with static members maybe, but the problem persists for the runner itself so there's little value in exploring such an enhancement.
The only other significant change is the [Before]
annotated method; this method is no-longer responsible for building the Parsley context so I renamed it from initializeContext()
to primeService()
, as that's all it now does; it primes the StubDirectoryService
with people and the id to trigger the error case.
Introducing the the ParsleyRunner
...
package com.darrenbishop.support.flexunit.parsley { import flash.utils.getDefinitionByName; import flash.utils.getQualifiedClassName; import flex.lang.reflect.metadata.MetaDataAnnotation; import org.flexunit.internals.runners.statements.IAsyncStatement; import org.flexunit.internals.runners.statements.StatementSequencer; import org.flexunit.runner.notification.IRunNotifier; import org.flexunit.runners.BlockFlexUnit4ClassRunner; import org.flexunit.runners.model.FrameworkMethod; public class ParsleyRunner extends BlockFlexUnit4ClassRunner { public static const CONTEXT_METADATA_NAME:String = 'Context'; private var contextDescriptor:Class; public function ParsleyRunner(cls:Class) { super(cls); var ctxMetaData:MetaDataAnnotation = testClass.klassInfo.getMetaData(CONTEXT_METADATA_NAME); this.contextDescriptor = classFor(ctxMetaData.defaultArgument.key) } private function classFor(name:String):Class { return Class(getDefinitionByName(getQualifiedClassName(name))); } private function withParsleyContextBuilt(test:Object):IAsyncStatement { return new BuildParsleyContext(test, contextDescriptor); } protected override function withBefores(method:FrameworkMethod, test:Object, statement:IAsyncStatement):IAsyncStatement { if (contextDescriptor) { var sequencer:StatementSequencer = new StatementSequencer(); sequencer.addStep(withParsleyContextBuilt(test)); sequencer.addStep(super.withBefores(method, test, statement)); return sequencer; } else { return super.withBefores(method, test, statement); } } } }I determine the class that describes the Parsley context for this test in the constructor, by retrieving the default attribute value of the
[Context]
metadata tag; this I consider to be a one-off class-level operation. The withBefores(...)
template method is invoked before each test method, to contribute to the preparation of the environment in which the test method will execute; this method delegates to withParsleyContextBuilt(...)
to generate a BuildParsleyContext
command-object. BuildParsleyContext
is not a Parsley command, as in the one being tested - it's just the pattern/convention used in the FlexUnit internals.
BuildParsleyContext
, the heavy lifter
This command-object does all the work using the Parsley ActionScript3 API for context initialization:
package com.darrenbishop.support.flexunit.parsley { import flash.utils.getDefinitionByName; import org.flexunit.internals.runners.statements.AsyncStatementBase; import org.flexunit.internals.runners.statements.IAsyncStatement; import org.flexunit.token.AsyncTestToken; import org.spicefactory.parsley.core.context.Context; import org.spicefactory.parsley.flex.FlexContextBuilder; public class BuildParsleyContext extends AsyncStatementBase implements IAsyncStatement { protected var test:Object; protected var contextDescriptor:Class; public function BuildParsleyContext(target:Object, contextDescriptor:Class) { this.test = target; this.contextDescriptor = contextDescriptor; } public function evaluate(parentToken:AsyncTestToken):void { buildContext(parentToken); } private function buildContext(parentToken:AsyncTestToken):void { var error:Error = null; try { var ctx:Context = FlexContextBuilder.build(contextDescriptor); ctx.createDynamicContext().addObject(test); } catch (e:Error) { error = e; } parentToken.sendResult(error); } } }Basically, what once lived and would have been replicated in the
[Before]
methods of each and every Parsley-powered test, now lives here; but again, there's nothing new, just a relocation of code to somewhere more appropriate for re-use.
And that's it for the [RunWith]
approach, but now with the relase of FlexUnit-4.1, we have...
The [Rule]
approach
This hook mechanism completely solves the problem of having to declare the runner implementation class as a string in a metadata attribute:
package com.darrenbishop.crm.directory.application { import com.darrenbishop.crm.directory.GetPersonCommandComponentTestContext; import com.darrenbishop.crm.directory.domain.Person; import com.darrenbishop.support.create; import com.darrenbishop.support.flexunit.async.AsyncHelper; import com.darrenbishop.support.flexunit.parsley.ParsleyRule; import flash.events.ErrorEvent; import flash.events.EventDispatcher; import org.flexunit.assertThat; import org.hamcrest.object.equalTo; import org.spicefactory.parsley.core.messaging.MessageProcessor; public class GetPersonCommandParsleyRuleComponentTest extends AsyncHelper { [Rule] public var rule:ParsleyRule = new ParsleyRule(GetPersonCommandComponentTestContext); [MessageDispatcher] public var dispatcher:Function; [Inject] public var service:StubDirectoryService; [Inject] public var command:GetPersonCommand; public var dodgyPerson:Person; public var soundPerson:Person; private var eventDispatcher:EventDispatcher; [Before] public function primeService():void { eventDispatcher = new EventDispatcher(); dodgyPerson = create(Person, {'id': 1, 'firstname': 'John001', 'lastname': 'Smith001', 'phone': 6803225}); soundPerson = create(Person, {'id': 2, 'firstname': 'John002', 'lastname': 'Smith002', 'phone': 6809168}); service.add(dodgyPerson, soundPerson); service.dodgyPerson = dodgyPerson.id; service.soundPerson = soundPerson.id; } [MessageError(type='com.darrenbishop.crm.directory.application.PersonEvent')] public function rethrowIt(processor:MessageProcessor, error:Error):void { eventDispatcher.dispatchEvent(toEvent(error)); } [MessageHandler(selector='PersonEvent.found')] public function passItOn(event:PersonEvent):void { eventDispatcher.dispatchEvent(event); } [Test(async,description='Test GetPersonCommand result-handler does not mangle the person found.')] public function resultDoesNotManglePersonFound():void { dispatcher(PersonEvent.newLookup(soundPerson.id)); waitFor(eventDispatcher, PersonEvent.FOUND, 1000); thenAssert(function(event:PersonEvent, data:*):void { assertThat(event.type, equalTo('PersonEvent.found')); assertThat(event.person.fullname, equalTo('John002 Smith002')); }); } [Test(async,description='Test GetPersonCommand result-handler verifies the id of the person found.')] public function resultVerifiesPersonFound():void { dispatcher(PersonEvent.newLookup(dodgyPerson.id)); waitFor(eventDispatcher, ErrorEvent.ERROR, 1000); thenAssert(function(event:ErrorEvent, data:*):void { assertThat(event.text, equalTo(sprintf('Found person (%d) does not match lookup person (%d)', soundPerson.id, dodgyPerson.id))); }); } } }The shift can be simply thought of as declaring the runner implementation as a member variable i.e.
rule
and annotating it with the [Rule]
metadata tag, rather than as a string metadata attribute. Of course, now it's no longer a runner, but a rule and extends a different parent class and implements slightly different template methods... but for the most part it's the same.
The ParsleyRule
to rule it all
As mentioned there are few differences, but one welcome difference, now that the rule is declared and initialized as an instance variable, is that in doing so the constructor is called and any arbitrary arguments can be passed in. I use this to communicate which Parsley context to initialize, by passing in the class that defines it.
package com.darrenbishop.support.flexunit.parsley { import org.flexunit.internals.runners.statements.IAsyncStatement; import org.flexunit.internals.runners.statements.MethodRuleBase; import org.flexunit.internals.runners.statements.StatementSequencer; import org.flexunit.rules.IMethodRule; import org.flexunit.runners.model.FrameworkMethod; import org.flexunit.token.AsyncTestToken; public class ParsleyRule extends MethodRuleBase implements IMethodRule { private var contextDescriptor:Class; public function ParsleyRule(contextDescriptor:Class) { super(); this.contextDescriptor = contextDescriptor; } override public function apply(base:IAsyncStatement, method:FrameworkMethod, test:Object):IAsyncStatement { var sequencer:StatementSequencer = new StatementSequencer(); sequencer.addStep(withParsleyContextBuilt(test)); sequencer.addStep(base); return super.apply(sequencer, method, test); } private function withParsleyContextBuilt(test:Object):IAsyncStatement { return new BuildParsleyContext(test, contextDescriptor); } override public function evaluate(parentToken:AsyncTestToken):void { super.evaluate(parentToken); proceedToNextStatement(); } } }The
[Rule]
approach has the benefit that it does not exhaust the use of inheritance, nor does it use [RunWith]
, both of which are use-once mechanisms.
There is now the benefit that multiple rules can be defined and used in conjunction with each other; this removes the impediments to mocking described in Part-1
I have actually succeeded in mocking a DirectoryService
instance and embedding that instance into the Parsley context ready for dependency-injection, but that's another blog :-)
What's Next...
Part 5: Testing with a Parsley-Aware DSL
The final (planned) part of this blog series will demonstrate theParsleyHelper
base class. ParsleyHelper
extends from AsyncHelper
, introduced in Part-3, to add a layer of abstraction for all the Parsley bits and pieces; this improved DSL implementation allows for the removal of all reference to anything-Parsley
... except for the ParsleyRule
... and the ParsleyHelper
.
6 comments:
Really smooth. Looking forward to see more articles from you based on parsley. Thanks a lot for sharing.
You're welcome. I do have the code for the final part ready just need to find the time to write the commentary.
I'll post again
Hi,
I have tried to use part of your code but I'm still getting some error when I use waitFor and thenAssert methods. Maybe something is missing bcs it tells me that I'm trying to call a possibly undefined function waitFor and thenAssert. Can you help me, please, what am I doing wrong? Thanks
Hey, I'll review the code this evening.
I posted the final part last night and had all tests passing in FB. I did make some changes to the code, which I need to apply to the earlier posts, but saw nothing like you described.
I'll get back to you asap.
Hey Michal, whatever part you are using, are you sure you are extending AsyncHelper or copied in the method???
Thanks a lot :) I didn't extended it.. It's clear for me now. But only theoretically ... I don't know how to do it practically. I've posted some comment with my problem at other article...
Post a Comment