Let's go test first...we wish to create a component which satisfies the following requirement:
HelloComponent will display an HTML element
with id 'main' which will contain the text 'hello'.
First we write a test...the SeasideTesting framework
contains an abstract subclass of TestCase called
STTestCase. It adds browser-like actions (follow
link, submit form, back, refresh), searching (find a HTML element
satisfying some criteria) and support for configuring and
accessing the component being tested in the Seaside server. Here
is the class definition for our first test case:
Smalltalk defineClass: #HelloComponentTest
superclass: #{Seaside.STTestCase}
indexedType: #none
private: false
instanceVariableNames: ''
classInstanceVariableNames: ''
imports: ''
category: ''
and a method which checks that our requirement is satisfied:
HelloComponentTest>>testMainElementContainsHello self newApplicationWithRootClass: HelloComponent. self establishSession. self assert: (self lastResponse stringWithId: 'main') = 'hello'
Line 2 of testMainElementContainsHello creats a
new Seaside application entry point whose root is
HelloComponent. We haven't created this class yet so
you may have to tell your Smalltalk IDE to "leave it
undeclared."
Line 3 establishes a Seaside session with the application by
requesting the root page. The
method establishSession answers an instance
of STSeasideResponse. This response can be later
retrieved using self lastResponse.
Finally line 4 uses the stringWithId:
convenience method to find the HTML element whose ID is
"main" and pull out its string contents, asserting that
this string is 'hello'.
At this point you should run this test case in a standard SUnit TestRunner. The test should fail, of course, since we haven't created the component yet.
Now let's implement a component to pass this test
Smalltalk defineClass: #HelloComponent
superclass: #{Seaside.WAComponent}
indexedType: #none
private: false
instanceVariableNames: ''
classInstanceVariableNames: ''
imports: ''
category: ''
renderContentOn: html
html span
id: 'main';
with: 'hello'
Run the HelloComponentTest again to verify that it
passes. You may want to add a your own entry point with
this component as the root so you can view the component in your
browser. This is not a requirement
of SeasideTesting...there is no need for each of your
components to have an entry point. When I'm developing tests I
like to avoid thinking of Seaside components as visual entities,
and focus on functional requirements for as long as possible so
normally I avoid creating this entry point until I really need to
look at the component in a browser.
HelloComponent will display an anchor labeled
with 'Leaving' which, when clicked, will display a new page
containing the word 'Goodbye'.
HelloComponentTest>>testClickingLeavingAnchorShowsGoodbye self newApplicationWithRootClass: HelloComponent. self establishSession. (self lastResponse anchorWithLabel: 'Leaving') click. self assert: (self lastResponse containsString: 'Goodbye')
Notice the duplication between this and the previous test
method. In a later section we'll pull out these duplicate lines
and put them in the standard SUnit setUp method. On
line 4 we see how to handle clicking on an anchor. We ask the
last response for the anchor with the specified label and we send
the resulting object the click message. In response
to click, SeasideTesting will simulate the
browser's request to the application and the response
(a STSeasideResponse) is available by
sending self lastResponse. Finally we test that this
response contains the required string using
the containsString: convenience method.
Run this test and observe that it fails (as expected!) with an error indicating that the requested anchor doesn't exist.
Let's modify our component to satisfy this requirement.
Replace the original HelloComponent>>renderContentOn:
with the following:
HelloComponent>>renderContentOn: html
html span
id: 'main';
with: 'hello'.
html anchor
callback: [self inform: 'Goodbye'];
with: 'Leaving'
Run your tests (this one and the previous one) to verify that they both pass.
The method STSeasideResponse>>anchorWithLabel: is
one of many search methods in
the SeasideTesting. These methods traverse the DOM and
return one or more, depending on the method, XHTML elements which
we call entities in SeasideTesting. These are
concrete STSeasideEntity instances. The actual type
of entity depends on the type of the XHTML element (anchor
elements produce STSeasideAnchor instances, for
example). We will see a few other ways to find elements in the
DOM so don't be surprised (or disgusted) as we incrementally
reveal these methods. In Finding XHTML
elements we will go over many of the commonly used search
methods.
In this section we will take the test-first approach to creating a simple editor for concert information. Since we are only interested in testing the user interface, the domain model is just given here:
Smalltalk defineClass: #Concert
superclass: #{Core.Object}
indexedType: #none
private: false
instanceVariableNames: 'title description date time location artist free '
classInstanceVariableNames: ''
imports: ''
category: ''
artist
^ artist
artist: anObject
artist := anObject
date
^ date
date: anObject
date := anObject
description
^ description
description: anObject
description := anObject
location
^ location
location: anObject
location := anObject
time
^ time
time: anObject
time := anObject
title
^ title
title: anObject
title := anObject
makeFree
free := true
makePaymentRequired
free := false
isFree
^free ifNil: [false]
Now, our test case:
Smalltalk defineClass: #ConcertEditorTest
superclass: #{Seaside.STTestCase}
indexedType: #none
private: false
instanceVariableNames: ''
classInstanceVariableNames: ''
imports: ''
category: ''
All of our test methods will be testing the same kind of component so
it makes sense to override the SUnit
TestCase>>setUp method to register the
application and establish a session:
ConcertEditorTest>>setUp self newApplicationWithRootClass: ConcertEditor. self establishSession
Note, we haven't defined the class ConcertEditor yet
so you may have to fight with your Smalltalk IDE to force it to
accept the definition of this method.
We will begin with requirements that help us illustrate manipulating submit button elements in SeasideTesting.
ConcertEditorTest>>testHasSaveAndCancelButtons
self assert:
(self lastResponse buttonWithValue: 'Save') notNil.
self assert:
(self lastResponse buttonWithValue: 'Cancel') notNil.
Note for XHTML buttons the text displayed on the button is
called its "value", so the method buttonWithValue:
searches the DOM for a single XHTML INPUT tag with
the specified value. The notNil check is useless
since buttonWithValue: would signal an error if the
button didn't exist but I feel safer leaving it in.
Verify that this test fails (we haven't even created the component yet!) and then implement the code to satisfy this requirement:
Smalltalk defineClass: #ConcertEditor
superclass: #{Seaside.WAComponent}
indexedType: #none
private: false
instanceVariableNames: ''
classInstanceVariableNames: ''
imports: ''
category: ''
renderContentOn: html
html submitButton value: 'Cancel'.
html submitButton value: 'Save'
ConcertEditorTest>>testCancelAnswersNil (self lastResponse buttonWithValue: 'Cancel') click. self assert: self answer isNil
Just as with anchors, sending click to a submit
button causes SeasideTesting to simulate the browser's
response to a user's click on this button. The method
STTestCase>>answer signals an error if the component
hasn't answered, otherwise it returns the component's answer. Run
this test and observe the failure message "form cannot be
nil." SeasideTesting is telling us that it can only
click on a button if it is in a form. We need to put our buttons
inside a form and make the "Cancel" button answer nil:
renderContentOn: html html form with: [html submitButton callback: [self answer: nil]; value: 'Cancel'. html submitButton value: 'Save']
Now run the test, it should pass. Finally we need to have some
requirements about how the editor modifies a Concert
instance. Rather than writing detailed requirements, for the sake
of this tutorial we just give a rather vague one:
Concert instance (hereafter
referred to as "the Concert").ConcertEditorTest>>testSaveAnswersConcert (self lastResponse buttonWithValue: 'Save') click. self assert: self answer notNil. self assert: (self answer isKindOf: Concert)We've given a loose interpretation of the word "edited" here. Later requirements will make this more explicit. Run this test...it should fail with a message "component did not answer." To pass this test we modify the
ConcertEditor to have
a concert i-var, to initialize it properly and to
answer it when "Save" is clicked. Here is
the entire ConcertEditor class:
Smalltalk defineClass: #ConcertEditor
superclass: #{Seaside.WAComponent}
indexedType: #none
private: false
instanceVariableNames: 'concert '
classInstanceVariableNames: ''
imports: ''
category: ''
initialize
super initialize.
concert := Concert new
renderContentOn: html
html form with:
[html submitButton
callback: [self answer: nil];
value: 'Cancel'.
html submitButton
callback: [self answer: concert];
value: 'Save']
You should run your test cases at this point to verify that this
component conforms to the requirements.
Concert's artist and description instance
variables.ConcertEditorTest>>testArtistAndDescription | resp | resp := self lastResponse. (resp entityWithId: 'artist') value: 'Bob Dylan'. (resp entityWithId: 'description') value: 'Folk/rock concert'. (resp buttonWithValue: 'Save') click. self assert: self answer artist = 'Bob Dylan'. self assert: self answer description = 'Folk/rock concert'
Here we retrieve the text input elements from the form using
STSeasideResponse>>entityWithId:. This method
searches the DOM for a single XHTML element with the specified id.
As these are XHTML input elements, SeasideTesting will
answer instances of
STSeasideTextEntity. We then specify a new value for
these inputs using the STHtmlInputComponent>>value:
with this new one). Clicking the Save button should
submit the page and update the Concert instance which
we verify. Now the component changes:
ConcertEditor>>renderContentOn: html html form with: [html textInput id: 'artist'; on: #artist of: concert. html textInput id: 'description'; on: #description of: concert. html submitButton callback: [self answer: nil]; value: 'Cancel'. html submitButton callback: [self answer: concert]; value: 'Save']
If you run the tests now they should all pass. At this point you might feel like looking at the component in a web browser. I discourage you from doing this since it is easy to become distracted into fiddling with the appearance of the component when we're supposed to be focused on the functionality. Still, if you insist, you can do one or both of the following:
ConcertEditor registerAsApplication:
'concertEditor' and then view it directly in your web
browser.Let's continue with requirements that illustrate testing other types of XHTML components.
Concert answered by pressing the "Save"
button will answer the selected city, as a string, in response
to location.
ConcertEditorTest>>testLocation
| selectInput labels |
selectInput := self lastResponse entityWithId: 'location'.
self assert: selectInput options size = 3.
labels := selectInput options collect: [:each | each label].
self
assert:
(#('New York' 'London' 'Paris') allSatisfy: [:city | labels includes: city]).
selectInput value: 'London'.
(self lastResponse buttonWithValue: 'Save') click.
self assert: self answer location = 'London'
We find the entity with CSS id 'location', which we expect is
an XHTML select input. In SeasideTesting these are
represented by instances of STSelectInput. Working
with XHTML select inputs is fairly straightforward. We can query
them for a list of options using the
STSelectInput>>options method
which answers a collection of STSelectOption instances.
Each option knows its text contents which can be obtained by
STSelectOption>>label. We test that all of the
expected labels are included as options. We use
STSelectInput>>value: to select the "London" option,
and subsequently submit the form, verifying that
the Concert object answered when Save was
pressed was updated correctly.
We now update the ConcertEditor>>renderContentOn:
method to display the appropriate XHTML select input:
ConcertEditor>>renderContentOn: html
html form with:
[html textInput
id: 'artist';
on: #artist of: concert.
html textInput
id: 'description';
on: #description of: concert.
html select
id: 'location';
on: #location of: concert;
list: #('New York' 'London' 'Paris').
html submitButton
callback: [self answer: nil];
value: 'Cancel'.
html submitButton
callback: [self answer: concert];
value: 'Save']
As always, run your tests to make sure they all pass. Some notes on select inputs:
SCHtmlInputComponent>>value:), the specified value must
correspond to the text for one of the options or an error will be
signaled.SCHtmlInputComponent>>value: method
specifies which option to select. Readers familiar with
XHTML will note that this may be different than the
option's value attribute. The value attribute is under
Seaside control and should be of no consequence to component
developers so it made more sense to give these semantics to the
SCHtmlInputComponent>>value: message.As a final example of working with form inputs we test the admission
fee aspect of ConcertEditor.
Concert
instance based on the users input. The checkbox will default to
unchecked.ConcertEditorTest>>testFree | checkbox | checkbox := self lastResponse entityWithId: 'free'. self deny: checkbox value. "default to false" checkbox value: true. (self lastResponse buttonWithValue: 'Save') click. self assert: self answer isFree ConcertEditorTest>>testRequiresPayment | checkbox | checkbox := self lastResponse entityWithId: 'free'. checkbox value: false. "doesn't really change value" (self lastResponse buttonWithValue: 'Save') click. self deny: self answer isFreeNow we implement a version of
ConcertEditor>>renderContentOn: to pass this test:
ConcertEditor>>renderContentOn: html
html form with:
[html text: 'Artist:'.
html textInput id: 'artist'; on: #artist of: concert.
html text: 'Description:'.
html textInput id: 'description'; on: #description of: concert.
html text: 'Location:'.
html select
id: 'location';
on: #location of: concert;
list: #('New York' 'London' 'Paris').
html checkbox
id: 'free';
onTrue: [concert makeFree] onFalse: [concert makePaymentRequired].
html submitButton callback: [self answer: concert]; value: 'Save'.
html submitButton callback: [self answer: nil]; value: 'Cancel']
SeasideTesting can simulate the user's use of the back
button. For the purposes of this discussion, rather than creating
our own component, let's just write a test case for
WACounter (VW7.7 users will need to load
the Seaside-Examples parcel). If you've never used that
component, create an application for it now and play with it a
bit. We start by testing that clicking the "++" link
increments the counter and clicking the "--" link
decrements it (nothing to do with the back button yet).
Smalltalk defineClass: #WACounterTest
superclass: #{Seaside.STTestCase}
indexedType: #none
private: false
instanceVariableNames: ''
classInstanceVariableNames: ''
imports: ''
category: ''
testIncrement
self newApplicationWithRootClass: Seaside.WACounter.
self establishSession.
(self lastResponse anchorWithLabel: '++') click.
self assert: self component count = 1.
(self lastResponse anchorWithLabel: '++') click.
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 counter as well (look
at WACounter>>states to see why):
testBack self newApplicationWithRootClass: Seaside.WACounter. self establishSession. (self lastResponse anchorWithLabel: '++') click. (self lastResponse anchorWithLabel: '++') click. self assert: self component count = 2. self back. (self lastResponse anchorWithLabel: '++') click. self assert: self component count = 2
The back method simply returns the
STSeasideResponse 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!
This section is less tutorial-oriented than the rest of this document. I won't provide many examples, I just want to discuss how to pick apart the DOM since finding the XHTML element you're querying is often half the battle.
To begin, open a class browser on the class hierarchy rooted
at STSeasideEntity. All XHTML elements and the
response object (the lastResponse) are descendants of
this class. Among other things STSeasideEntity
contains our search methods. This makes it possible to restrict
searches to being within elements. For example, suppose I have a
component that produces the following structure:
<div class="common">
NO
</div>
<div id="unique">
<div class="common">
YES
</div>
<div class="common">
YES AGAIN
</div>
</div>
Suppose that I want to find the occurrences of elements with class common, but only the ones inside the DIV with id unique. The following code would do the trick:
|uniqueDiv innerCommons| uniqueDiv := self lastResponse entityWithId: 'unique'. innerCommons := uniqueDiv entitiesWithClass: 'common'.
Notice that, on the last line, we
queried uniqueDiv rather than the entire
document.
The search-* method categories
of STSeasideEntity contain search methods. It should
be clear from the method name whether or not the method returns a
single or multiple elements. If the method returns a single
element, and you aren't using the ifAbsent: argument,
failure to find the element will signal an error (not
answer nil!). Also, if an search method is supposed
to return a single element and there are multiple elements that
meet the criteria, it will signal an error. Here are some
commonly-used search methods:
| Type of search | Method |
|---|---|
| All children | children |
| All descendants | allEntities |
| Elements with tag | entitiesNamed: |
| Elements with class | entitiesWithClass: |
| Element with ID | entityWithId:, entityWithId:ifAbsent: |
In addition to these methods, there are type-specific search
methods. These methods behave as above but, in addition, ensure
that the XHTML element being produced is the correct kind. So,
for example, anchorWithId: would signal an error if
the XHTML element with the specified ID was not an anchor. These
methods have been added to SeasideTesting on an as-needed
basis. I don't plan to continue to grow this protocol. Here are
some commonly-used type-specific search methods:
| Type of search | Method |
|---|---|
| All anchors | anchors |
| Anchors which include specified text inside it | anchorsIncludingString: |
| Anchors which have exactly specified string inside them | anchorsWithLabel: |
| Unique anchor with exactly specified string inside it | anchorWithLabel: |
| All buttons | buttons |
| Button with specified label | buttonWithValue: |
| All input components | inputs |
| Labels with given text | labelsWithText: |
Let me start by saying that everything you've learned so far will be useful in external-browser based test cases. Still, understanding some of the differences between the simulated-browser and external-browser frameworks is helpful to prevent you from running into subtle problems.
In simulated-browser test cases SeasideTesting makes HTTP requests to the Seaside server whenever you tell it to click on an anchor or a button. SeasideTesting reads the server's response and therefore always has an up-to-date view of the HTML being "displayed" on the web page.
In external-browser test cases SeasideTesting asks an external web browser (Firefox) to simulate user gestures like clicking, typing etc. In response to these gestures, the external browser may make requests to Seaside and/or modify the DOM by the execution of Javascript code. If SeasideTesting wants to know the XHTML being displayed by the external browser, it asks the external browser to send it. I refer to this as a "page fetch" and it is triggered automatically by some events but there are times when it must be triggered manually.
Even in external-browser test cases, all of the test code runs in the Smalltalk image. For example, rather than asking Firefox "does the page contain the word frog?" we simply ask Firefox for a copy of the page and use our usual Smalltalk tools to examine its contents. This sets SeasideTesting apart, for better or worse, from many of the in-browser testing tools (Albatros, Selenium, and watir). This means that the full suite of SUnit tools used by simulated-browser tests are available to external-browser tests. The chief drawback of this is that SeasideTesting must periodically refresh its model of the page being shown in the browser via a page fetch.
When using external-browser tests, please keep in mind that the browser could be executing Javascript which may continue to change the contents of the page, even after it is fetched. This is especially important for asynchronous requests which I will discuss in detail later.
Just to make sure the external browser support is working, run
the tests in the
package SeasideTesting-Tests-ExtBrowserFunctional. As
of this time there is one test that fails under Seaside 2.8
with an error (STExtDialogTest>>testAlert)
representing work in progress. There are 6 failures/errors in
Seaside 3.0. The additional 5 errors are due to a bug in
Seaside's error handling. If the web browser doesn't open and/or
other tests fail, there is no point in going forward...time to
look for help. If a browser other than Firefox opens you may need
to make Firefox your default browser. Under UNIX/Linux, at least,
you can specify a different browser with:
"Change /usr/bin/firefox as needed for your OS" UnixBrowserLaunchService currentExternalBrowser: '/usr/bin/firefox'
This might be useful if you prefer to not have Firefox be your default browser in your desktop environment. I assume something similar can be done in Windows.
SeasideTesting includes the STExtTestCase
class which performs the setup necessary to make a test case use
an external browser. Let's build a Javascript-enabled version of
our "hello component":
AjaxHelloComponent will display the word
"Hello" in an H1 tag.
AjaxHelloComponent will include a anchor
labeled "hide" which, when clicked, will hide the "Hello"
greeting by adding the CSS class "hidden" to the H1
tag from AHC 1. This action will occur without a request to the
server (it will be client-side Javascript only).
Notice that we describe how the greeting will be hidden (via a CSS tag) since it is not possible for SeasideTesting to ensure that the item is actually hidden from the user's view. Generally tests of the actual appearance must be performed by a human. (See Screenshots subsection below for ways in which SeasideTesting can help with this.) OK...now the tests:
Smalltalk defineClass: #AjaxHelloComponentTest
superclass: #{Seaside.STExtTestCase}
indexedType: #none
private: false
instanceVariableNames: ''
classInstanceVariableNames: ''
imports: ''
category: ''
setUp
| app |
super setUp.
app := self newApplicationWithRootClass: AjaxHelloComponent.
app addLibrary: Scriptaculous.SULibrary
"Note: for VW7.7/Seaside 3.0 the line above needs to be:
add
addLibrary: Scriptaculous.PTDeploymentLibrary;
addLibrary: Scriptaculous.SUDeploymentLibrary."
heading
"Answer the first H1 tag on the page"
^(self lastResponse entitiesNamed: 'h1') first
testShowsGreeting
self establishSession.
self assert: (self heading containsString: 'Hello').
self deny: (self heading cssClasses includes: 'hidden')
testHideAnchorHidesGreeting
self establishSession.
(self lastResponse anchorWithLabel: 'hide') click.
self assert: (self heading cssClasses includes: 'hidden')
As always, run the tests to see that they fail. These look
pretty much like normal SeasideTesting test cases. Since
the component will use Scriptaculous, we've had to
add SULibrary to the application entry point
that SeasideTesting created. Another important point to
note is that the click message, in addition to
causing the external web browser to "click" on the anchor, also
caused it to send an updated version of the page
to SeasideTesting.
Now we need to write the component:
Smalltalk defineClass: #AjaxHelloComponent
superclass: #{Seaside.WAComponent}
indexedType: #none
private: false
instanceVariableNames: ''
classInstanceVariableNames: ''
imports: ''
category: ''
style
^'
.hidden {
display: none;
}
'
renderContentOn: html
html heading
level: 1;
id: #greeting;
with: 'Hello'.
html horizontalRule.
html anchor
onClick: (html scriptaculous element id: #greeting; addClassName: 'hidden');
with: 'hide'
Re-run the tests...they should pass.
Quite often components perform an AJAX request in response to events like a mouse click. These AJAX requests run asynchronously once they have been dispatched to the server. That is, the browser doesn't wait for a response before returning from the click event handler, for example. This means that SeasideTesting may ask the browser to send a copy of the page before the AJAX call has completed. Subsequent assertions about the contents of this page may lead to incorrect test results.
The solution provided by SeasideTesting is a simple
polling loop: poll the browser until some criteria is met. This
is implemented via
the STExtTestCase>>assertEventually: method. Let's
work through an example where this might be used.
AjaxHelloComponent will have an anchor labeled
"Change". When clicked this anchor will modify
the H1 tag to contain a greeting from the list
(cycling back to the beginning once the list is exhausted):
testAnotherAnchorChangesGreeting self establishSession. self assert: (self heading containsString: 'Hello'). (self lastResponse anchorWithLabel: 'Change') click. self assertEventually: [self heading contentString = 'G''day']. (self lastResponse anchorWithLabel: 'Change') click. self assertEventually: [self heading contentString = 'Salut']. (self lastResponse anchorWithLabel: 'Change') click. self assertEventually: [self heading contentString = 'Bonjour']. (self lastResponse anchorWithLabel: 'Change') click. self assertEventually: [self heading contentString = 'Hello'].
The assertEventually: call tests the condition
block (first argument), if this condition is false, it refetches
the page from the browser and repeats until either the default
timeout (500ms,
see STTestCase>>defaultJavascriptTimeout) is reached
or the condition block is satisfied.
Be careful:
assertEventually:.assertEventually: my code would break.
That can't happen here but is quite common in real
applications.With that out of the way, let's pass the tests. Here is
the complete source to AjaxHelloComponent
(note position i-var added):
Smalltalk defineClass: #AjaxHelloComponent
superclass: #{Seaside.WAComponent}
indexedType: #none
private: false
instanceVariableNames: 'position '
classInstanceVariableNames: ''
imports: ''
category: ''
initialize
super initialize.
position := 0.
greetings
^#('Hello' 'G''day' 'Salut' 'Bonjour') collect: [:each | each asString]
nextGreeting
position := position + 1 \\ self greetings size.
renderContentOn: html
html heading
level: 1;
id: #greeting;
with: [self renderGreetingOn: html].
html horizontalRule.
html anchor
onClick: (html scriptaculous element id: #greeting; addClassName: 'hidden');
with: 'hide'.
html anchor
onClick: (html updater id: #greeting; callback: [:r | self nextGreeting. self renderGreetingOn: r]);
with: 'Change'
renderGreetingOn: html
html text: (self greetings at: position + 1)
style
^'
.hidden {
display: none;
}
'
In renderContentOn: you can see that I used the
Scriptaculous updater to update the H1
element to contain the new greeting.
When an anchor or submit button causes a full-page update
(rather than an AJAX update), you probably want your test case to
wait until the new page is loaded before continuing. This can be
done using waitForPageToLoad. Here's an example:
AjaxHelloComponent will include an anchor
labeled "Goodbye". When clicked this anchor will show a new
page with the text "Bye" on it.
AjaxHelloComponentTests>>testGoodbyeAnchorWorks self establishSession. self deny: (self lastResponse containsString: 'Bye'). (self lastResponse anchorWithLabel: 'Goodbye') click. self waitForPageToLoad. self assert: (self lastResponse containsString: 'Bye').
and the rendering code to make it pass:
AjaxHelloComponent>>renderContentOn: html html heading level: 1; id: #greeting; with: [self renderGreetingOn: html]. html horizontalRule. html anchor onClick: (html scriptaculous element id: #greeting; addClassName: 'hidden'); with: 'hide'. html anchor onClick: (html updater id: #greeting; callback: [:r | self nextGreeting. self renderGreetingOn: r]); with: 'Change'. html anchor callback: [self inform: 'Bye']; with: 'Goodbye'
Keep in mind that waitForPageToLoad only works for
full page updates. You can't use it to wait for some javascript
update to complete, for example.
Javascript-enabled components can respond to a variety of
events. Some of these events can be generated
by SeasideTesting. The primitive event generation
methods are in the event generation method category
of STSeasideEntity. These methods cause the browser
to invoke the corresponding javascript event on the specified
element (the receiver). This is done using
the jQuery library's event
generation support. In the future I would like to make it
possible to not only generate these events but also specify the
various parameters of the javascript event objects so that one
could, for example, simulate drag-and-drop operations. Here is an
example of testing if the example text on a text input disappears
when that text input gets focus (this code was pulled from the
integration tests for SeasideTesting):
Smalltalk.Seaside defineClass: #ExampleTextTest
superclass: #{Seaside.STExtTestCase}
indexedType: #none
private: false
instanceVariableNames: ''
classInstanceVariableNames: ''
imports: ''
category: ''
setUp
|app|
super setUp.
app := self newApplicationWithRootClass: ExampleTextComponent.
app addLibrary: Scriptaculous.SULibrary.
self establishSession
testExampleTextDisappearsOnFocus
self assertEventually: [(self lastResponse entityWithId: #exampleTextInput) value = 'example text'].
(self lastResponse entityWithId: #exampleTextInput) doFocus.
(self lastResponse entityWithId: #exampleTextInput) doBlur.
self
assertEventually: [(self lastResponse entityWithId: #exampleTextInput) value = '']
failInMS: 2000
a the component:
Smalltalk defineClass: #ExampleTextComponent
superclass: #{Seaside.WAComponent}
indexedType: #none
private: false
instanceVariableNames: ''
classInstanceVariableNames: ''
imports: ''
category: ''
renderContentOn: html
html form:
[html textInput id: #exampleTextInput; exampleText: 'example text']
SeasideTesting also includes a higher-level API for
event generation, called "actions". These are used to simulate
user gestures and often involve one or more of the primitive
events as well as typically a page fetch after the event is
generated. These can be found in the actions method
category of STSeasideEntity. This is needed
because jQuery's event generation
doesn't always correspond to the expected user gesture. Here are
some details:
STSeasideEntity>>clickFor the typical XHTML element the click message is
simply a doClick followed by
a fetchPage. Unfortunately jQuery doesn't behave
properly when sending click to an anchor. In this
case SeasideTesting has a special javascript method for
simulating a click.
STSeasideEntity>>type:, typeBackspace, typeBackspaces:As with click events, after a string is "typed" the page is fetched. The jQuery key event generation code doesn't do what is needed by SeasideTesting so, again, I have provided separate javascript for generation of these events. This was taken and modified from the Selenium project (under the terms of the apache license). The details can be found in STExternalBrowserLibrary>>htmlUtilsJs. Since keycode generation is platform and browser dependent, there may be problems with simulating various international characters. Please let me know if you have problems (send complete details!) and I'll try to solve them. I would really like SeasideTesting to be as language/encoding friendly.
Most of SeasideTesting's functionality is encapsulated
in the class STWebAppTester which I refer to as "the
tester". The abstract STTestCase simply provides
convenience methods many of which delegate directly to the
tester. STWebAppTester is a subclass
of STRemoteAppTester and adds tools that are useful
when testing an application that resides in the same Smalltalk
image as the test case. The
superclass STRemoteAppTester is useful in its own
right as it can test Seaside applications that are running in
other images and/or on other machines. For example, one of my
end-to-end test cases looks roughly like this:
testDeployAndStartProvidesLoginPage (Deployer forServer: TestServer new) deploy: MyApplication new. self tester establishSession. self assert: (self tester lastResponse containsString: 'Login'). tester ^tester ifNil: [tester := STRemoteAppTester new. tester rootUrl: self testServerUrl] testServerUrl ^'http://some.test.server/'
This is just a sketch but it shows basically how one could test a "remote" application. I don't expect this to be generally useful except in writing full functional tests like the one above. Please keep in mind that SeasideTesting currently only supports testing remote Seaside applications (I use the Smalltalk platform's XML parser so, at the very least, the remote application needs to produce well-formed XHTML but there are probably other dependencies on Seaside-generated XHTML).
As a trivial example, just to demonstrate this functionality, consider testing the anchor labeled "Screenshots" on http://seaside.st. Clicking on this anchor should show a page whose heading is "Screenshots":
Smalltalk defineClass: #SeasideDotStTest
superclass: #{XProgramming.SUnit.TestCase}
indexedType: #none
private: false
instanceVariableNames: ''
classInstanceVariableNames: ''
imports: ''
category: ''
testScreenshotsAnchor
"Make sure that there is an anchor labeled 'Screenshots' and that clicking on it brings you to
a page with a heading element that contains 'Screenshots'"
| tester anchor headings |
tester := Seaside.STRemoteAppTester new.
tester rootUrl: 'http://seaside.st/'.
tester establishSession.
anchor := tester lastResponse anchorWithLabel: 'Screenshots'.
anchor click.
headings := tester lastResponse entitiesNamed: 'h1'.
self assert: (headings anySatisfy: [:heading | heading containsString: 'Screenshots'])
You can even put this section together with the last and write
tests of remote applications that run through an external web
browser. This just involves setting the browser
(STRemoteAppTester>>browser:). Look for senders of
this message for examples.
STWebAppTester (the
"tester"). If you prefer not to subclass STTestCase
you can use a "tester" in your own test heirarchy. For example,
we can write a test of the WACounter class that is
included with Seaside without subclassing STTestCase:
Smalltalk defineClass: #WACounterTest
superclass: #{XProgramming.SUnit.TestCase}
indexedType: #none
private: false
instanceVariableNames: ''
classInstanceVariableNames: ''
imports: ''
category: ''
testIncrement
| tester |
tester := Seaside.STWebAppTester new.
tester newApplicationWithRootClass: Seaside.WACounter path: 'WACounter_test'.
tester establishSession.
self assert: ((tester lastResponse entitiesNamed: 'h1') first containsString: '0').
(tester lastResponse anchorWithLabel: '++') click.
self assert: ((tester lastResponse entitiesNamed: 'h1') first containsString: '1').
Many components are designed to be created and initialized before being "called:" from Seaside. For example, suppose you have a view which you normally expect to use as follows:
showEditorFor: someObject | editorView | editorView := EditorView new. editorView objectToBeEdited: someObject. self call: editorView
The 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.
STTestCase>>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.
In Seaside, entry points to applications are instances
of WAApplication. These "applications" specify some
low-level Seaside configuration and permit users to add their own
configuration information. In SeasideTesting if you want
to use your own configuration classes, set values for some of the
configuration parameters, and/or add libraries to your component
under test simply do it after creating the application
(a WAApplication) with
the newApplication* methods that we have used
throughout this tutorial:
HelloComponentTest>>testComponentUsesMailer app := self newApplicationWithRootClass: SomeComponentClass. app configuration addAncestor: MyCustomConfiguration new. app preferenceAt: #sessionClass put: MyCustomSession. app preferenceAt: #mailerFactory put: FakeMailer. self establishSession. ...
establishSession just passes the message on to an
instance of STWebAppTester (the tester). If you wish
to have several simultaneous sessions in a test method, just
create your own tester instances and send them the appropriate
messages. I don't currently have an example.
STComponentError and records the original
exception as the cause. As an example, suppose I
have the following component (this code is from
the SeasideTesting's integration test suite):
Smalltalk.Seaside defineClass: #STErrorHandlingComponent
superclass: #{Seaside.WAComponent}
indexedType: #none
private: false
instanceVariableNames: 'renderingError '
classInstanceVariableNames: ''
imports: ''
category: ''
initialize
super initialize.
renderingError := false
haveRenderingError
renderingError := true
renderContentOn: html
html anchor callback: [self haveRenderingError]; id: #renderError; with: 'Have rendering error'.
html break.
renderingError ifTrue: [1 / 0].
html anchor callback: [(Array new: 0) at: 1]; id: #callbackError; with: 'Have callback error'.
html text: 'blah'
I can test that ZeroDivide gets raised via the
following test:
Smalltalk.Seaside defineClass: #STErrorHandlingTest
superclass: #{Seaside.STTestCase}
indexedType: #none
private: false
instanceVariableNames: ''
classInstanceVariableNames: ''
imports: ''
category: ''
setUp
super setUp.
self newApplicationWithRootClass: STErrorHandlingComponent.
self establishSession.
testRenderingErrorCause
| theException |
theException := nil.
[(self lastResponse anchorWithLabel: 'Have rendering error') click] on: STComponentError do: [:ex |
self assert: (ex cause isKindOf: ZeroDivide).
theException := ex].
self assert: theException notNil.
See STErrorHandlingTest for more examples.