Testing the Cart | Building a Shopping Cart with Symfony
Tags: php, symfony, tutorial, testing
Published: 2020-12-21 12:57:03

- [Configuring the Test Environment](#configure-environment)
 - [Configuring a Database for Tests](#configure-database)
 - [Configuring PHPUnit](#configure-phpunit)
 - [Configuring a Session](#configure-session)
- [Writing Cart Assertions](#write-cart-assertions)
 - [`assertCartIsEmpty()`](#assert-cart-is-empty)
 - [`assertCartTotalEquals()`](#assert-cart-total-equals)
 - [`assertCartItemsCountEquals()`](#assert-cart-items-count-equals)
 - [`assertCartContainsProductWithQuantity()`](#assert-product-quantity-equals)
 - [`assertCartNotContainsProduct()`](#assert-cart-not-contains-product)
- [Writing Functional Tests](#write-functional-tests)
  - [`testCartIsEmpty()`](#test-cart-is-empty)
  - [`testAddProductToCart()`](#test-add-product-to-cart)
  - [`testAddProductTwiceToCart()`](#test-add-product-twice-to-cart)
  - [`testRemoveProductFromCart()`](#test-remove-product-from-cart)
  - [`testClearCart()`](#test-clear-cart)
  - [`testUpdateQuantity()`](#test-update-quantity)
  - [`testQuantityIsInvalid()`](#test-quantity-is-invalid)
- [Executing Tests](#execute-tests)

-------

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 <a name="configure-environment"></a>
--------------------------------

Symfony runs tests in a special `test` environment. It loads the `config/packages/test/*.yaml` settings specifically for testing.

### Configuring a Database for Tests <a name="configure-database"></a>
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 <a name="configure-phpunit"></a>
Symfony provides a `phpunit.xml.dist` file with default values for testing:

```xml
<?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:

```xml
<extensions>
    <extension class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension" />
</extensions>
```

### Configuring the Session <a name="configure-session"></a>

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:

```yaml
framework:
    test: true
    session:
        storage_id: session.storage.mock_file
```

Writing Cart Assertions <a name="write-cart-assertions"></a>
------------------------

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
<?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 the` Assert` 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:

```php
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()` <a name="assert-cart-total-equals"></a>

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:

```php
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()` <a name="assert-cart-items-count-equals"></a>

Add a `assertCartItemsCountEquals` method to count the number of items in the cart and compare it with the expected value:

```php
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()` <a name="assert-cart-contains-product-with-quantity"></a>

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:

```php
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()` <a name="assert-cart-no-contains-product"></a>
Add a `assertCartNotContainsProduct` method to check that the cart does not contain a product given as argument:

```php
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 <a name="write-functional-tests"></a>
------------------------
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
<?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
<?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
<?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()` <a name="test-cart-is-empty"></a>
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
<?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()` <a name="test-add-product-to-cart"></a>
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
<?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()` <a name="test-add-product-twice-to-cart"></a>

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
<?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()` <a name="test-remove-product-from-cart"></a>
Add a `testRemoveProductFromCart()` to test that the *Remove* button on the cart page removes the product from the cart:

```php
<?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()` <a name="test-clear-cart"></a>
Add a `testClearCart()` to test that the *Clear* button on the cart page removes all items from the cart:

```php
<?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()` <a name="test-update-quantity"></a>

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
<?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 <a name="execute-tests"></a>
---------------

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!