Previously on Jobeet
Yesterday, we saw how to unit test out Jobeet classes using the lime testing library packaged with symfony.
Today, we will write functional tests for the features we have already
implemented in the job
and category
modules.
Functional Tests
Functional tests are a great tool to test your application from end to end: from the request made by a browser to the response sent by the server. They test all the layers of an application: the routing, the model, the actions, and the templates. They are very similar to what you probably already do manually: each time you add or modify an action, you need go to the browser and check that everything works as expected by clicking on links and checking elements on the rendered page. In other words, you run a scenario corresponding to the use case you have just implemented.
As the process is manual, it is tedious and error prone. Each time you change something in your code, you must step through all the scenarios to ensure that you did not break something. That's insane. Functional tests in symfony provide a way to easily describe scenarios. Each scenario can then be played automatically over and over again by simulating the experience a user has in a browser. Like unit tests, they give you the confidence to code in peace.
The sfBrowser
class
In symfony, functional tests are run through a special browser, implemented by
the sfBrowser
class. It
acts as a browser tailored for your application and directly connected to it,
without the need for a web server. It gives you access to all symfony objects
before and after each request, giving you the opportunity to introspect them
and do the checks you want programatically.
sfBrowser
provides methods that simulates navigation done in a classic
browser:
Method | Description |
---|---|
get() |
Gets a URL |
post() |
Posts to a URL |
call() |
Calls a URL (used for PUT and DELETE methods) |
back() |
Goes back one page in the history |
forward() |
Goes forward one page in the history |
reload() |
Reloads the current page |
click() |
Clicks on a link or a button |
select() |
selects a radiobutton or checkbox |
deselect() |
deselects a radiobutton or checkbox |
restart() |
Restarts the browser |
Here are some usage examples of the sfBrowser
methods:
$browser = new sfBrowser(); $brower-> get('/')-> click('Design')-> get('/category/programming?page=2')-> get('/category/programming', array('page' => 2))-> post('search', array('keywords' => 'php')) ;
sfBrowser
contains additional methods to configure the browser behavior:
Method | Description |
---|---|
setHttpHeader() |
Sets an HTTP header |
setAuth() |
Sets the basic authentication credentials |
setCookie() |
Set a cookie |
removeCookie() |
Removes a cookie |
clearCookie() |
Clears all current cookies |
followRedirect() |
Follows a redirect |
The sfTestFunctional
class
We have a browser, but we need a way to introspect the symfony objects
to do the actual testing. It can be done with lime and some sfBrowser
methods like getResponse()
and getRequest()
but symfony provides a
better way.
The test methods are provided by another class,
sfTestFunctional
that takes a sfBrowser
instance in its constructor. The sfTestFunctional
class delegates the tests to tester objects. Several testers are bundled
with symfony, and you can also create your own.
As we saw yesterday, functional tests are stored under the
test/functional
directory. For Jobeet, tests are to be found in the
test/functional/frontend
sub-directory as each application has its own
subdirectory. This directory already contains 2 files:
categoryActionsTest.php
, and jobActionsTest.php
as all tasks that generate
a module automatically create a basic functional test file:
// test/functional/frontend/categoryActionsTest.php include(dirname(__FILE__).'/../../bootstrap/functional.php'); $browser = new sfTestFunctional(new sfBrowser()); $browser-> get('/category/index')-> with('request')->begin()-> isParameter('module', 'category')-> isParameter('action', 'index')-> end()-> with('response')->begin()-> isStatusCode(200)-> checkElement('body', '!/This is a temporary page/')-> end() ;
At first, the script above may look a bit strange to you. That's because
methods of sfBrowser
and sfTestFunctional
always return $this
to enable
a fluent interface. It allows
you to chain the method calls for better readability.
Tests are run within a tester block context. A tester block context begins
with with('TESTER NAME')->begin()
and ends with end()
:
$browser-> with('request')->begin()-> isParameter('module', 'category')-> isParameter('action', 'index')-> end() ;
The code tests that the request parameter module
equals category
and
action
equals index
.
When you only need to call one test method on a tester, you don't need to create a block:
with('request')->isParameter('module', 'category')
.
The request tester provides tester methods to introspect and test the
sfWebRequest
object:
Method | Description |
---|---|
isParameter() |
Checks a request parameter value |
isFormat() |
Checks the format of a request |
isMethod() |
Checks the method |
hasCookie() |
Checks whether the request has a cookie with the |
given name | |
isCookie() |
Checks the value of a cookie |
There is also a response tester class that provides tester methods against
the sfWebResponse
object:
Method | Description |
---|---|
checkElement() |
Checks if a response CSS selector match some criteria |
isHeader() |
Checks the value of a header |
isStatusCode() |
Checks the response status code |
isRedirected() |
Checks if the current response is a redirect |
We will describe more testers classes in the coming days (for forms, user, cache, ...).
Running Functional Tests
As for unit tests, launching functional tests can be done by executing the test file directly:
$ php test/functional/frontend/categoryActionsTest.php
Or by using the test:functional
task:
$ php symfony test:functional frontend categoryActions
Test Data
As for Propel unit tests, we need to load test data each time we launch a functional test. We can reuse the code we have written yesterday:
include(dirname(__FILE__).'/../../bootstrap/functional.php'); $browser = new JobeetTestFunctional(new sfBrowser()); $loader = new sfPropelData(); $loader->loadData(sfConfig::get('sf_test_dir').'/fixtures');
Loading data in a functional test is a bit easier than in unit tests as the database has already been initialized by the bootstrapping script.
As for unit tests, we won't copy and paste this snippet of code in each test
file, but we will rather create our own functional class that inherits from
sfTestFunctional
:
// lib/test/JobeetTestFunctional.class.php class JobeetTestFunctional extends sfTestFunctional { public function loadData() { $loader = new sfPropelData(); $loader->loadData(sfConfig::get('sf_test_dir').'/fixtures'); return $this; } }
Writing Functional Tests
Writing functional tests is like playing a scenario in a browser. We already have written all the scenarios we need to test as part of the day 2 stories.
First, let's test the Jobeet homepage by editing the jobActionsTest.php
test file. Replace the code with the following one:
Expired jobs are not listed
// test/functional/frontend/jobActionsTest.php include(dirname(__FILE__).'/../../bootstrap/functional.php'); $browser = new JobeetTestFunctional(new sfBrowser()); $browser->loadData(); $browser->info('1 - The homepage')-> get('/')-> with('request')->begin()-> isParameter('module', 'job')-> isParameter('action', 'index')-> end()-> with('response')->begin()-> info(' 1.1 - Expired jobs are not listed')-> checkElement('.jobs td.position:contains("expired")', false)-> end() ;
As with lime
, an informational message can be inserted by calling the
info()
method to make the output more readable. To the exclusion of expired
jobs from the homepage, we check that the CSS selector
.jobs td.position:contains("expired")
does not match anywhere in the
response HTML content (remember that in the fixture files, the only expired
job we have contains "expired" in the position).
The
checkElement()
method is able to interpret most valid CSS3 selectors.
Only n jobs are listed for a category
Add the following code at the end of the test file:
// test/functional/frontend/jobActionsTest.php $max = sfConfig::get('app_max_jobs_on_homepage'); $browser->info('1 - The homepage')-> get('/')-> info(sprintf(' 1.2 - Only %s jobs are listed for a category', $max))-> with('response')-> checkElement('.category_programming tr', $max) ;
The checkElement()
method can also check that a CSS selector matches n times.
A category has a link to the category page only if too many jobs
$browser->info('1 - The homepage')-> get('/')-> info(' 1.3 - A category has a link to the category page only if too many jobs')-> with('response')->begin()-> checkElement('.category_design .more_jobs', false)-> checkElement('.category_programming .more_jobs')-> end() ;
Here, we check that there is no "more jobs" link for the design category
(.category_design .more_jobs
does not exist), and that there is a "more
jobs" link for the programming category (.category_programming .more_jobs
does exist).
Jobs are sorted by date
// most recent job in the programming category $criteria = new Criteria(); $criteria->add(JobeetCategoryPeer::SLUG, 'programming'); $category = JobeetCategoryPeer::doSelectOne($criteria); $criteria = new Criteria(); $criteria->add(JobeetJobPeer::EXPIRES_AT, time(), Criteria::GREATER_THAN); $criteria->addDescendingOrderByColumn(JobeetJobPeer::CREATED_AT); $job = JobeetJobPeer::doSelectOne($criteria); $browser->info('1 - The homepage')-> get('/')-> info(' 1.4 - Jobs are sorted by date')-> with('response')->begin()-> checkElement('.category_programming tr:last:contains("102")')-> checkElement(sprintf('.category_programming tr:first a[href*="/%d/"]', $job->getId()))-> end() ;
To test if jobs are actually sorted by date, we check that the last job listed
on the homepage contains 102
in the company. But testing the first job on
the programming list is trickier as the the first two jobs have the exact same
position, company, and location. So, we need to check that the URL contains
the expected primary key. As the primary key can change between runs, we need
to get the Propel object from the database first.
Even if the test works as is, we need to refactor the code a bit, as getting
the first job of the programming category can be reused elsewhere in our
tests. We won't move the code to the Model layer as the code is test specific.
Instead, we will move the code to the JobeetTestFunctional
class we have
created earlier. This class acts as a Domain Specific functional tester class
for Jobeet:
// lib/test/JobeetTestFunctional.class.php class JobeetTestFunctional extends sfTestFunctional { public function getMostRecentProgrammingJob() { // most recent job in the programming category $criteria = new Criteria(); $criteria->add(JobeetCategoryPeer::SLUG, 'programming'); $category = JobeetCategoryPeer::doSelectOne($criteria); $criteria = new Criteria(); $criteria->add(JobeetJobPeer::EXPIRES_AT, time(), Criteria::GREATER_THAN); $criteria->addDescendingOrderByColumn(JobeetJobPeer::CREATED_AT); return JobeetJobPeer::doSelectOne($criteria); } // ... }
Each job on the homepage is clickable
$browser->info('2 - The job page')-> get('/')-> info(' 2.1 - Each job on the homepage is clickable')-> click('Web Developer', array('position' => 1))-> with('request')->begin()-> isParameter('module', 'job')-> isParameter('action', 'show')-> isParameter('company_slug', 'sensio-labs')-> isParameter('location_slug', 'paris-france')-> isParameter('position_slug', 'web-developer')-> isParameter('id', $browser->getMostRecentProgrammingJob()->getId())-> end() ;
To test the job link on the homepage, we simulate a click on the "Web
Developer" text. As there are many of them on the page, we have explicitly to
asked the browser to click on the first one (array('position' => 1)
).
Each request parameter is then tested to ensure that the routing has done its job correctly.
Learn by the Example
In this section, we have provided all the code needed to test the job and category pages. Read the code carefully as you may learn some new neat tricks:
// lib/test/JobeetTestFunctional.class.php class JobeetTestFunctional extends sfTestFunctional { public function loadData() { $loader = new sfPropelData(); $loader->loadData(sfConfig::get('sf_test_dir').'/fixtures'); return $this; } public function getMostRecentProgrammingJob() { // most recent job in the programming category $criteria = new Criteria(); $criteria->add(JobeetCategoryPeer::SLUG, 'programming'); $category = JobeetCategoryPeer::doSelectOne($criteria); $criteria = new Criteria(); $criteria->add(JobeetJobPeer::EXPIRES_AT, time(), Criteria::GREATER_THAN); $criteria->addDescendingOrderByColumn(JobeetJobPeer::CREATED_AT); return JobeetJobPeer::doSelectOne($criteria); } public function getExpiredJob() { // expired job $criteria = new Criteria(); $criteria->add(JobeetJobPeer::EXPIRES_AT, time(), Criteria::LESS_THAN); return JobeetJobPeer::doSelectOne($criteria); } } // test/functional/frontend/jobActionsTest.php include(dirname(__FILE__).'/../../bootstrap/functional.php'); $browser = new JobeetTestFunctional(new sfBrowser()); $browser->loadData(); $browser->info('1 - The homepage')-> get('/')-> with('request')->begin()-> isParameter('module', 'job')-> isParameter('action', 'index')-> end()-> with('response')->begin()-> info(' 1.1 - Expired jobs are not listed')-> checkElement('.jobs td.position:contains("expired")', false)-> info(sprintf(' 1.2 - Only %s jobs are listed for a category', sfConfig::get('app_max_jobs_on_homepage')))-> checkElement('.category_programming tr', sfConfig::get('app_max_jobs_on_homepage'))-> info(' 1.3 - A category has a link to the category page only if too many jobs')-> checkElement('.category_design .more_jobs', false)-> checkElement('.category_programming .more_jobs')-> info(' 1.4 - Jobs are sorted by date')-> checkElement(sprintf('.category_programming tr:first a[href*="/%d/"]', $browser->getMostRecentProgrammingJob()->getId()))-> checkElement('.category_programming tr:last:contains("102")')-> end() ; $browser->info('2 - The job page')-> info(' 2.1 - Each job on the homepage is clickable and give detailed information')-> click('Web Developer', array('position' => 1))-> with('request')->begin()-> isParameter('module', 'job')-> isParameter('action', 'show')-> isParameter('company_slug', 'sensio-labs')-> isParameter('location_slug', 'paris-france')-> isParameter('position_slug', 'web-developer')-> isParameter('id', $browser->getMostRecentProgrammingJob()->getId())-> end()-> info(' 2.2 - A non-existent job forwards the user to a 404')-> get('/job/foo-inc/milano-italy/0/painter')-> with('response')->isStatusCode(404)-> info(' 2.3 - An expired job page forwards the user to a 404')-> get(sprintf('/job/sensio-labs/paris-france/%d/web-developer', $browser->getExpiredJob()->getId()))-> with('response')->isStatusCode(404) ; // test/functional/frontend/categoryActionsTest.php include(dirname(__FILE__).'/../../bootstrap/functional.php'); $browser = new JobeetTestFunctional(new sfBrowser()); $browser->loadData(); $browser->info('1 - The category page')-> info(' 1.1 - Categories on homepage are clickable')-> get('/')-> click('Programming')-> with('request')->begin()-> isParameter('module', 'category')-> isParameter('action', 'show')-> isParameter('slug', 'programming')-> end()-> info(sprintf(' 1.2 - Categories with more than %s jobs also have a "more" link', sfConfig::get('app_max_jobs_on_homepage')))-> get('/')-> click('22')-> with('request')->begin()-> isParameter('module', 'category')-> isParameter('action', 'show')-> isParameter('slug', 'programming')-> end()-> info(sprintf(' 1.3 - Only %s jobs are listed', sfConfig::get('app_max_jobs_on_category')))-> with('response')->checkElement('.jobs tr', sfConfig::get('app_max_jobs_on_category'))-> info(' 1.4 - The job listed is paginated')-> with('response')->begin()-> checkElement('.pagination_desc', '/32 jobs/')-> checkElement('.pagination_desc', '#page 1/2#')-> end()-> click('2')-> with('request')->begin()-> isParameter('page', 2)-> end()-> with('response')->checkElement('.pagination_desc', '#page 2/2#') ;
Debugging Functional Tests
Sometimes a functional test fails. As symfony simulates a browser without any
graphical interface, it can be hard to diagnose the problem. Thankfully,
symfony provides the debug()
method to output the response header and
content:
$browser->with('response')->debug();
The debug()
method can be inserted anywhere in a response
tester block
and will halt the script execution.
Functional Tests Harness
The test:functional
task can also be used to launch all functional tests for
an application:
$ php symfony test:functional frontend
The task outputs a single line for each test file:
Tests Harness
As you may expect, there is also a task to launch all tests for a project (unit and functional):
$ php symfony test:all
See you Tomorrow
That wraps up our tour of the symfony test tools. You have no excuse anymore to not test your applications! With the lime framework and the functional test framework, symfony provides powerful tools to help you write tests with little effort.
We have just scratched the surface of functional tests. From now on, each time we implement a feature, we will also write tests to learn more features of the test framework.
The functional test framework does not replace tools like "Selenium". Selenium runs directly in the browser to automate testing across many platforms and browsers and as such, it is able to test your application's JavaScript.
Be sure to come back tomorrow, as we will talk about yet another great feature of symfony: the form framework.
Good job
The parser seems to have broken for the post.. it's all fixed if you watch it in the tutorial overview.
hi, i have used symfony 1.0 at my many projects. But i do not understand why do we use/need all these tests ? is there any document about why we use it ? All of them are about how do we do functinal tests.
Sorry for my english.
hi, thanks for great tutorial. found few issues. for expired job need to set any expires_at date in the past, otherwise it will set date in future and method JobeetTestFunctional->getExpiredJob() return null.
another issue is with symfony test:all sometimes it fail tests #11 in the jobActionsTest (isParameter('company_slug', 'sensio-labs')->)
Hi,
really nice tutorial about the testing capabilities of symfony.
Like Iking I changed the fixtures a bit, so that the generated entries become a created_at value of yesterday. So it is guaranteed that the sensio programming job is the first on the homepage.
As far as I am concerned you can remove the query for category in getMostRecentProgrammingJob as it is not used.
Since I was a bit sloppy in session #07 I forget the "more" feature on the homepage. The tests showed me this :)
Greetings, Martin
hi,
thanks for this great tutorial.
as martin already mentioned, is the category object not used in "getMostRecentProgrammingJob()". You have to modify the job criteria by adding following line
$criteria->add(JobeetJobPeer::CATEGORY_ID, $category->getId());
Hope that works :-)
Is it just me or for this line:
checkElement('.category_programming tr:last:contains("102")')->
Changing the number inside the contain() does not really do anything...
All test are successful but I've allways got that error message :
Fatal error: Call to a member function getToken() on a non-object in /users/web/var/code/common/symfony-1.2.1/lib/plugins/sfPropelPlugin/lib/routing/sfPropelRoute.class.php on line 124
I really don't understand and don't know where to search.
Hi, thanks for this great tutorial.
But, I think sfDomCssSelector can't understand 'tr:last:contains("102")'.