Testing Seaside Components

C. David Shaffer
Shaffer Consulting

A simple test

First, you should be familiar with the SUnit framework (and especially Stephane Ducasse's tutorial). I have added a small number of supporting classes to make testing Seaside components easier. You should load these classes into your Smalltalk image. The framework (and your test cases) must be in the same image as your Seaside server, we aren't testing remotely since we may want to ask questions about the state of the component which rendered the output.

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.

Following anchors

OK, what about a component which has anchors? Rather than creating our own component, let's just write a test case for 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!

Web test runner

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.
	^ s
adding 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.

Dealing with errors and failures

You'll notice some small differences when working with errors during component rendering and/or callback handling in the test runners (both the web and morphic versions). The most obvious thing you'll notice is the presence of two debuggers. One of them is clearly labeled "Browser waiting for response, proceed when you're done debugging." The other debugger is presenting your error as normally shown in SUnit. Proceed with your normal debugging in this second debugger. When you are done debugging your error and have either terminated or proceeded in this second debugger, press Proceed in the first debugger (the one displaying "Browser waiting..."). There may be subsequent debuggers presented since once you proceed your test case will continue to run even if you terminated Seaside's processing of the request (Seaside returns a non-XHTML document when your component doesn't render itself properly). When this happens, it is best to just terminate these later debuggers since they are likely to have nothing to do with errors in your code.

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.

Forms, input components and checking for an answer

Forms displayed in a component can be obtained from the response by sending 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 value
Notice 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.

Testing a specific instance

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.

Slightly more advanced subjects

Here I discuss some potential stumbling blocks you might hit as you progress away from the simple types of tests written above. I'm sure there are things that I didn't think of...I'll try to update this periodically in response to questions.

The front most component and delegation

Following delegation chains is a common exercise when testing components. Suppose, for example, that you want to ask a question about the front-most component rather than the root. I have provided 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.

Customizing the environment ("configuration") of your test application

If you want to use your own configuration classes and/or set values for some of the configuration parameters you should extend the method 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

Custom main class

Currently I don't have code to support having anything other than 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...

Testing error handlers

Same problem as above...not yet supported.

Multiple browsers

You many have as many separate browser sessions as you like in a test method. The default implementations of 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.

setUp and tearDown

Typically my test cases do the same thing: set up an application for a component, test the application and then remove it. You've seen the code for the first two parts. The removal of the application is done automatically in 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 establishSession
Why 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.