The definitive guide of Symfony 1.1

15.3. Functional Tests

Functional tests validate parts of your applications. They simulate a browsing session, make requests, and check elements in the response, just like you would do manually to validate that an action does what it's supposed to do. In functional tests, you run a scenario corresponding to a use case.

15.3.1. What Do Functional Tests Look Like?

You could run your functional tests with a text browser and a lot of regular expression assertions, but that would be a great waste of time. Symfony provides a special object, called sfBrowser, which acts like a browser connected to a symfony application without actually needing a server — and without the slowdown of the HTTP transport. It gives access to the core objects of each request (the request, session, context, and response objects). Symfony also provides an extension of this class called sfTestBrowser, designed especially for functional tests, which has all the abilities of the sfBrowser object plus some smart assert methods.

A functional test traditionally starts with an initialization of a test browser object. This object makes a request to an action and verifies that some elements are present in the response.

For example, every time you generate a module skeleton with the generate:module or the propel:generate-crud tasks, symfony creates a simple functional test for this module. The test makes a request to the default action of the module and checks the response status code, the module and action calculated by the routing system, and the presence of a certain sentence in the response content. For a foobar module, the generated foobarActionsTest.php file looks like Listing 15-9.

Listing 15-9 - Default Functional Test for a New Module, in tests/functional/frontend/foobarActionsTest.php

<?php

include(dirname(__FILE__).'/../../bootstrap/functional.php');

// Create a new test browser
$browser = new sfTestBrowser();

$browser->
  get('/foobar/index')->
  isStatusCode(200)->
  isRequestParameter('module', 'foobar')->
  isRequestParameter('action', 'index')->
  checkResponseElement('body', '!/This is a temporary page/')
;

Tip The browser methods return an sfTestBrowser object, so you can chain the method calls for more readability of your test files. This is called a fluid interface to the object, because nothing stops the flow of method calls.

A functional test can contain several requests and more complex assertions; you will soon discover all the possibilities in the upcoming sections.

To launch a functional test, use the test:functional task with the symfony command line, as shown in Listing 15-10. This task expects an application name and a test name (omit the Test.php suffix).

Listing 15-10 - Launching a Single Functional Test from the Command Line

> php symfony test:functional frontend foobarActions

# get /comment/index
ok 1 - status code is 200
ok 2 - request parameter module is foobar
ok 3 - request parameter action is index
not ok 4 - response selector body does not match regex /This is a temporary page/
# Looks like you failed 1 tests of 4.
1..4

The generated functional tests for a new module don't pass by default. This is because in a newly created module, the index action forwards to a congratulations page (included in the symfony default module), which contains the sentence "This is a temporary page". As long as you don't modify the index action, the tests for this module will fail, and this guarantees that you cannot pass all tests with an unfinished module.

Note In functional tests, the autoloading is activated, so you don't have to include the files by hand.

15.3.2. Browsing with the sfTestBrowser Object

The test browser is capable of making GET and POST requests. In both cases, use a real URI as parameter. Listing 15-11 shows how to write calls to the sfTestBrowser object to simulate requests.

Listing 15-11 - Simulating Requests with the sfTestBrowser Object

include(dirname(__FILE__).'/../../bootstrap/functional.php');

// Create a new test browser
$b = new sfTestBrowser();

$b->get('/foobar/show/id/1');                   // GET request
$b->post('/foobar/show', array('id' => 1));     // POST request

// The get() and post() methods are shortcuts to the call() method
$b->call('/foobar/show/id/1', 'get');
$b->call('/foobar/show', 'post', array('id' => 1));

// The call() method can simulate requests with any method
$b->call('/foobar/show/id/1', 'head');
$b->call('/foobar/add/id/1', 'put');
$b->call('/foobar/delete/id/1', 'delete');

A typical browsing session contains not only requests to specific actions, but also clicks on links and on browser buttons. As shown in Listing 15-12, the sfTestBrowser object is also capable of simulating those.

Listing 15-12 - Simulating Navigation with the sfTestBrowser Object

$b->get('/');                  // Request to the home page
$b->get('/foobar/show/id/1');
$b->back();                    // Back to one page in history
$b->forward();                 // Forward one page in history
$b->reload();                  // Reload current page
$b->click('go');               // Look for a 'go' link or button and click it

The test browser handles a stack of calls, so the back() and forward() methods work as they do on a real browser.

Tip The test browser has its own mechanisms to manage sessions (sfTestStorage) and cookies.

Among the interactions that most need to be tested, those associated with forms probably rank first. To simulate form input and submission, you have three choices. You can either make a POST request with the parameters you wish to send, call click() with the form parameters as an array, or fill in the fields one by one and click the submit button. They all result in the same POST request anyhow. Listing 15-13 shows an example.

Listing 15-13 - Simulating Form Input with the sfTestBrowser Object

// Example template in modules/foobar/templates/editSuccess.php
<?php echo form_tag('foobar/update') ?>
  <?php echo input_hidden_tag('id', $sf_params->get('id')) ?>
  <?php echo input_tag('name', 'foo') ?>
  <?php echo submit_tag('go') ?>
  <?php echo textarea('text1', 'foo') ?>
  <?php echo textarea('text2', 'bar') ?>
</form>

// Example functional test for this form
$b = new sfTestBrowser();
$b->get('/foobar/edit/id/1');

// Option 1: POST request
$b->post('/foobar/update', array('id' => 1, 'name' => 'dummy', 'commit' => 'go'));

// Option 2: Click the submit button with parameters
$b->click('go', array('name' => 'dummy'));

// Option 3: Enter the form values field by field name then click the submit button
$b->setField('name', 'dummy')->
    click('go');

Note With the second and third options, the default form values are automatically included in the form submission, and the form target doesn't need to be specified.

When an action finishes by a redirect(), the test browser doesn't automatically follow the redirection; you must follow it manually with followRedirect(), as demonstrated in Listing 15-14.

Listing 15-14 - The Test Browser Doesn't Automatically Follow Redirects

// Example action in modules/foobar/actions/actions.class.php
public function executeUpdate($request)
{
  // ...

  $this->redirect('foobar/show?id='.$request->getParameter('id'));
}

// Example functional test for this action
$b = new sfTestBrowser();
$b->get('/foobar/edit?id=1')->
    click('go', array('name' => 'dummy'))->
    isRedirected()->   // Check that request is redirected
    followRedirect();    // Manually follow the redirection

There is one last method you should know about that is useful for browsing: restart() reinitializes the browsing history, session, and cookies — as if you restarted your browser.

Once it has made a first request, the sfTestBrowser object can give access to the request, context, and response objects. It means that you can check a lot of things, ranging from the text content to the response headers, the request parameters, and configuration:

$request  = $b->getRequest();
$context  = $b->getContext();
$response = $b->getResponse();

15.3.3. Using Assertions

Due to the sfTestBrowser object having access to the response and other components of the request, you can do tests on these components. You could create a new lime_test object for that purpose, but fortunately sfTestBrowser proposes a test() method that returns a lime_test object where you can call the unit assertion methods described previously. Check Listing 15-15 to see how to do assertions via sfTestBrowser.

Listing 15-15 - The Test Browser Provides Testing Abilities with the test() Method

$b = new sfTestBrowser();
$b->get('/foobar/edit/id/1');
$request  = $b->getRequest();
$context  = $b->getContext();
$response = $b->getResponse();

// Get access to the lime_test methods via the test() method
$b->test()->is($request->getParameter('id'), 1);
$b->test()->is($response->getStatuscode(), 200);
$b->test()->is($response->getHttpHeader('content-type'), 'text/html;charset=utf-8');
$b->test()->like($response->getContent(), '/edit/');

Note The getResponse(), getContext(), getRequest(), and test() methods don't return an sfTestBrowser object, therefore you can't chain other sfTestBrowser method calls after them.

You can check incoming and outgoing cookies easily via the request and response objects, as shown in Listing 15-16.

Listing 15-16 - Testing Cookies with sfTestBrowser

$b->test()->is($request->getCookie('foo'), 'bar');     // Incoming cookie
$cookies = $response->getCookies();
$b->test()->is($cookies['foo'], 'foo=bar');            // Outgoing cookie

Using the test() method to test the request elements ends up in long lines. Fortunately, sfTestbrowser contains a bunch of proxy methods that help you keep your functional tests readable and short — in addition to returning an sfTestBrowser object themselves. For instance, you can rewrite Listing 15-15 in a faster way, as shown in Listing 15-17.

Listing 15-17 - Testing Directly with sfTestBrowser

$b = new sfTestBrowser();
$b->get('/foobar/edit/id/1')->
    isRequestParameter('id', 1)->
    isStatusCode()->
    isResponseHeader('content-type', 'text/html; charset=utf-8')->
    responseContains('edit');

The status 200 is the default value of the parameter expected by isStatusCode(), so you can call it without any argument to test a successful response.

One more advantage of proxy methods is that you don't need to specify an output text as you would with a lime_test method. The messages are generated automatically by the proxy methods, and the test output is clear and readable.

# get /foobar/edit/id/1
ok 1 - request parameter "id" is "1"
ok 2 - status code is "200"
ok 3 - response header "content-type" is "text/html"
ok 4 - response contains "edit"
1..4

In practice, the proxy methods of Listing 15-17 cover most of the usual tests, so you will seldom use the test() method on an sfTestBrowser object.

Listing 15-14 showed that sfTestBrowser doesn't automatically follow redirections. This has one advantage: You can test a redirection. For instance, Listing 15-18 shows how to test the response of Listing 15-14.

Listing 15-18 - Testing Redirections with sfTestBrowser

$b = new sfTestBrowser();
$b->
    get('/foobar/edit/id/1')->
    click('go', array('name' => 'dummy'))->
    isStatusCode(200)->
    isRequestParameter('module', 'foobar')->
    isRequestParameter('action', 'update')->

    isRedirected()->      // Check that the response is a redirect
    followRedirect()->    // Manually follow the redirection

    isStatusCode(200)->
    isRequestParameter('module', 'foobar')->
    isRequestParameter('action', 'show');

15.3.4. Using CSS Selectors

Many of the functional tests validate that a page is correct by checking for the presence of text in the content. With the help of regular expressions in the responseContains() method, you can check displayed text, a tag's attributes, or values. But as soon as you want to check something deeply buried in the response DOM, regular expressions are not ideal.

That's why the sfTestBrowser object supports a getResponseDom() method. It returns a libXML2 DOM object, much easier to parse and test than a flat text. Refer to Listing 15-19 for an example of using this method.

Listing 15-19 - The Test Browser Gives Access to the Response Content As a DOM Object

$b = new sfTestBrowser();
$b->get('/foobar/edit/id/1');
$dom = $b->getResponseDom();
$b->test()->is($dom->getElementsByTagName('input')->item(1)->getAttribute('type'),'text');

But parsing an HTML document with the PHP DOM methods is still not fast and easy enough. If you are familiar with the CSS selectors, you know that they are an even more powerful way to retrieve elements from an HTML document. Symfony provides a tool class called sfDomCssSelector that expects a DOM document as construction parameter. It has a getTexts() method that returns an array of strings according to a CSS selector, and a getElements() method that returns an array of DOM elements. See an example in Listing 15-20.

Listing 15-20 - The Test Browser Gives Access to the Response Content As an sfDomCssSelector Object

$b = new sfTestBrowser();
$b->get('/foobar/edit/id/1');
$c = new sfDomCssSelector($b->getResponseDom())
$b->test()->is($c->getTexts('form input[type="hidden"][value="1"]'), array('');
$b->test()->is($c->getTexts('form textarea[name="text1"]'), array('foo'));
$b->test()->is($c->getTexts('form input[type="submit"]'), array(''));

In its constant pursuit for brevity and clarity, symfony provides a shortcut for this: the checkResponseElement() proxy method. This method makes Listing 15-20 look like Listing 15-21.

Listing 15-21 - The Test Browser Gives Access to the Elements of the Response by CSS Selectors

$b = new sfTestBrowser();
$b->get('/foobar/edit/id/1')->
    checkResponseElement('form input[type="hidden"][value="1"]', true)->
    checkResponseElement('form textarea[name="text1"]', 'foo')->
    checkResponseElement('form input[type="submit"]', 1);

The behavior of the checkResponseElement() method depends on the type of the second argument that it receives:

  • If it is a Boolean, it checks that an element matching the CSS selector exists.
  • If it is an integer, it checks that the CSS selector returns this number of results.
  • If it is a regular expression, it checks that the first element found by the CSS selector matches it.
  • If it is a regular expression preceded by !, it checks that the first element doesn't match the pattern.
  • For other cases, it compares the first element found by the CSS selector with the second argument as a string.

The method accepts a third optional parameter, in the shape of an associative array. It allows you to have the test performed not on the first element returned by the selector (if it returns several), but on another element at a certain position, as shown in Listing 15-22.

Listing 15-22 - Using the Position Option to Match an Element at a Certain Position

$b = new sfTestBrowser();
$b->get('/foobar/edit?id=1')->
    checkResponseElement('form textarea', 'foo')->
    checkResponseElement('form textarea', 'bar', array('position' => 1));

The options array can also be used to perform two tests at the same time. You can test that there is an element matching a selector and how many there are, as demonstrated in Listing 15-23.

Listing 15-23 - Using the Count Option to Count the Number of Matches

$b = new sfTestBrowser();
$b->get('/foobar/edit?id=1')->
    checkResponseElement('form input', true, array('count' => 3));

The selector tool is very powerful. It accepts most of the CSS 3 selectors, and you can use it for complex queries such as those of Listing 15-24.

Listing 15-24 - Example of Complex CSS Selectors Accepted by checkResponseElement()

$b->checkResponseElement('ul#list li a[href]', 'click me');
$b->checkResponseElement('ul > li', 'click me');
$b->checkResponseElement('ul + li', 'click me');
$b->checkResponseElement('h1, h2', 'click me');
$b->checkResponseElement('a[class$="foo"][href*="bar.html"]', 'my link');
$b->checkResponseElement('p:last ul:nth-child(2) li:contains("Some text")');

15.3.5. Testing for errors

Sometimes, your actions or your model throw exceptions on purpose (for example to display a 404 page). Even if you can use a CSS selector to check for a specific error message in the generated HTML code, it's better to use the throwsException method to check that an exception has been thrown as show in Listing 15-25.

Listing 15-25 - Testing for Exceptions

$b = new sfTestBrowser();
$b->
    get('/foobar/edit/id/1')->
    click('go', array('name' => 'dummy'))->
    isStatusCode(200)->
    isRequestParameter('module', 'foobar')->
    isRequestParameter('action', 'update')->

    throwsException()->                   // Checks that the last request threw an exception
    throwsException('RuntimeException')-> // Checks the class of the exception
    throwsException(null, '/error/');     // Checks that the content of the exception message matches the regular expression

15.3.6. Working in the Test Environment

The sfTestBrowser object uses a special front controller, set to the test environment. The default configuration for this environment appears in Listing 15-26.

Listing 15-26 - Default Test Environment Configuration, in frontend/config/settings.yml

test:
  .settings:
    error_reporting:        <?php echo (E_ALL | E_STRICT & ~E_NOTICE)."\n" ?>
    cache:                  off
    web_debug:              off
    no_script_name:         off
    etag:                   off

The cache and the web debug toolbar are set to off in this environment. However, the code execution still leaves traces in a log file, distinct from the dev and prod log files, so that you can check it independently (myproject/log/frontend_test.log). In this environment, the exceptions don't stop the execution of the scripts — so that you can run an entire set of tests even if one fails. You can have specific database connection settings, for instance, to use another database with test data in it.

Before using the sfTestBrowser object, you have to initialize it. If you need to, you can specify a hostname for the application and an IP address for the client — that is, if your application makes controls over these two parameters. Listing 15-27 demonstrates how to do this.

Listing 15-27 - Setting Up the Test Browser with Hostname and IP

$b = new sfTestBrowser('myapp.example.com', '123.456.789.123');

15.3.7. The test:functional Task

The test:functional task can run one or more functional tests, depending on the number of arguments received. The rules look much like the ones of the test:unit task, except that the functional test task always expects an application as first argument, as shown in Listing 15-28.

Listing 15-28 - Functional Test Task Syntax

// Test directory structure
test/
  functional/
    frontend/
      myModuleActionsTest.php
      myScenarioTest.php
    backend/
      myOtherScenarioTest.php

## Run all functional tests for one application, recursively
> php symfony test:functional frontend

## Run one given functional test
> php symfony test:functional frontend myScenario

## Run several tests based on a pattern
> php symfony test:functional frontend my*