Refactoring implies a lot of changes in the code. It means that you need a way to check that you don't break anything during the process. So, before beginning the refactoring session, I asked Vince about its unit and functional test suite.
But Vince had no unit or functional tests. So, we decided to write some functional tests before starting the refactoring.
The symfony browser
In symfony, you can test your application by simulating a browser, thanks to the sfTestBrowser
class. This class behaves like a real browser but it does not use the HTTP layer to call symfony. This has two main advantages: it is faster and you are able to introspect symfony objects after each request.
// test/functional/frontend/productActionsTest.php include(dirname(__FILE__).'/../../bootstrap/functional.php'); $browser = new sfTestBrowser();
Fixtures
As we need the tests to be reproducible, we need to ensure that the database content is always the same when we launch our tests. So, I asked Vince to create some test data:
// data/fixtures/product.yml Category: toy_story: name: Toy Story Wall-E: name: Wall-E Product: U-Command: title: U Command Wall-E image: walle.jpg description: | Action packed Wall E with realistic expressions, light-up eyes, original movie voice and sound effects Send an instant order or preprogrammed action sequence to Wall E via the wireless remote control and it'll carry out your command Programmable remote control with 1000 and more action combos 10 program buttons for expressions, voice, SFX, dance, motion and more Real tread motion price: 59.99 is_new: true is_in_stock: true category_id: Wall-E Interaction-Eve: title: Interaction Eve image: eve.jpg description: | Eve with amazing light-up eye expressions, actions, original movie voice and sound effects Talk to it; push it along or press a button and it¿ll respond Lift her up for flying mode and sound effects Raise Eve's right arm for Cannon Blaster sounds Also reacts to Interaction Wall-E, which is sold separately price: 65.99 is_new: true is_in_stock: true category_id: Wall-E vending: title: Wall-E Mini Figure Set image: set.jpg description: | Complete set of 8 figures Hard to Find Vending Machine Figures Great Detail Small Figures - Approx 1.5 inches tall price: 6.99 is_new: true is_in_stock: false category_id: Wall-E woody: title: Toy Story Woody image: woody.jpg description: | Woody features pull-string electronic phrases and comes with fun fire-rescue accessories! Includes 3 button-cell batteries. Talking Woody comes with Wheezy figure, blazing building, cowboy hat, rescue hammer, and rescue backpack with water projectile. Ages 4 & up. price: 24.99 is_new: false is_in_stock: true category_id: toy_story
In this fixture file, we create two categories and four products. All products are in stock, except for the "Wall-E Mini Figure Set" product.
To load the data from the fixtures file, we use the sfPropelData
class. By default, sfPropelData
deletes all the data from the tables we import, so that it starts with a clean database.
// initialize the database with fixtures $databaseManager = new sfDatabaseManager($configuration); $loader = new sfPropelData(); $loader->loadData(sfConfig::get('sf_data_dir').'/fixtures');
symfony uses the database configuration from the test
environment when you are in functional test context. So, if you don't want to mess up your default development database, create a specific configuration by adding a test
entry in the databases.yml
configuration file.
Now, each time you execute this script, the database is cleaned up and the fixtures data are loaded. So, even if our tests alter the data, it won't affect the next test run.
CSS3 selectors
On the homepage, we need to ensure that we have a list of product, and that all products displayed on the page are in stock. Let's test that the "Toy Story Woody" product is displayed, but not the "Wall-E Mini Figure Set" product:
$browser-> get('/')-> isStatusCode(200)-> isRequestParameter('module', 'product')-> isRequestParameter('action', 'index')-> checkResponseElement('body', '/Toy Story Woody/')-> checkResponseElement('body', '!/Wall-E Mini Figure Set/') ;
The script is self-explanatory:
- get the homepage (
/
) - check that the
body
content contains the product title we are looking for - check that the page does not contain the product that is not in stock
When a product is new (is_new
column), the 'NEW!' text is added after the title. Before writing the test to check if it works correctly, let have a look at the homepage template:
<h1>Our products</h1> <?php foreach ($products as $product): ?> <div> <h2> <?php echo $product->getTitle() ?> <?php if ($product->getIsNew()): ?><span style="margin-left: 10px; color: #e55">NEW!</span><?php endif; ?> </h2> <div style="margin-bottom: 10px"> <em>Category</em>: <?php echo $product->getCategory()->getName() ?> - <em>Price</em>: Only $<?php echo $product->getPrice() ?> - <?php if (in_array($product->getId(), array_keys($sf_user->getAttribute('favorites', array())))): ?> <a href="<?php echo url_for('product/removeFromFavorites?id='.$product->getId()) ?>"><img src="/images/favorite.png" /></a> <?php else: ?> <small><?php echo link_to('add to my favorites', 'product/addToFavorites?id='.$product->getId()) ?></small> <?php endif; ?> </div> <div> <div style="float: left"> <img width="100px" src="/images/products/<?php echo $product->getImage() ?>" /> </div> <p> <?php echo $product->getDescription() ?> <?php if ($sf_user->isAuthenticated()): ?> <p style="text-align: right"><a href="<?php echo url_for('product/edit?id='.$product->getId()) ?>">Edit this product</a></p> <?php endif; ?> </p> <br style="clear: both" /> </div> <div style="text-align: right"> <?php echo link_to(image_tag('/images/add_to_cart.png'), 'product/buy?id='.$product->getId()) ?> </div> <hr /> </div> <?php endforeach; ?>
To check that the NEW
text is added after the product title, we cannot just test for NEW
in the body tag, we need to be more precise. In symfony, it is pretty simple as the checkResponseElement()
method takes a CSS3 selector as its first argument:
$browser-> get('/')-> checkResponseElement('h2:contains("NEW")', 2) ;
Here, we test that we have exactly two h2
tags that contains the text NEW
.
Now, we need to test the "Edit this product" process. The scenario is the following:
- Sign in as an administrator
- Click on a "Edit this product" link
- Fill the form with some new values and upload a new file
- Submit the form
- Check that the submitted values have been taken into account on the homepage
- Sign out
To login as an administrator, we need to click on the "sign in" link:
$browser-> click('signin')-> isRedirected()-> isRequestParameter('module', 'user')-> isRequestParameter('action', 'signin')-> followRedirect()-> isStatusCode(200)-> isRequestParameter('module', 'product')-> isRequestParameter('action', 'index')-> checkResponseElement('body', '!/signin/')-> checkResponseElement('body', '/signout/') ;
After being authenticated, the signin
action redirects the user back to the homepage. We then check that the 'signin' link does not exist anymore and has been replaced by a 'signout' link.
Now that we are authenticated, we can click on the "Edit this product" link. But there are several links with this name. Let's say we want to click on the second one:
// The position attribute is new in symfony 1.2 $browser-> click('Edit this product', array(), array('position' => 2))-> isStatusCode(200)-> isRequestParameter('module', 'product')-> isRequestParameter('action', 'edit')-> checkResponseElement('h2', '/U Command Wall-E/') ;
After some basic checks on the page, we are ready to submit the form:
$browser-> click('Save', array('product' => array( 'price' => '10', 'image' => dirname(__FILE__).'/../../../web/images/products/eve.jpg', 'is_new' => false, ))) ;
When you click on a button, you can pass the values for fields you want to override. In this example, we have changed the price
value, the is_new
value, and we have uploaded an image by giving the full path of the file we want to upload.
After checking that we are redirected to the homepage, we can check that our changes have been taken into account:
$browser-> isRedirected()-> followRedirect()-> isStatusCode(200)-> isRequestParameter('module', 'product')-> isRequestParameter('action', 'index') checkResponseElement('h2:contains("NEW")', 1)-> checkResponseElement(sprintf('img[src$="%s"]', sha1('eve.jpg').'.jpg')) ;
We can now sign out:
$browser-> click('signout')-> isRequestParameter('module', 'user')-> isRequestParameter('action', 'signout')-> isRedirected()-> followRedirect()-> isStatusCode(200)-> isRequestParameter('module', 'product')-> isRequestParameter('action', 'index')-> checkResponseElement('body', '/signin/')-> checkResponseElement('body', '!/signout/') ;
Now, everytime we make a change to the code, we launch the functional tests to be sure we don't break some features in the process:
$ php symfony test:functional frontend productActions
Application specific browser
The functional tests we have written are pretty simple and of course, we will have to write some more to cover all the website features. And as your test suite grows, we will likely copy and paste some code, for example the sign in and sign out process. To avoid repeating the same process over and over again, it is generally a good idea to create an application specific browser class and customize it for your application:
class StoreBrowser extends sfTestBrowser { public function signin() { return $this-> get('/user/signin')-> isRedirected()-> isRequestParameter('module', 'user')-> isRequestParameter('action', 'signin')-> followRedirect()-> isStatusCode(200)-> isRequestParameter('module', 'product')-> isRequestParameter('action', 'index')-> checkResponseElement('body', '!/signin/')-> checkResponseElement('body', '/signout/') ; } public function signout() { return $this-> get('/user/signout')-> isRequestParameter('module', 'user')-> isRequestParameter('action', 'signout')-> isRedirected()-> followRedirect()-> isStatusCode(200)-> isRequestParameter('module', 'product')-> isRequestParameter('action', 'index')-> checkResponseElement('body', '/signin/')-> checkResponseElement('body', '!/signout/') ; } }
Here is a simple test that only sign in and then sign out:
include(dirname(__FILE__).'/../../bootstrap/functional.php'); // initialize the database with fixtures $databaseManager = new sfDatabaseManager($configuration); $loader = new sfPropelData(); $loader->loadData(sfConfig::get('sf_data_dir').'/fixtures'); $browser = new StoreBrowser(); $browser-> signin()-> // do something while logged in signout() ;
That's all for today. As we are now backed by our test suite, Vince will be quite comfortable during the refactoring session.
I'm really intellectually stucked with test. What's the point in testing if an item is "new", you can see it when refreshing the page... Does Sension Labs gives some training on testing applications ?
@Jarod: Sure you can see the new text in your browser but the main goal of testing is to automate the checks you can do manually. That way, each time you change something in your code, you can just run the test task and symfony will do all the tests for you, automatically.
And yes, Sensio provides training on tests (if you are interested, send me an email).
No login/password are needed for administrator authentification ?
To start with unit and functional tests is always a good idea :)
I love this part.
This is really great ! I wasn't sure about how testing a symfony app would be.. It seems pretty easy and powerful, maybe I'll change my mine on testing symfony apps.
This series of articles is a really good idea !
Reading the 1st part of this tutorial I was very corious wether it would meet my expectations... Now I am sure that I can get a lot insights from it.
It's the tutorials that make Symfony the best choice for me.
Thank you for your article. Please, at the end of the fifth to put the source code of this project before and after refactoring.
Yeah. What about the user an login on the authentication process??
Hi Fabien. Great post. Can i translated this 5 post on my site, to spanish?
@puentesdiaz: feel free to translate the posts and post a link to them in the comments.
@nicolas.martin: I think you're right Nicolas. signin function should take an username and a password strings as parameters and include in the middle of its code something like :
setField('username',$username)-> setField('password',$password)-> click('login')->