This post was published as part of the symfony 2008 advent calendar. As this tutorial might have been updated since then, you are advised to read the last version from the symfony 1.2 documentation (for Propel or Doctrine).

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

Tests on the command line

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:

Functional tests harness

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

Tests harness

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.

Published in #Tutorials