The SeasideTesting framework contains an abstract
subclass of TestCase called
SCComponentTestCase. It adds browser-like actions
(follow link, submit form, back, refresh) and support for
configuring and accessing component being tested in the Seaside
server. We begin by creating a Seaside component which we wish to
test:
WAComponent subclass: #SCTestComponent1 instanceVariableNames: '' classVariableNames: '' poolDictionaries: '' category: 'SeasideTesting-Examples' renderContentOn: html html cssId: 'main'. html span: 'hello'We declare a new test case class:
SCComponentTestCase subclass: #SCSampleComponentTest instanceVariableNames: '' classVariableNames: '' poolDictionaries: '' category: 'SeasideTesting-Examples'Now we write a test that ensures that the content is generated properly (lines numbered for later reference, not part of code):
1) testComponent1 2) self newApplicationWithRootClass: SCTestComponent1. 3) self establishSession. 4) self assert: (self lastResponse stringTaggedWithId: 'main') = 'hello'
Line 2 creats a new Seaside application whose root is
SCTestComponent1. The path to this application will
be /seaside/SCTestComponent1 but the application will
be removed in tearDown -- more on that later.
Line 3 establishes a session with the application by requesting
a root page and subsequently following the redirect. The returned
result is an instance of SCSeasideResponse but
is ignored. The response can be later retrieved using "self
lastResponse".
Finally line 4 finds the span whose ID is
"main" and pulls out the body of that tag as a string.
It then asserts that the string is 'hello'. The
lastResponse method returns an instance of
SCSeasideResponse. It typically represents the most
recent response returned by the server to the browser (the
exception to this is when you use the back method to
move backward to a previous response). Explicitly testing the
text produced by a component usually makes for brittle code but
testing a small number of specific strings, like the account
balance displayed in an account summary view, seems to produce
useful tests.
At this point you should run this test case in a standard SUnit TestRunner (select TestRunner from Squeaks "open" menu, pick your test case and run it) to verify that everything is working.
WACounter. If you've never used that component,
create an application for it now and play with it a bit.
Basically we want to test to make sure that clicking the
"++" link increments the counter and clicking the
"--" link decrements it. We also want to test the back
button to make sure that it behaves properly.
We need to write a test case which emulates following an anchor
in a browser. We have the SCSeasideResponse
returned by establishSession. Using that there are
several ways to identify an anchor. The most common is by
assigning it an ID and looking it up with
anchorWithId:, but the one used in this example below
is to use the text of the anchor via
anchorWithLabel:. In our test case we just send
ourselves followAnchor: with the anchor we got from
the document and we'll get back a new
SCSeasideResponse:
SCComponentTestCase subclass: #WACounterTest instanceVariableNames: '' classVariableNames: '' poolDictionaries: '' category: 'SeasideTesting-Examples' testIncrement self newApplicationWithRootClass: WACounter. self establishSession. self followAnchor: (self lastResponse anchorWithLabel: '++'). self assert: self component count = 1. self followAnchor: (self lastResponse anchorWithLabel: '++'). self assert: self component count = 2
You can see that we can access the current component via the
component method. This method returns the root
component used to render the last request (although this component
may have a delegate which is rendering the current view -- more on
that later). Now, let's make sure that pressing the back button
works as desired. Backing up in the browser should back up the
state of the couter as well:
testBack self newApplicationWithRootClass: WACounter. self establishSession. self followAnchor: (self lastResponse anchorWithLabel: '++'). self followAnchor: (self lastResponse anchorWithLabel: '++'). self assert: self component count = 2. self back. self followAnchor: (self lastResponse anchorWithLabel: '++'). self assert: self component count = 2
The back method simply returns the
SCSeasideResponse which was retrieved by the request
before the last one. It also causes subsequent calls to
lastResponse to return the corresponding response.
It does not ask the server for a fresh view of that page. To get
a fresh view of the current page (simulating the browser refresh
button) simply send refresh in your test case.
Finally, since some browsers might not use a cache for the pages
in their back buffer, you can simulate going back and reloading
the current page by sending backAndRefresh which just
combines the two previously discussed methods.
To round out these tests you should add
testDecrement and testInitialState which
do just what their names imply. Be sure to run the tests!
Frankly, I'm not even sure if this is useful but I implemented
a simple web interface to run tests. Feedback on the usefulness
of this tool would be appreciated. I may drop if it no one uses
it. Here's how it works...if you follow the naming convention
that component tests have the same name as the component class but
with 'Test' appended then you'll see two effects: the halo for the
component will show a small rectangular button and if the
component is a root component for an application then the Seaside
configuration tool will show a "test" link for the
application. So, in the last section we named our test
WACounterTest so that the test link will show up for
WACounter. Clicking on this halo button or test link
will run the test suite for the component. It does not
recursively descend the visual presenters list looking for other
test suites, sorry...maybe in the next version. Keep in mind that
you're not testing that particular instance of the
component, you're just running the tests provided for that
component's class.
If your Seaside application's root component overrides the
class side suite method then it can return any test
suite you like. I don't know how this will play out in practice
but I'd expect you would do something like:
suite | s | s := TestSuite named: 'All shopping tests'. s addTest: MyComponentTest1 buildSuite. s addTest: MyComponentTest2 buildSuite. ^ sadding each test class relevant to the application. Running tests make take long enough that your browser will time out before the result it returned. If that's the case, pressing refresh will probably not work (it will just re-run the tests causing the timeout again). If there is interest in the web-based test runner, I will find a way to deal with this problem. Mozilla has a generous default timeout so I haven't had a problem yet.
Without going into too much detail, the reason I've done things
this way is due to the threaded nature of request processing in
Seaside. When our test cases submit a request, Seaside (actually
WAProcessMonitor) forks a new process and blocks our
test process waiting on a semaphore which will be signaled when
this new process is done with our request. If the new process
(the request processing process) raises a debugger, we can't
interact with it since our GUI process is blocked on the
semaphore. Anyway, I think that's what happens. My solution to
this was to fork the initial request during debugging but then I
don't want the test case to proceed until you're done
debugging...hence the "Browser waiting..." debugger.
formWithId: for a single
SCSeasideForm or forms which returns a
collection of SCSeasideForm's in the order in which
they were encountered. As with anchors, let's test one of
Seaside's simplest built-in components:
WAYesOrNoDialog.
SCComponentTestCase subclass: #WAYesOrNoDialogTest instanceVariableNames: '' classVariableNames: '' poolDictionaries: '' category: 'SeasideTesting-Examples' testYes | form | self newApplicationWithRootClass: WAYesOrNoDialog. form := self establishSession forms first. self submitForm: form pressingButton: (form buttonWithValue: 'Yes'). self assert: (self componentAnswered: true)So, we submitted a form by pressing the button labeled "Yes". We checked that the component answered the expected value (
componentAnswered: test to make sure
that the component produced and answer and that the answer was the
value specified in the argument). You should now write a test
method which tests the "No" button.
The form in the previous example had no input fields but that
certainly isn't typical of forms. The SCSeasideForm
class supports getting and setting values of input fields. The
most generic mechanism is to use the method
inputWithId: which returns an instance of a subclass
of SCHtmlInputComponent. These objects understand a
ValueModel-like protocol so you can send them value
and value: however they also provide more reasonable,
component-specific, protocols to adjust their values. I'll
clarify this in the first example.
Here we will write test for WAHtmlTest since it
displays almost all of the different kinds of HTML input
components. We can't use the stock version of this class since it
doesn't assign ID's to all of the inputs which makes testing them
difficult. The SeasideTesting package updates this
component so that it does assign ID's. Spend a view minutes
studying the code and behavior of this component. If you don't
understand what it is supposed to do, the tests might not make
sense.
testCheckbox | form | self newApplicationWithRootClass: WAHtmlTest. self establishSession. form _ self lastResponse formWithId: 'checkbox-form'. (form checkboxWithId: 'cb-a') check. self submitForm: form pressingButton: form buttons first. self assert: self component booleanList first value. form _ self lastResponse formWithId: 'checkbox-form'. (form checkboxWithId: 'cb-a') uncheck. self submitForm: form pressingButton: form buttons first. self deny: self component booleanList first valueNotice the use of
check and uncheck.
These could have been replaced with value: true and
value: false. Here's an example where the
value: version is less clear:
testSelect | form | self newApplicationWithRootClass: WAHtmlTest. self establishSession. form _ self lastResponse formWithId: 'select-form'. (form selectListWithId: 'select-list') selectOptionWithText: '7'. self submitForm: form pressingButton: form buttons first. self assert: self component number = 7. form _ self lastResponse formWithId: 'select-form'. self should: [(form selectListWithId: 'select-list') selectOptionWithText: '15'] raise: Error. (form selectListWithId: 'select-list') value: '2'. self submitForm: form pressingButton: form buttons first. self assert: self component number = 2
In the first two submissions I select the list option using
selectOptionWithText: which produces an error if the
option is not in the list. In the last submission I use
value: which actually just sends
selectOptionWithText: With radio buttons the value
submitted to Seaside is opaque to the developer (Seaside generates
a numerical value for each radio button). It would not make sense
to hard code that value into a test. Instead, we want to select
the value which corresponds to the option labeled with '2'.
Basically all of the value: methods are written this
way...they should not allow you to insert an arbitrary value into
an HTML element. If this seems confusing, just ignore it.
Finally, what's the difference between
selectListWithId: and inputWithId:? The
former method checks to make sure that the input component is
indeed an HTML select component while the latter would return any
HTML input component with the supplied ID. Using the more
specific version puts additional constraints on what you expect
the input component to look like. You'll find similar versions
for other components: textInputWithId:,
radioButtonWithId: etc. Look at the
inputs method category in SCSeasideForm
for more details.
showEditorFor: someObject | editorView | editorView := EditorView new. editorView objectToBeEdited: someObject. self call: editorViewThe problem which comes when testing
EditorView is
that the testing framework makes the class the root class for a
Seaside application but Seaside constructs an instance of the
class when a request is submitted from your test case. You are
not given a chance to initialize this instance. To get around
this problem SeasideTesting allows you to specify an
initialization block which will be evaluated after your component
has been created:
testSimpleEdit self newApplicationWithRooClass: EditorView initializeWith: [:view | view objectToBeEdited: SomeClass new]. self establishSession. ...As you can see, the initialization block is passed the component instance which was created by the Seaside server.
SCComponentTestCase>>frontMostComponent for
that purpose. Look at the sample code
SCSampleComponentTest>>testFrontMost for an example
of using this method. If you need to look deeper into a
components structure you can look at the source code of that
method as a first example. Also, keep in mind that this testing
framework works best when testing at the component level. If you
are using a component which is a composite of several other
components, you should write tests for the smaller components
first and then limit your tests of the larger component to
features which emphasize its API.
configureApplicationForComponent:. Here's
an example:
configureApplicationForComponent: aComponentClass super configureApplicationForComponent: aComponentClass. app configuration addAncestor: MyCustomConfiguration localConfiguration. app preferenceAt: #sessionClass put: MyCustomSession. FakeMailer reset. app preferenceAt: #mailerFactory put: FakeMailer
WARenderLoopMain as your main class. I had to hack
something together so that I could get the root component out of
my instance of the main class. This could easily be fixed by
looking up the session and getting to the instance of the main
class. If you need it, let me know...
establishSession, followAnchor: and
submitForm:pressingButton: just pass the message on
to an instance of SCBrowserSimulator. If you wish to
have several simultaneous sessions in a test method, just create
your own browser instances and send them the appropriate messages.
I don't currently have an example.
SCComponentTestCase>>tearDown. If you override
tearDown in your tests, be sure to send super
tearDown.
Typically my setUp methods look like this:
setUp self newApplicationWithRootClass: self componentClass. self establishSessionWhy didn't I make this part of
SCComponentTestCase?
I'm not really sure. I have several test classes which have
methods which test different components . If I were you, the
first thing I'd do is create your own subclass of
SCComponentTestCase and put this setUp method in it.