Testing, Upgrades and Addons
|
This content was generated with the assistance of AI and should be verified against the official documentation of each platform before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written against PrestaShop 9.2.0 (released 2026-09-30, Symfony 6.4.46, PHP 8.5.12 in the official Docker image),
PHPUnit 10.5.66, PHPStan 2.3.3, PHP CS Fixer 3.95.27, prestashop/php-dev-tools 5 and the Update Assistant module 7.6.6, and
it was checked on 2026-10-27. It applies to the open-source, self-hosted PrestaShop; nothing here depends on a plan or an
edition. It is the last page of the module path: after the pages on module basics
and security, it explains how to prove that a module works, how to ship it and how to keep a shop
up to date.
The running example is Pocket Coffee. The module pcfitesting holds the shop’s price rules: beans cost 10% less for members of the
Coffee Club, shipping is a flat 4.90 EUR in Spain (free from 40 EUR) and 9.90 EUR in the rest of the European Union. The rules
live in a small class without any PrestaShop dependency, so most of them can be tested without a shop, and the module
class reads the free-shipping threshold from the shop configuration, so a smaller part needs a real shop. The page
runs every tool against that module and shows the output.
What to test, and with which tool
A PrestaShop module is PHP code that runs inside a large legacy application, so a useful test strategy mixes checks that do not need the shop with checks that do. The table lists the layers in the order in which they should run in a pipeline, cheapest first.
| Layer | What it finds | Needs a shop? | Tool |
|---|---|---|---|
Syntax check |
Code that a supported PHP version cannot parse |
No |
|
Coding standard |
Style differences from the PrestaShop standard |
No |
PHP CS Fixer with |
Static analysis |
Calls to methods or classes that do not exist in a given PrestaShop version, wrong types |
The shop sources, not an installed shop |
PHPStan |
Unit tests |
Logic errors in classes that do not touch PrestaShop |
No |
PHPUnit |
Integration tests |
Wrong use of the shop API, configuration, database |
An installed shop with the module |
PHPUnit with the shop bootstrapped |
Console smoke test |
Install, configure, disable, reset, upgrade and uninstall failures |
An installed shop |
|
Behaviour tests |
Business rules of the core (carts, discounts, prices) |
The core checkout and a test database |
Behat (core) |
UI tests |
Whole user journeys in a browser |
An installed shop reachable over HTTP |
Playwright (core) |
The first six layers ran on a local PrestaShop 9.2.0 shop for this page; the last two are the core’s own suites, which are
described from the developer documentation and were not run (see What was run and what was not). The official module
documentation (modules/testing in the developer guide) was written for PHP 5.6 and PHPUnit 5.7 and still shows them in its
examples; the commands below are the current equivalents.
The module under test
<?php
declare(strict_types=1);
namespace PrestaShop\Module\Pcfitesting\Pricing;
/**
* Pocket Coffee price rules. No PrestaShop class is used here, so the logic runs in a plain PHPUnit test.
*/
final class ClubPricing
{
public const ZONE_SPAIN = 'ES';
public const ZONE_EU = 'EU';
private const SHIPPING = [
self::ZONE_SPAIN => 4.90,
self::ZONE_EU => 9.90,
];
public function __construct(
private readonly float $freeShippingFrom,
private readonly float $clubRate = 0.10,
) {
}
/**
* Beans are 10% cheaper for members of the Coffee Club.
*/
public function beansUnitPrice(float $basePrice, bool $isMember): float
{
if ($basePrice < 0) {
throw new \InvalidArgumentException('The price cannot be negative.');
}
return round($isMember ? $basePrice * (1 - $this->clubRate) : $basePrice, 2);
}
/**
* Flat rate per zone, free from the configured subtotal in Spain.
*/
public function shippingCost(string $zone, float $subtotal): float
{
if (!isset(self::SHIPPING[$zone])) {
throw new \InvalidArgumentException(sprintf('Unknown shipping zone "%s".', $zone));
}
if ($zone === self::ZONE_SPAIN && $subtotal >= $this->freeShippingFrom) {
return 0.0;
}
return self::SHIPPING[$zone];
}
}
<?php
declare(strict_types=1);
if (!defined('_PS_VERSION_')) {
exit;
}
if (file_exists(__DIR__ . '/vendor/autoload.php')) {
require_once __DIR__ . '/vendor/autoload.php';
}
use PrestaShop\Module\Pcfitesting\Pricing\ClubPricing;
class Pcfitesting extends Module
{
public const CONFIG_FREE_SHIPPING_FROM = 'PCFITESTING_FREE_SHIPPING_FROM';
public function __construct()
{
$this->name = 'pcfitesting';
$this->tab = 'pricing_promotion';
$this->version = '1.0.0';
$this->author = 'Pocket Coffee';
$this->need_instance = 0;
$this->ps_versions_compliancy = ['min' => '9.0.0', 'max' => '9.99.99'];
$this->bootstrap = true;
parent::__construct();
$this->displayName = $this->trans('Pocket Coffee club pricing', [], 'Modules.Pcfitesting.Admin');
$this->description = $this->trans('Coffee Club price and shipping rules used by the testing examples.', [], 'Modules.Pcfitesting.Admin');
}
public function isUsingNewTranslationSystem(): bool
{
return true;
}
public function install(): bool
{
return parent::install()
&& Configuration::updateValue(self::CONFIG_FREE_SHIPPING_FROM, '40');
}
public function uninstall(): bool
{
return Configuration::deleteByName(self::CONFIG_FREE_SHIPPING_FROM) && parent::uninstall();
}
public function getClubPricing(): ClubPricing
{
return new ClubPricing((float) Configuration::get(self::CONFIG_FREE_SHIPPING_FROM));
}
}
Every tool below is declared in composer.json as a development dependency and wrapped in a Composer script, so that the
same one-word command runs locally, in a container and in CI:
{
"name": "pocketcoffee/pcfitesting",
"description": "Pocket Coffee club pricing, used by the testing examples",
"type": "prestashop-module",
"license": "AFL-3.0",
"require": { "php": ">=8.1" },
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.95",
"phpstan/phpstan": "^2.3",
"phpunit/phpunit": "^10.5",
"prestashop/php-dev-tools": "^5.0"
},
"autoload": { "psr-4": { "PrestaShop\\Module\\Pcfitesting\\": "src/" } },
"autoload-dev": { "psr-4": { "PrestaShop\\Module\\Pcfitesting\\Tests\\": "tests/" } },
"config": { "prepend-autoloader": false, "sort-packages": true },
"scripts": {
"lint": "find . -name '*.php' -not -path './vendor/*' -print0 | xargs -0 -n1 php -l -n | (! grep -v 'No syntax errors detected')",
"cs": "php-cs-fixer fix --dry-run --diff",
"cs-fix": "php-cs-fixer fix",
"stan": "phpstan analyse --configuration=tests/phpstan/phpstan.neon --memory-limit=1G",
"test": "phpunit"
}
}
prestashop/php-dev-tools is at version 5, released on 2024-05-23. It installs only the command
vendor/bin/prestashop-coding-standards (with the sub-commands cs-fixer:init and phpstan:init) and the PHPStan extension files;
it does not install PHP CS Fixer or PHPStan itself, which the project requires separately. Its optional dependency
prestashop/header-stamp (the tool that writes license headers into every file) could not be installed together with PHP CS Fixer
3.95, because it supports only Symfony Console up to 6.0 while PHP CS Fixer needs 7.x; if you need license headers, run it from a separate Composer
project. The module code and the vendor/ folder of the development checkout must stay out of the production archive, see
Packaging and distribution.
Source: PrestaShop devdocs: Basic checks
Syntax check
The first check is the cheapest: ask PHP to parse every file. Run it with the oldest and the newest PHP version the module claims to support, because a module that uses PHP 8.4 syntax will break a shop that still runs 8.1 (PrestaShop 9.x still accepts 8.1 as its minimum). The Composer script wraps the command of the developer documentation:
composer lint
# which runs
find . -name '*.php' -not -path './vendor/*' -print0 | xargs -0 -n1 php -l -n | (! grep -v 'No syntax errors detected')
The command prints nothing and exits with code 0 when every file parses; the final grep -v inverts the exit status so that
any line other than "No syntax errors detected" fails the script. It passed on PHP 8.1.34, 8.3.36 and 8.5.12 (the module code and the
test suite were also run on all three, see below).
Coding standard
PrestaShop modules follow the same coding standard as the core, which is distributed as a configuration for PHP CS Fixer by
prestashop/php-dev-tools. The initialisation command copies a ready-made configuration file into the module:
composer require --dev prestashop/php-dev-tools friendsofphp/php-cs-fixer
php vendor/bin/prestashop-coding-standards cs-fixer:init
# File ".php-cs-fixer.dist.php" copied to "./.php-cs-fixer.dist.php"
The generated file is small. It creates the PrestaShop configuration object, takes its finder and restricts it to the module;
the line that excludes dist is an addition, so that the files staged for the archive are not analysed:
<?php
$config = new PrestaShop\CodingStandards\CsFixer\Config();
/** @var \Symfony\Component\Finder\Finder $finder */
$finder = $config->setUsingCache(true)->getFinder();
$finder->in(__DIR__)->exclude(['vendor', 'dist']);
return $config;
On the first run of composer cs (a dry run that prints a diff), 8 of the 8 files of the module needed changes. The rules that fired
were a blank line after the opening PHP tag before declare(strict_types=1) and single-quoted strings:
--- /app/src/Pricing/ClubPricing.php
+++ /app/src/Pricing/ClubPricing.php
@@ -1,4 +1,5 @@
<?php
+
declare(strict_types=1);
namespace PrestaShop\Module\Pcfitesting\Pricing;
--- /app/src/index.php
+++ /app/src/index.php
@@ -1,3 +1,4 @@
<?php
-header("Location: ../");
+
+header('Location: ../');
exit;
Found 8 of 8 files that can be fixed in 0.048 seconds, 20.00 MB memory used
composer cs-fix applied the changes ("Fixed 8 of 8 files"), a second composer cs reported "Found 0 of 8 files that can be
fixed", and the command exits with a non-zero status whenever a file would change, which makes the dry run suitable for CI. The
listings in this page are the files after the fixer. PHP CS Fixer 3.95.27 ran on PHP 8.5.12 without needing its environment
override. The 1.7-era documentation names the file .php_cs.dist, the current one is .php-cs-fixer.dist.php.
Source: GitHub: PrestaShop/php-dev-tools
Static analysis with PHPStan
PHPStan reads the code without running it. For a module its value is that it can analyse the module against the sources of a particular PrestaShop version and report calls to methods that do not exist there, which is the usual way a module breaks between versions. The developer guide’s example is a module that ran on 1.7.0.3 and called a core method that had been removed.
The module’s own classes extend core classes (Module) that PHPStan cannot find in the module folder, so the analysis needs the PrestaShop
sources and a bootstrap file that loads the shop’s autoloader. prestashop/php-dev-tools provides both: the extension file
vendor/prestashop/php-dev-tools/phpstan/ps-module-extension.neon (bootstrap, stubs for Module and Tab, and the constants
that differ per version) and a bootstrap that reads the environment variable _PS_ROOT_DIR_ with the path of the shop. The
command phpstan:init copies a template of the configuration into tests/phpstan/, but that template is written for PHPStan 0.12 and does
not load in PHPStan 2: it still uses the option excludes_analyse, which was renamed excludePaths, and relative paths are resolved from the
folder of the .neon file, so vendor/ pointed to tests/phpstan/vendor. On PHPStan 2.3.3 the following file works:
includes:
- %currentWorkingDirectory%/vendor/prestashop/php-dev-tools/phpstan/ps-module-extension.neon
parameters:
# PHPStan 2: "excludes_analyse" became "excludePaths", and relative paths are resolved from this file,
# so the module root is written with %currentWorkingDirectory%
paths:
- %currentWorkingDirectory%
excludePaths:
- %currentWorkingDirectory%/vendor
- %currentWorkingDirectory%/tests
- %currentWorkingDirectory%/dist
reportUnmatchedIgnoredErrors: false
level: 6
The PrestaShop sources can come from a running shop or, better for CI, from the official image of the version to test. The container
does not need an installed shop, but the files must be on a named volume (a container without a volume exposes nothing through --volumes-from):
# files of the version under test, on a volume, from an image that is never installed
# (the entrypoint copies the shop to the volume on the first start)
docker run -d --name ps -v ps-files:/var/www/html prestashop/prestashop:9.2.0
until docker exec ps test -f /var/www/html/config/defines.inc.php; do sleep 2; done
# analyse the module against those files
docker run --rm --volumes-from ps -v "$PWD:/module" -w /module \
-e _PS_ROOT_DIR_=/var/www/html \
composer:2 php -d memory_limit=1G vendor/bin/phpstan analyse \
--configuration=tests/phpstan/phpstan.neon --no-progress
Result on PrestaShop 9.2.0 with level 6 (the same run against the image prestashop/prestashop:9.1.5 printed "Detected PS version 9.1.5" and no errors):
Detected PS version 9.2.0 [OK] No errors
Three findings from running it:
-
At level
maxthe same module reported two errors, both in the constructor: "Property ModuleCore::$displayName (string) does not accept mixed" (and the same for$description), because the coretrans()method is typed to returnmixed. The developer guide warns that inconsistencies in the core PHPDoc may prevent a module from reaching the highest level; choose a level that the code passes and raise it with a baseline when you can. -
A deliberate error shows the kind of report PHPStan produces. Replacing the body of
getClubPricing()with a call to a method that does not exist and a wrong argument type gave: "Call to an undefined static method Configuration::getValueTwice()" (identifierstaticMethod.notFound) and "Parameter #2 $clubRate of class PrestaShop\Module\Pcfitesting\Pricing\ClubPricing constructor expects float, string given" (identifierargument.type). The code was restored afterwards. -
When the PrestaShop root was mounted read-only (
--volumes-from ps:ro) and the filevar/cache/prod/class_index.phpdid not exist, the bootstrap failed with "Cannot rename … to /var/www/html/var/cache/prod/class_index.php: Read-only file system", because the legacy autoloader writes its class index there. Either mount the volume read-write, as above, or keep it read-only and mount a temporary file system over the cache folder with--tmpfs /var/www/html/var/cache:rw,mode=1777.
To test several versions, run the job once per image tag (the matrix in the CI workflow below uses 9.2.0 and 9.1.5). The image prestashop/prestashop:nightly
(updated daily) tracks the development branch, which was already 9.3.0 at the check date, and is a cheap early warning for removed methods.
Source: PHPStan: Configuration reference
Unit tests with PHPUnit
The core runs PHPUnit 10.5 and the developer guide asks module authors to base unit tests on PHPUnit\Framework\TestCase only, and to put
the logic that can be isolated in classes that do not use ObjectModels, the context or the Db singleton. That is why ClubPricing is a plain
class with its dependencies in the constructor. The test uses PHPUnit 10 attributes for data providers:
<?php
declare(strict_types=1);
namespace PrestaShop\Module\Pcfitesting\Tests\Unit\Pricing;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use PrestaShop\Module\Pcfitesting\Pricing\ClubPricing;
final class ClubPricingTest extends TestCase
{
private ClubPricing $pricing;
protected function setUp(): void
{
$this->pricing = new ClubPricing(40.0);
}
/**
* @return iterable<string, array{float, bool, float}>
*/
public static function beansPrices(): iterable
{
yield 'guest pays the list price' => [8.90, false, 8.90];
yield 'member 250 g' => [8.90, true, 8.01];
yield 'member 1 kg' => [29.90, true, 26.91];
yield 'member Ethiopia 250 g' => [9.90, true, 8.91];
}
#[DataProvider('beansPrices')]
public function testBeansUnitPrice(float $base, bool $member, float $expected): void
{
self::assertSame($expected, $this->pricing->beansUnitPrice($base, $member));
}
public function testNegativePriceIsRejected(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->pricing->beansUnitPrice(-1.0, true);
}
/**
* @return iterable<string, array{string, float, float}>
*/
public static function shippingCases(): iterable
{
yield 'Spain below the threshold' => [ClubPricing::ZONE_SPAIN, 39.99, 4.90];
yield 'Spain at the threshold' => [ClubPricing::ZONE_SPAIN, 40.0, 0.0];
yield 'EU is never free' => [ClubPricing::ZONE_EU, 100.0, 9.90];
}
#[DataProvider('shippingCases')]
public function testShippingCost(string $zone, float $subtotal, float $expected): void
{
self::assertSame($expected, $this->pricing->shippingCost($zone, $subtotal));
}
public function testUnknownZoneIsRejected(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->pricing->shippingCost('US', 10.0);
}
}
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
failOnWarning="true"
failOnRisky="true">
<testsuites>
<testsuite name="unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>
failOnWarning and failOnRisky turn the two classes of problems that tend to be ignored into failures. The suite runs with composer test:
PHPUnit 10.5.66 by Sebastian Bergmann and contributors. Runtime: PHP 8.5.12 Configuration: /app/phpunit.xml ......... 9 / 9 (100%) Time: 00:00.011, Memory: 8.00 MB Club Pricing (PrestaShop\Module\Pcfitesting\Tests\Unit\Pricing\ClubPricing) ✔ Beans unit price with data set "guest pays the list price" ✔ Beans unit price with data set "member 250 g" ✔ Beans unit price with data set "member 1 kg" ✔ Beans unit price with data set "member Ethiopia 250 g" ✔ Negative price is rejected ✔ Shipping cost with data set "Spain below the threshold" ✔ Shipping cost with data set "Spain at the threshold" ✔ Shipping cost with data set "EU is never free" ✔ Unknown zone is rejected OK (9 tests, 9 assertions)
The same lint and the same nine tests also passed on PHP 8.1.34 and 8.3.36 in php:8.1-cli and php:8.3-cli containers that used the
vendor/ folder installed with 8.5, which is what the CI matrix below automates. Code coverage needs a coverage driver that the Composer image does not have;
the developer guide for the core runs XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text with Xdebug installed, and the same applies to a module (not run here).
For more on PHPUnit itself, see the PHP section on testing with PHPUnit and Pest, on static analysis and on PSR standards and coding style.
Integration tests inside a shop
Some behaviour only exists inside the shop: configuration values, the module manager, the database. For those tests PHPUnit runs in the
shop’s PHP process, after the bootstrap has loaded the shop’s configuration, autoloader and database connection. The module checkout is placed
in modules/pcfitesting of an installed shop (as in a real development environment, with the folder mounted or copied), so the shop is
two levels above the tests and no second copy of the module exists.
<?php
declare(strict_types=1);
// The module checkout lives in modules/pcfitesting of an installed shop, so the shop is two levels up.
$root = getenv('_PS_ROOT_DIR_') ?: dirname(__DIR__, 4);
if (!is_file($root . '/config/config.inc.php')) {
fwrite(STDERR, "Run the tests from modules/pcfitesting of an installed shop, or define _PS_ROOT_DIR_.\n");
exit(1);
}
require_once $root . '/config/config.inc.php';
require_once __DIR__ . '/../../vendor/autoload.php';
require_once __DIR__ . '/../../pcfitesting.php';
<?php
declare(strict_types=1);
namespace PrestaShop\Module\Pcfitesting\Tests\Integration;
use Module;
use PHPUnit\Framework\TestCase;
/**
* Needs a shop where the module is installed: see phpunit.integration.xml.
*/
final class ModuleTest extends TestCase
{
private string|false $previous;
protected function setUp(): void
{
$this->previous = \Configuration::get(\Pcfitesting::CONFIG_FREE_SHIPPING_FROM);
}
protected function tearDown(): void
{
\Configuration::updateValue(\Pcfitesting::CONFIG_FREE_SHIPPING_FROM, $this->previous);
}
public function testModuleIsInstalled(): void
{
self::assertTrue(\Module::isInstalled('pcfitesting'));
self::assertInstanceOf(\Pcfitesting::class, \Module::getInstanceByName('pcfitesting'));
}
public function testThresholdComesFromTheShopConfiguration(): void
{
$module = \Module::getInstanceByName('pcfitesting');
self::assertInstanceOf(\Pcfitesting::class, $module);
\Configuration::updateValue(\Pcfitesting::CONFIG_FREE_SHIPPING_FROM, '50');
self::assertSame(4.90, $module->getClubPricing()->shippingCost('ES', 45.0));
\Configuration::updateValue(\Pcfitesting::CONFIG_FREE_SHIPPING_FROM, '40');
self::assertSame(0.0, $module->getClubPricing()->shippingCost('ES', 45.0));
}
}
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="tests/Integration/bootstrap.php"
colors="true"
cacheDirectory=".phpunit.cache"
failOnWarning="true">
<testsuites>
<testsuite name="integration">
<directory>tests/Integration</directory>
</testsuite>
</testsuites>
</phpunit>
The second tests changes a configuration key of the module and restores it in tearDown(), so the suite leaves the shop as it found it. It ran
inside the shop container, as the web server user, with the development dependencies present in the module folder:
docker exec -u www-data -w /var/www/html/modules/pcfitesting ec-prestashop \
php vendor/bin/phpunit -c phpunit.integration.xml --testdox
Module (PrestaShop\Module\Pcfitesting\Tests\Integration\Module) ⚠ Module is installed ✔ Threshold comes from the shop configuration OK, but there were issues! Tests: 2, Assertions: 5, Deprecations: 14.
The 14 deprecations were reported by PHPUnit for the first test, and --display-deprecations showed that they come from the vendor code of another bundled
module (psxmarketingwithgoogle, "Using ${var} in strings is deprecated"), loaded when the module manager scans the modules, not from
pcfitesting. Do not set failOnDeprecation in an integration suite that boots a whole shop. Two environment details cost time and are
worth knowing:
-
Running PHPUnit with a PHP that has no database extension fails in the bootstrap with "Cannot select any valid SQL engine". The
composer:2image has nomysqliorpdo_mysql, so the integration suite has to run in the shop’s own image or in an image that adds those extensions. -
Loading the module’s Composer autoloader twice, from a copy of the module outside the shop and from the installed copy, fails with "Cannot redeclare class ComposerAutoloaderInit…", because both copies have the same hash. Test the module where it is installed.
Smoke tests from the command line
The cheapest integration test needs no PHP test code: use the module commands of the console to install, configure and reset the module and read the result. The developer guide introduced them for continuous integration of modules (from 1.7.2):
bin/console prestashop:module install pcfitesting
bin/console prestashop:config get PCFITESTING_FREE_SHIPPING_FROM
bin/console prestashop:module configure pcfitesting
bin/console prestashop:config get PCFITESTING_FREE_SHIPPING_FROM
bin/console prestashop:module disable pcfitesting
bin/console prestashop:module enable pcfitesting
bin/console prestashop:module reset pcfitesting
bin/console prestashop:config get PCFITESTING_FREE_SHIPPING_FROM
bin/console prestashop:module:list
The configure action applies a self-configuration file. When no file is given, the command looks for a file named self_config.yml in the
module folder and its sub-folders. The file of the module sets the threshold to 45 EUR:
# Applied by: bin/console prestashop:module configure pcfitesting
configuration:
update:
PCFITESTING_FREE_SHIPPING_FROM: 45
Results on PrestaShop 9.2.0, run in this order after installing the module from the archive built below: install "succeeded"; the key was 40;
"Configuration successfully applied" and the key became 45; disable, enable and reset all "succeeded"; and after reset (which
uninstalls and installs again) the key was 40 again. prestashop:module:list showed pcfitesting 1.0.0 Enabled. A self-configuration file can also
update or delete Configuration keys, run .sql files (paths are relative to the file), copy files or URLs, and execute a PHP class that implements
ModuleComplexConfigurationInterface; the actions work per shop with --id_shop and --id_shop_group. The upgrade action, which runs the scripts of the
upgrade/ folder, is the subject of the page on module upgrades.
The core test suites: PHPUnit, Behat and UI tests
The core repository has three families of tests in the folders tests/Unit, tests/Integration and tests/UI. They matter to module developers
for two reasons: a module can be installed in a checkout of the core and the suites run against it, and the way the core writes tests is the style the
community expects. This section is based on the developer guide; it was not run, because it needs a checkout of the core repository and a test database
(see What was run and what was not).
| Command (from the core checkout) | Runs |
|---|---|
|
The PHPUnit unit suite, configured by |
|
The PHPUnit integration suite |
|
The Behat suite |
|
All PHPUnit suites |
|
Creates the database that the integration and Behat suites need |
A single class or method can be run with vendor/bin/phpunit -c tests/Unit/phpunit.xml <file> --filter <method>; --debug without --filter
lists the names to filter by.
Behat describes behaviour in Gherkin scenarios that business people can read, and maps each step to a method of a feature context class.
Scenarios are in tests/Integration/Behaviour/Features/Scenario, contexts in tests/Integration/Behaviour/Features/Context, contexts extend
AbstractPrestaShopFeatureContext and are listed in tests/Integration/Behaviour/behat.yml. The run command is
php -d date.timezone=UTC ./vendor/bin/behat -c tests/Integration/Behaviour/behat.yml --name="free shipping", where --name selects scenarios. When a step is not defined, Behat
offers to generate a snippet. The developer guide’s tutorial builds this scenario for a cart rule with free shipping:
Scenario: With free shipping voucher, there is no shipping fees
Given I have an empty default cart
And there is a product in the catalog named "product1" with a price of 50.0 and 1000 items in stock
And there is a zone named "North America"
And there is a country named "country1" and iso code "US" in zone "North America"
Given there is a carrier named "carrier1"
And carrier "carrier1" applies shipping fees of 5.0 in zone "North America" for price between 0 and 150
Given there is a cart rule named "free4behat" that applies no discount with priority 4, quantity of 1000 and quantity per user 1000
And cart rule "free4behat" offers free shipping
And cart rule "free4behat" has a discount code "free4behat"
When I add 1 items of product "product1" in my cart
Then my cart total should be 57.0 tax included
When I use the discount "free4behat"
Then my cart total should be 50.0 tax included
UI tests drive a browser as a customer or an employee. The stack is Playwright (automation), Mocha (test framework), Chai (assertions) and Faker (data), and the tests need a
working shop. The README in tests/UI of the core explains the setup; one test or folder is selected with TEST_PATH:
TEST_PATH="sanity/02_productsBO/01_filterProducts" URL_FO="https://domain.tld/" npm run test:specific
TEST_PATH="sanity/02_productsBO/*" URL_FO="https://domain.tld/" npm run test:specific
The core’s nightly QA dashboard (https://nightly.prestashop-project.org) runs the campaigns sanity, functional, e2e, regression and autoupgrade in Chromium, Edge and
Firefox against the nightly builds. The dashboard listed develop, 9.0.x, 8.2.x, 8.1.x, 8.0.x and the 1.7.8.x and 1.7.7.x branches at the check date, not 9.1 or 9.2. Two documentation
inconsistencies to be aware of: the page "How to execute unit tests" still calls the functional suite "Puppeteer tests", while the UI tests chapter and the testing resources page
say Playwright; and the module-testing guide says that its section on functional tests will be completed when the tests are available in a dedicated repository, and points to tests/UI of the core in the meantime.
For a module’s own browser tests, Playwright is the tool; see the section on Playwright for the tool itself.
Source: PrestaShop devdocs: UI tests
Continuous integration
The checks above fit one workflow. This one runs for every push and pull request: a matrix of PHP versions for the syntax check and the unit tests, the
coding standard on the newest PHP, PHPStan against two PrestaShop versions using the official images, and the archive build as the last job. It was
checked with actionlint (no findings), and the individual commands were run locally as described in the sections above; the workflow itself was not run on GitHub.
The stages and what feeds each one, from a push to a release on the marketplace:
The first three jobs and the PHPStan job of the workflow below correspond to the left half of the diagram and the last job builds the archive; the smoke test, the integration suite and the Validator are run by hand or by a self-hosted pipeline that has a shop (the section on packaging shows the commands).
name: Module CI
on:
push:
pull_request:
permissions:
contents: read
jobs:
php:
name: Lint, style and unit tests (PHP ${{ matrix.php }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.1', '8.2', '8.3', '8.4', '8.5']
steps:
- uses: actions/checkout@v7
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
tools: composer:v2
- name: Install dependencies
run: composer update --prefer-dist --no-interaction --no-progress
- name: Syntax check
run: composer lint
- name: Coding standards
if: matrix.php == '8.5'
run: composer cs
- name: Unit tests
run: composer test
phpstan:
name: PHPStan against PrestaShop ${{ matrix.prestashop }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
prestashop: ['9.2.0', '9.1.5']
steps:
- uses: actions/checkout@v7
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.5'
tools: composer:v2
- name: Install dependencies
run: composer update --prefer-dist --no-interaction --no-progress
# The shop does not need to be installed: the image only has to provide its files on a volume.
- name: Start a PrestaShop container for its sources
run: |
docker run -d --name ps -v ps-files:/var/www/html prestashop/prestashop:${{ matrix.prestashop }}
until docker exec ps test -f /var/www/html/config/defines.inc.php; do sleep 2; done
sleep 10
- name: Run PHPStan
run: |
docker run --rm --volumes-from ps -v "$PWD:/module" -w /module \
-e _PS_ROOT_DIR_=/var/www/html \
composer:2 php -d memory_limit=1G vendor/bin/phpstan analyse \
--configuration=tests/phpstan/phpstan.neon --no-progress
package:
name: Build the module archive
needs: [php, phpstan]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
tools: composer:v2
- name: Build
run: ./build-zip.sh
- uses: actions/upload-artifact@v7
with:
name: pcfitesting
path: dist/pcfitesting.zip
if-no-files-found: error
Source: PrestaShop devdocs: CI/CD
The developer guide shows a similar pipeline taken from the ps_checkout module and an equivalent one for GitLab. Its examples use the
PrestaShop-maintained actions prestashop/github-action-php-lint and prestashopcorp/github-action-php-cs-fixer, old action versions
(actions/checkout@v2.0.0) and PHPStan 0.12; when you copy from it, replace the actions by the plain Composer scripts above, which are easier to run
locally and do not depend on a third-party action. The pipeline of a production module usually adds the Node build of its assets, the archive build,
the Validator run (see below) and, on a tag, the creation of the release asset.
Packaging and distribution
A module is distributed as a ZIP archive whose single top-level folder has the module’s technical name. It contains the production files only. The
developer guide’s examples remove tests, .gitlab-ci.yml, composer.phar, .editorconfig, Makefile, docker-compose.yml, node_modules and _dev with shell commands;
the script of this module goes the other way round and copies only what ships:
#!/bin/sh
# Builds dist/pcfitesting.zip: production files only, production Composer autoloader, index.php in every folder.
set -eu
NAME=pcfitesting
STAGE="dist/stage/${NAME}"
rm -rf dist
mkdir -p "${STAGE}"
# 1. copy only what ships
cp -a pcfitesting.php config.xml composer.json index.php src config upgrade self_config.yml "${STAGE}/"
# 2. production dependencies and an optimised autoloader (no dev packages)
(cd "${STAGE}" && composer install --no-dev --optimize-autoloader --no-interaction --no-progress)
# 3. protective index.php in every folder, including vendor/
composer global require prestashop/autoindex --no-interaction --no-progress
"$(composer global config bin-dir --absolute --quiet)/autoindex" prestashop:add:index "${STAGE}"
# 4. the archive has the technical name of the module as its only top-level folder
(cd dist/stage && zip -qr "../${NAME}.zip" "${NAME}")
ls -l "dist/${NAME}.zip"
Source: PrestaShop devdocs: CI/CD
composer install --no-dev --optimize-autoloader leaves no PHPUnit, PHPStan or PHP CS Fixer in the archive, and prestashop/autoindex (installed globally, because it conflicts with
the Symfony 7 packages of PHP CS Fixer in the module’s own composer.json) adds a protective index.php to every folder, including vendor/ and the sub-folders of Composer.
The script ran in the composer:2 image, which already contains zip, and produced dist/pcfitesting.zip with 32 entries. Its
content, with the Composer internals of vendor/composer/ left out:
pcfitesting/ pcfitesting/composer.json pcfitesting/composer.lock pcfitesting/config.xml pcfitesting/config/index.php pcfitesting/index.php pcfitesting/pcfitesting.php pcfitesting/self_config.yml pcfitesting/src/index.php pcfitesting/src/Pricing/ClubPricing.php pcfitesting/src/Pricing/index.php pcfitesting/upgrade/index.php pcfitesting/vendor/autoload.php pcfitesting/vendor/index.php
A check of the extracted archive found an index.php in every folder. The archive was then copied into the modules folder of the shop, installed with
bin/console prestashop:module install pcfitesting and put through the smoke test of the previous section. In the back office the same archive is uploaded in Module
Manager > Upload a module, and the module can be installed on a shop of version 9.0 or later because ps_versions_compliancy of the main class says so.
The Validator and the Addons review
Modules sold or listed on the Addons marketplace go through a technical validation. Two tools exist. The Validator (https://validator.prestashop.com) analyses an
archive or a public GitHub repository and produces a report; the project’s documentation says never to submit a module that has not been through it, and the 9.x developer guide still points
modules to it. At the check date the address redirected to a sign-in page. The Marketplace team then applies a checklist of extra points.
The checklist exists in the developer documentation only for 1.7 (modules/sell/techvalidation-checklist); the 8 and 9 trees contain no such page, so the rules below may be out of date for 9.x and should be
confirmed with the Addons team when submitting. They are also the rules a careful reviewer would apply to any module:
-
the module structure is valid, with an
index.phpin each folder, one module per archive, files in the right directories, a compatible license, and core tables are not altered; -
SQL is sanitised and external calls are secured, no risky methods are used (see the page on security), Ajax and cron scripts are secured and implemented as controllers;
-
no PHP errors in debug mode, no debug statements, no commented-out code or empty files, hooks are only used when needed, HTML is in templates, the code is in English, and the module has documentation;
-
payment modules have additional rules: double check
id_cart, pass the provider’s amount tovalidateOrder()when the order is created after approval, and verify the call source with a signature or token.
The 9.x good-practices page adds: prefix everything the module creates (configuration keys, Smarty variables, tables, CSS classes), delete configuration, tables and tabs on uninstall, do not
write into the modules folder (use var/modules/<name>/ and var/cache/<env>/modules/<name>/), do not use external ajax files or obfuscate code, and restrict the loading of back-office assets to the
module’s own pages. The Marketplace itself (https://addons.prestashop.com) answers 403 to anonymous command-line clients, and an account is needed to submit, so
the submission could not be inspected.
Versions, upgrade scripts and automatic updates
A shop shows an "Update" button for a module when a newer version is available, and runs the scripts of the module’s upgrade/ folder in order, starting from the first one whose version is higher than the installed one. Each script
is named upgrade-x.y.z.php and defines a function upgrade_module_x_y_z($module) that returns true on success; new hooks and new database columns belong in those
scripts. The Update Assistant, described next, also updates the modules of a store while it updates the core: it downloads the newest compatible release of each installed
module from local sources, Composer or the Marketplace, uninstalls the modules that are not compatible with the target version and runs their upgrades after the core update. To test that path
without publishing, put an archive of the module in <admin-dir>/autoupgrade/modules/<name>.zip; if the archive is invalid the updater falls back to the Marketplace.
Updating a shop with the Update Assistant
The Update Assistant (technical name autoupgrade, formerly "1-Click Upgrade") updates, backs up and restores a store. It is a module with a web interface in the back office and a console
application. The commands and screens below are described from the developer guide and from the module’s README at tag 7.6.6; the module was not run for this page, for two reasons: the
module is not bundled in the 9.2.0 Docker image (php modules/autoupgrade/bin/console answers "Could not open input file"), and the shop used for the examples had no earlier version to update from and
must not be modified in ways that cannot be undone. Treat the output samples below as the documentation’s, not as a transcript of a run.
Requirements and paths
-
Version 7.6.6 of the module is the first with full support for PrestaShop 9.2, and the update to 9.2 starts from 9.0.x or 9.1.x. A store on a beta or release candidate of 9.2 cannot be updated to the final release with the module: reinstall.
-
The module can be downloaded from Addons, from the Marketplace module in the back office, or from the GitHub releases of the module. For the CLI it only has to be present in
modules/; it does not need to be installed. -
Everything the module writes goes below
<admin-dir>/autoupgrade/:backup/(backups),download/(archives for the local channel),modules/(module archives for tests),logs/(files namedyyyy-dd-mm-hhmmss-process.txt) andtmp/(working files, emptied at the end). -
The updater checks the requirements before it starts. Blocking problems are marked with a red cross, warnings with an orange sign. Typical blockers are PHP safe mode, an outdated module, or the shop not being in maintenance mode (the CLI prints ).
Channels
| Channel | Meaning |
|---|---|
|
The version that PrestaShop’s API recommends for the store’s PHP version (the default). It is the best tested and may be older than the latest release. |
|
The newest PrestaShop version compatible with the store’s PHP version. |
|
An archive that you provide: a |
From the back office
After installing the module and opening its configuration page, choose "Update your store". The path has five steps: Version choice (the three channels above, with requirement checks), Update options,
Backup, Update and Post-update. The options of the second step are: deactivate non-native modules (default yes, and ignored for PrestaShop 9 and later according to the CLI help), regenerate
email templates (default yes, which overwrites customised templates), and disable all overrides (default no according to the back-office guide). The module’s README lists the
defaults for the same option differently (true for PS_DISABLE_OVERRIDES), so set the option explicitly. The backup step offers a complete backup (files, database and images, the default), a partial one without images, or none; always
keep a complete backup and one made outside the module. The update then runs unattended and shows a progress log.
From the command line
The CLI is the way to automate updates in a pipeline and to avoid web-server limits (max_execution_time, memory_limit of php-fpm). Every command takes the admin folder name as its last argument:
php modules/autoupgrade/bin/console update:check-new-version <admin-dir>
php modules/autoupgrade/bin/console update:check-requirements <admin-dir>
php modules/autoupgrade/bin/console update:check-modules <admin-dir>
php modules/autoupgrade/bin/console backup:create --include-images=1 <admin-dir>
php modules/autoupgrade/bin/console update:start --config-file-path=/path/to/config.json <admin-dir>
php modules/autoupgrade/bin/console backup:list <admin-dir>
php modules/autoupgrade/bin/console backup:restore --backup=<backup-name> <admin-dir>
The commands of the module (all documented in the developer guide) are backup:create, backup:list, backup:restore, backup:delete, update:check-new-version,
update:check-requirements, update:check-modules and update:start, and the usual Symfony Console options apply (-n for no interaction, -v, -vv, -vvv for verbosity). update:check-new-version
prints a table of versions with the columns version, channel, type (major, minor, patch) and information. update:check-modules lists the installed modules that need attention, and with -v shows the compatibility details
taken from the Marketplace API. backup:restore and backup:delete run in interactive mode when no backup name is given.
The options of update:start are:
| CLI option | Configuration file key | Meaning |
|---|---|---|
|
|
Channel and, for |
|
|
Chain the update steps automatically (default) or stop after each task and log the command for the next one |
|
|
Disable modules installed after the shop was created (ignored for 9 and later) |
|
|
Uninstall modules that are not compatible with the target version (documented in the module’s README) |
|
|
Regenerate email templates (customisations are lost) |
|
|
Disable all class and controller overrides during the update |
|
JSON file with the same keys |
|
|
Advanced: the task to start from (default |
{
"channel": "online_recommended",
"PS_AUTOUP_CUSTOM_MOD_DESACT": 1,
"PS_AUTOUP_REGEN_EMAIL": 0,
"PS_AUTOUP_KEEP_IMAGES": 1,
"PS_DISABLE_OVERRIDES": 1
}
The example sets the keys of the README’s sample file for Pocket Coffee: update to the recommended version, keep the shop’s customised email templates, keep images in the backups and disable overrides during the run (and re-enable them afterwards after checking them).
What the module does
The README documents the tasks. A backup is three tasks and an update is ten, and a failed update offers a restore, which is the reverse of the backup:
UpdateFiles runs several times (the first call builds the list of files, the next ones copy parts of it), UpdateDatabase runs the SQL files of the target version, clears caches and updates languages, UpdateModules updates module files and runs their migrations, and CleanDatabase removes obsolete or inconsistent data that comes from using the shop, not from the update.
Before and after
The update is a production change and deserves a routine. Before: update to the latest patch of the current branch first, move a copy of the shop to staging and run the whole procedure there, check the PHP version against the
target (see the page on versions and support), run update:check-modules, make a complete backup with the module and a second one with your own tools (files and database),
enable maintenance mode and add your IP. After: refresh the back office and sign in again, open the Module Manager and re-enable modules that were disabled, check the front office (create an account, add a product, place an order,
pay), disable maintenance mode, review the modules that were uninstalled because they were incompatible, regenerate thumbnails in Preferences > Images if images are missing, and run the smoke tests of your own modules. If the back office
cannot be opened, enable debug mode in config/defines.inc.php with _PS_MODE_DEV_, and restore from the backup in <admin-dir>/autoupgrade/backup through the CLI when needed.
On an immutable image running on several pods the update is a new image plus a one-off upgrade job, not the assistant run from the back office; see Cloud: Releases and Upgrades.
What was run and what was not
Run on a local PrestaShop 9.2.0 shop (the official Docker image with MySQL 8.4) and in composer:2 (PHP 8.5.12), php:8.1-cli and php:8.3-cli containers: php -l on all module files on PHP 8.1, 8.3 and 8.5; PHP CS Fixer
with the PrestaShop standard (before and after the fix); PHPStan 2.3.3 at levels 6 and max, with an injected error, against the installed shop and against uninstalled prestashop/prestashop:9.2.0 and 9.1.5 containers on named volumes; the nine unit
tests on PHP 8.1, 8.3 and 8.5; the two integration tests inside the shop container; the archive build, its content check and its installation; the console smoke test (install, configure with self_config.yml, disable, enable,
reset); actionlint on the workflow; and the exact PHPStan step of the workflow against the volume container.
Not run: the core’s Behat and Playwright suites (they need a checkout of the core repository, a test database and, for the UI tests, Node.js and Playwright browsers); code coverage (no coverage driver in the images used); the CI workflow on GitHub itself; the Validator and the Addons submission (an account is required); the Update Assistant in the back office and from the CLI (see above), so its outputs are the documentation’s; and PrestaShop Flashlight, the alternative Docker image for development and testing mentioned in the developer guide. The test module and the test files were removed from the shop and from the containers afterwards.