DEV Community

Cover image for Testing the Cart | Building a Shopping Cart with Symfony
Quentin Ferrer
Quentin Ferrer

Posted on

Testing the Cart | Building a Shopping Cart with Symfony


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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For now, the database is empty, load the products fixtures with:

$ bin/console doctrine:fixtures:load -e test
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
{
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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\"."
    );
}
Enter fullscreen mode Exit fullscreen mode

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)."
    );
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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\"."
    );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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');
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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']);
    }
}

Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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']);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

All tests should be passed. If so, that marks the end of the tutorial. You did it, congrats!

Top comments (6)

Collapse
 
duboiss profile image
Steven • Edited

Hi, good work.

Some points.
Part 2 : add serverVersion to DATABASE_URL in Changing the Environment Variable step
make:controller ProductController => HomeController

Part 3 : add step for bin/console doctrine:database:create before migration
I'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 by EntityManagerInterface in RemoveExpiredCartsCommand

With user-linked cart we wouldn't need an "expired" cart system :D

Thanks

Collapse
 
qferrer profile image
Quentin Ferrer • Edited

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!

Collapse
 
manu76 profile image
Manu-76

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

Collapse
 
sc8 profile image
SC

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

Warning:       Your XML configuration validates against a deprecated schema.
Suggestion:    Migrate your XML configuration using "--migrate-configuration"!

Testing
[critical] Uncaught PHP Exception RuntimeException: "Failed to start the session because headers have already been sent by "C:\Users\...\vendor\phpunit\phpunit\src\Util\Printer.php" at line 104." at C:\Users\...\vendor\symfony\http-foundation\Session\Storage\NativeSessionStorage.php line 145
F[critical] Uncaught PHP Exception RuntimeException: "Failed to start the session because headers have already been sent by "C:\Users\...\vendor\phpunit\phpunit\src\Util\Printer.php" at line 104." at C:\Users\...\vendor\symfony\http-foundation\Session\Storage\NativeSessionStorage.php line 145
[critical] Uncaught PHP Exception RuntimeException: "Failed to start the session because headers have already been sent by "C:\Users\...\vendor\phpunit\phpunit\src\Util\Printer.php" at line 104." at C:\Users\...\vendor\symfony\http-foundation\Session\Storage\NativeSessionStorage.php line 145
F[critical] Uncaught PHP Exception RuntimeException: "Failed to start the session because headers have already been sent by "C:\Users\...\vendor\phpunit\phpunit\src\Util\Printer.php" at line 104." at C:\Users\...\vendor\symfony\http-foundation\Session\Storage\NativeSessionStorage.php line 145
E[critical] Uncaught PHP Exception RuntimeException: "Failed to start the session because headers have already been sent by "C:\Users\...\vendor\phpunit\phpunit\src\Util\Printer.php" at line 104." at C:\Users\...\vendor\symfony\http-foundation\Session\Storage\NativeSessionStorage.php line 145
[critical] Uncaught PHP Exception RuntimeException: "Failed to start the session because headers have already been sent by "C:\Users\...\vendor\phpunit\phpunit\src\Util\Printer.php" at line 104." at C:\Users\...\vendor\symfony\http-foundation\Session\Storage\NativeSessionStorage.php line 145
E[critical] Uncaught PHP Exception RuntimeException: "Failed to start the session because headers have already been sent by "C:\Users\...\vendor\phpunit\phpunit\src\Util\Printer.php" at line 104." at C:\Users\...\vendor\symfony\http-foundation\Session\Storage\NativeSessionStorage.php line 145
[critical] Uncaught PHP Exception RuntimeException: "Failed to start the session because headers have already been sent by "C:\Users\...\vendor\phpunit\phpunit\src\Util\Printer.php" at line 104." at C:\Users\...\vendor\symfony\http-foundation\Session\Storage\NativeSessionStorage.php line 145
E[critical] Uncaught PHP Exception RuntimeException: "Failed to start the session because headers have already been sent by "C:\Users\...\vendor\phpunit\phpunit\src\Util\Printer.php" at line 104." at C:\Users\...\vendor\symfony\http-foundation\Session\Storage\NativeSessionStorage.php line 145
[critical] Uncaught PHP Exception RuntimeException: "Failed to start the session because headers have already been sent by "C:\Users\...\vendor\phpunit\phpunit\src\Util\Printer.php" at line 104." at C:\Users\...\vendor\symfony\http-foundation\Session\Storage\NativeSessionStorage.php line 145
E                                                              6 / 6 (100%)
Enter fullscreen mode Exit fullscreen mode
`ERRORS!
Tests: 6, Assertions: 2, Errors: 4, Failures: 2.

Remaining indirect deprecation notices (1)

  1x: The "DAMA\DoctrineTestBundle\Doctrine\DBAL\VersionAwarePlatformStaticDriver" class implements "Doctrine\DBAL\VersionAwarePlatformDriver" that is deprecated All drivers will have to be aware of the server version in the next major release.
    1x in CartControllerTest::testCartIsEmpty from App\Tests\Controller
`
Enter fullscreen mode Exit fullscreen mode
Collapse
 
mis0u profile image
Mickaël

Nice article

Collapse
 
anguz profile image
Angel Guzman

Excelent!! Good Work