The last step is to test the cart page. We will check that all of the cart features we have developed are working. This will also ensure that when you write a new line of code, your cart will still work.
Configuring the Test Environment
Symfony runs tests in a special test
environment. It loads the config/packages/test/*.yaml
settings specifically for testing.
Configuring a Database for Tests
We should use a separate database for tests to not mess with the databases used in the other configuration environments.
To do that, edit the .env.test
file at the root directory of your project and define the new value for the DATABASE_URL
env var:
DATABASE_URL="mysql://root:happy@127.0.0.1:3306/happy_shop_test"
Next, create the database and update the database schema by executing the following command:
$ bin/console doctrine:database:create -e test
$ bin/console doctrine:migrations:migrate -e test
For now, the database is empty, load the products fixtures with:
$ bin/console doctrine:fixtures:load -e test
Configuring PHPUnit
Symfony provides a phpunit.xml.dist
file with default values for testing:
<?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="bin/.phpunit/phpunit.xsd"
backupGlobals="false"
colors="true"
bootstrap="tests/bootstrap.php"
>
<php>
<ini name="error_reporting" value="-1" />
<server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" />
<server name="SYMFONY_PHPUNIT_REMOVE" value="" />
<server name="SYMFONY_PHPUNIT_VERSION" value="7.5" />
</php>
<testsuites>
<testsuite name="Project Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<filter>
<whitelist processUncoveredFilesFromWhitelist="true">
<directory suffix=".php">src</directory>
</whitelist>
</filter>
<listeners>
<listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
</listeners>
</phpunit>
We will reset the database after each test to be sure that one test is not dependent on the previous ones. To do that, enable the PHPUnit listener provided by the DoctrineTestBundle
bundle:
<extensions>
<extension class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension" />
</extensions>
Configuring the Session
The cart depends on the session. Testing code using a real session is tricky, that's why Symfony provides a MockFileSessionStorage
mock that simulates the PHP session workflow. It's already set in the config/packages/test/framework.yaml
settings:
framework:
test: true
session:
storage_id: session.storage.mock_file
Writing Cart Assertions
When doing functional tests, sometimes we need to make complex assertions in order to check whether the Request, the Response, or the Crawler contain the expected information to make our test succeed.
Symfony already provides a lot of assertions but we will write our own assertions specifically for the cart. It will help us to make the functional test more readable and to avoid writing duplicate code.
Create a CartAssertionsTrait
trait that will provide methods to make assertions on the cart page:
<?php
namespace App\Tests;
use PHPUnit\Framework\Assert;
use Symfony\Component\DomCrawler\Crawler;
trait CartAssertionsTrait
{
}
We will use the Crawler to find DOM elements in the Response and the PHPUnit Assertions provided by theAssert
class to make assertions.
assertCartIsEmpty()
When a cart is empty, we display a specific message to the user. To assert that the cart is empty, add a assertCartIsEmpty()
method and check that the message is displayed by using the Crawler
object:
public static function assertCartIsEmpty(Crawler $crawler)
{
$infoText = $crawler
->filter('.alert-info')
->getNode(0)
->textContent;
$infoText = self::normalizeWhitespace($infoText);
Assert::assertEquals(
'Your cart is empty. Go to the product list.',
$infoText,
"The cart should be empty."
);
}
private static function normalizeWhitespace(string $value): string
{
return trim(preg_replace('/(?:\s{2,}+|[^\S ])/', ' ', $value));
}
assertCartTotalEquals()
To assert that the cart totals equals an expected value, add a assertCartTotalEquals()
method to retrieve the cart total and compare it with the expected value:
public static function assertCartTotalEquals(Crawler $crawler, $expectedTotal)
{
$actualTotal = (float)$crawler
->filter('.col-md-4 .list-group-item span')
->getNode(0)
->textContent;
Assert::assertEquals(
$expectedTotal,
$actualTotal,
"The cart total should be equal to \"$expectedTotal €\". Actual: \"$actualTotal €\"."
);
}
assertCartItemsCountEquals()
Add a assertCartItemsCountEquals
method to count the number of items in the cart and compare it with the expected value:
public static function assertCartItemsCountEquals(Crawler $crawler, $expectedCount): void
{
$actualCount = $crawler
->filter('.col-md-8 .list-group-item')
->count();
Assert::assertEquals(
$expectedCount,
$actualCount,
"The cart should contain \"$expectedCount\" item(s). Actual: \"$actualCount\" item(s)."
);
}
assertCartContainsProductWithQuantity()
It will help us assert that the quantity of product a user wants to purchase in the cart is equal to the expected quantity. Add a method assertCartContainsProductWithQuantity
to retrieve the item from the product name and compare the quantity of the given product with the expected quantity:
public static function assertCartContainsProductWithQuantity(Crawler $crawler, string $productName, int $expectedQuantity): void
{
$actualQuantity = (int)self::getItemByProductName($crawler, $productName)
->filter('input[type="number"]')
->attr('value');
Assert::assertEquals($expectedQuantity, $actualQuantity);
}
private static function getItemByProductName(Crawler $crawler, string $productName)
{
$items = $crawler->filter('.col-md-8 .list-group-item')->reduce(
function (Crawler $node) use ($productName) {
if ($node->filter('h5')->getNode(0)->textContent === $productName) {
return $node;
}
return false;
}
);
return empty($items) ? null : $items->eq(0);
}
assertCartNotContainsProduct()
Add a assertCartNotContainsProduct
method to check that the cart does not contain a product given as argument:
public static function assertCartNotContainsProduct(Crawler $crawler, string $productName): void
{
Assert::assertEmpty(
self::getItemByProductName($crawler, $productName),
"The cart should not contain the product \"$productName\"."
);
}
Writing Functional Tests
In Symfony, a functional test consists to test a Controller. As you want to test a Controller, you need to generate a functional test for this controller.
Generate a functional test for testing the CartController
:
symfony console make:functional-test Controller\\CartController
A CartControllerTest
class has been generated in the tests/Controller/
directory:
<?php
namespace App\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class CartControllerTest extends WebTestCase
{
public function testSomething()
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('h1', 'Hello World');
}
}
It extends a special WebTestCase
class that provides a client object to help us making requests on the application pages. In fact, the test client simulates an HTTP client like a browser and makes requests into your Symfony application. As we don't use JavaScript, we don't need to test in a real browser. The request()
method takes the HTTP method and a URL as arguments and returns a Crawler instance. The Crawler is used to find DOM elements in the Response. We have used it before to make cart assertions.
We will need to add products to the cart to test the cart page. Since we already have the product fixtures, we will go to the homepage (/
) to get a random product from the product list with its name, price, and URL. Let's create a getRandomProduct()
method to do that:
<?php
namespace App\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\BrowserKit\AbstractBrowser;
class CartControllerTest extends WebTestCase
{
private function getRandomProduct(AbstractBrowser $client): array
{
$crawler = $client->request('GET', '/');
$productNode = $crawler->filter('.card')->eq(rand(0, 9));
$productName = $productNode->filter('.card-title')->getNode(0)->textContent;
$productPrice = (float)$productNode->filter('span.h5')->getNode(0)->textContent;
$productLink = $productNode->filter('.btn-dark')->link();
return [
'name' => $productName,
'price' => $productPrice,
'url' => $productLink->getUri()
];
}
}
Now, thanks to this method we will be able to add a random product to the cart. Let's create a addRandomProductToCart()
method, get a random product, and go to the product detail page thanks to the product URL. Then, add the product to the cart using the form.
<?php
namespace App\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\BrowserKit\AbstractBrowser;
class CartControllerTest extends WebTestCase
{
// ...
private function addRandomProductToCart(AbstractBrowser $client, int $quantity = 1): array
{
$product = $this->getRandomProduct($client);
$crawler = $client->request('GET', $product['url']);
$form = $crawler->filter('form')->form();
$form->setValues(['add_to_cart[quantity]' => $quantity]);
$client->submit($form);
return $product;
}
}
Now, for each functional test, we will able to add a random product, go to the cart page (/cart
) and check the cart total, product quantity, and the number of items in the cart using the product name and price extracted from the homepage.
testCartIsEmpty()
The first test is to verify that the cart is empty when we go to the cart page and that we have never added products to the cart.
Add a testCartIsEmpty()
method, go to the cart page (/cart
) and assert that the cart is empty thanks to the CartAssertionsTrait
trait:
<?php
namespace App\Tests\Controller;
use App\Tests\CartAssertionsTrait;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class CartControllerTest extends WebTestCase
{
use CartAssertionsTrait;
public function testCartIsEmpty()
{
$client = static::createClient();
$crawler = $client->request('GET', '/cart');
$this->assertResponseIsSuccessful();
$this->assertCartIsEmpty($crawler);
}
}
testAddProductToCart()
Add a testAddProductToCart()
method to test the product form that allows adding products to the cart. We will add a product to the cart and assert that the cart has only 1 item with a quantity equals to 1. We also check the cart total is equal to the product price.
<?php
namespace App\Tests\Controller;
use App\Tests\CartAssertionsTrait;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\BrowserKit\AbstractBrowser;
class CartControllerTest extends WebTestCase
{
use CartAssertionsTrait;
// ...
public function testAddProductToCart()
{
$client = static::createClient();
$product = $this->addRandomProductToCart($client);
$crawler = $client->request('GET', '/cart');
$this->assertResponseIsSuccessful();
$this->assertCartItemsCountEquals($crawler, 1);
$this->assertCartContainsProductWithQuantity($crawler, $product['name'], 1);
$this->assertCartTotalEquals($crawler, $product['price']);
}
}
testAddProductTwiceToCart()
We need to test when we add a product twice to the cart that the product is not duplicated in the cart and the quantity is increased.
Add a method testAddProductTwiceToCart()
method, get a random product and go to the product page. Add the product twice to the cart using the product form and assert then that the cart has only 1 item with a quantity equals to 2. We will also assert that the cart total is equal to the product price multiplied by 2.
<?php
namespace App\Tests\Controller;
use App\Tests\CartAssertionsTrait;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\BrowserKit\AbstractBrowser;
class CartControllerTest extends WebTestCase
{
use CartAssertionsTrait;
// ...
public function testAddProductTwiceToCart()
{
$client = static::createClient();
// Gets a random product form the homepage
$product = $this->getRandomProduct($client);
// Go to a product page from
$crawler = $client->request('GET', $product['url']);
// Adds the product twice to the cart
for ($i=0 ; $i<2 ;$i++) {
$form = $crawler->filter('form')->form();
$form->setValues(['add_to_cart[quantity]' => 1]);
$client->submit($form);
$crawler = $client->followRedirect();
}
// Go to the cart
$crawler = $client->request('GET', '/cart');
$this->assertResponseIsSuccessful();
$this->assertCartItemsCountEquals($crawler, 1);
$this->assertCartContainsProductWithQuantity($crawler, $product['name'], 2);
$this->assertCartTotalEquals($crawler, $product['price'] * 2);
}
}
testRemoveProductFromCart()
Add a testRemoveProductFromCart()
to test that the Remove button on the cart page removes the product from the cart:
<?php
namespace App\Tests\Controller;
use App\Tests\CartAssertionsTrait;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\BrowserKit\AbstractBrowser;
class CartControllerTest extends WebTestCase
{
use CartAssertionsTrait;
// ...
public function testRemoveProductFromCart()
{
$client = static::createClient();
$product = $this->addRandomProductToCart($client);
// Go to the cart page
$client->request('GET', '/cart');
// Removes the product from the cart
$client->submitForm('Remove');
$crawler = $client->followRedirect();
$this->assertCartNotContainsProduct($crawler, $product['name']);
}
}
testClearCart()
Add a testClearCart()
to test that the Clear button on the cart page removes all items from the cart:
<?php
namespace App\Tests\Controller;
use App\Tests\CartAssertionsTrait;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\BrowserKit\AbstractBrowser;
class CartControllerTest extends WebTestCase
{
use CartAssertionsTrait;
// ...
public function testClearCart()
{
$client = static::createClient();
$this->addRandomProductToCart($client);
// Go to the cart page
$client->request('GET', '/cart');
// Clears the cart
$client->submitForm('Clear');
$crawler = $client->followRedirect();
$this->assertCartIsEmpty($crawler);
}
}
testUpdateQuantity()
Add a testUpdateQuantity
method to check that updating product quantities are working. To do that, use the cart form and set the product's quantity value to 4. Then, assert that the cart contains the product with a quantity equal to 4 and the cart total is equal to the product price multiplied by 4.
<?php
namespace App\Tests\Controller;
use App\Tests\CartAssertionsTrait;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\BrowserKit\AbstractBrowser;
class CartControllerTest extends WebTestCase
{
use CartAssertionsTrait;
// ...
public function testUpdateQuantity()
{
$client = static::createClient();
$product = $this->addRandomProductToCart($client);
// Go to the cart page
$crawler = $client->request('GET', '/cart');
// Updates the quantity
$cartForm = $crawler->filter('.col-md-8 form')->form([
'cart[items][0][quantity]' => 4
]);
$client->submit($cartForm);
$crawler = $client->followRedirect();
$this->assertCartTotalEquals($crawler, $product['price'] * 4);
$this->assertCartContainsProductWithQuantity($crawler, $product['name'], 4);
}
}
Executing Tests
If you are familiar with PHPUnit, running the tests in Symfony is done the same way:
$ bin/phpunit
PHPUnit 7.5.20 by Sebastian Bergmann and contributors.
Testing Project Test Suite
...... 6 / 6 (100%)
Time: 1.62 seconds, Memory: 34.00 MB
OK (6 tests, 14 assertions)
All tests should be passed. If so, that marks the end of the tutorial. You did it, congrats!
Top comments (6)
Hi, good work.
Some points.
Part 2 : add serverVersion to
DATABASE_URL
in Changing the Environment Variable stepmake:controller ProductController
=>HomeController
Part 3 : add step for
bin/console doctrine:database:create
before migrationI'm not sure if
float
is the correct type for the price. Maybe store price with cents in an integer ?Part 4 : it's relation, not relations (in CLI make:entity)
Part 10 : replace
EntityManager
byEntityManagerInterface
in RemoveExpiredCartsCommandWith user-linked cart we wouldn't need an "expired" cart system :D
Thanks
Thanks for your feedback Steven!
I fixed them! The database creation has been added in part 2: dev.to/qferrer/getting-started-bui...
Regarding the data type for the price, you're probably right. Sylius use the integer type for the price column in the database: github.com/Sylius/Sylius/blob/mast.... But, Prestashop uses the decimal type: github.com/pal/prestashop/blob/0c5....
I didn't make a user-linked cart because I didn't want to make the tutorial complicated. We would have managed the cart on different devices based on a context and defined a cart flow for anonymous and logged in users.
Thanks again!
Bonjour quentin et félicitation pour ce tuto qui m'a beaucoup aidé pour cette partie difficile de la gestion d'un panier sur un site de e-commerce...Je m'adresse à toi en français si tu préfères je peux le faire en anglais....Je ne vais pas m'étaler sur cette réponse mais je ne sais pas comment te joindre et j'aimerais pouvir discuter avec toi de ton code car je suis en reconversion pro devweb et pour mon projet de soutenance il y a des parties qui ne fonctionnent pas et je n'ai pas assez de connaissances malgré mes recherches pour résoudre mes problèmes seul....Peux-tu m'aider?
Je te remercie par avance de ta réponse
Hello, I have problems with last step - testing.
I applied all things like in your tutorial but when I ran tests there is a lot of errors, could you take a look? I was trying solve it by myself but no effect :(
I am using PHP8 and Symfony 5.4
Nice article
Excelent!! Good Work