Testing HTML Components

For a WordPress (classic) theme using get_template_part generally provides a good level of organization. But for a large, enterprise-level website, I organize my server-side rendered UI elements into what I call "HTML Components". It's somewhat inspired by React.

Components let you split the UI into independent, reusable pieces, and think about each piece in isolation.

Since we prefer OOP for this project, there's an interface for the components:

interface Component
{
public function render(): string;
}

A component could be as simple as this one, but typically there's more to it:

class Infobox implements Component
{
public function render() {
return '<div class="infobox">...</div>';
}
}

There's a template renderer to avoid mixin HTML in the PHP class and achieve a better separation of concerns:

interface TemplateRenderer
{
public function render(string $templateName, array $data = []): string;
}

Besides passing the data to the template files, components can retrieve icons, derive state from props (React jargon), etc., do what's needed.

Here's a stripped-down but "real" component:

class Component {
use ResourcesDirTemplateFileTrait;

public function __construct(
readonly private Props $props,
readonly private TemplateRenderer $templateRenderer,
readonly private Svg\Loader $svgLoader
) {
}

public function render(): string
{
return $this->templateRenderer->render(
$this->templateFile(),
[
TemplateArgs::TITLE => $this->props->title,
TemplateArgs::CONTENT => $this->props->content,
TemplateArgs::DEFAULT_IS_OPEN => json_encode(!$this->props->title),
TemplateArgs::ICON_TOGGLE => $this->props->title
? $this->svgLoader->byName('arrow-caret-down')
: '',
]
);
}
}

While these components do not contain "too much logic", I strive to provide tests for them.

Testing

When testing these components, I'm interested in two things. First, if the components are rendering the output (HTML) I expect. Second, if the data passed to the template files is what it should be.

Testing the output also tests the data indirectly. There's an overlap between the two types of tests, but they can't be exchanged, and they focus on different aspects.

I made the rule to always write tests "for the HTML", but only test the data directly for more "complex" components.

"Snapshot" testing

PHPUnit had the assertEqualXMLStructure method, which made it easy to compare two DOM structures. There are plans to reintroduce it.

Until then, I'm comparing two "HTML" strings. This has its shortcomings. For example, the order of the attributes has to match; spacing can cause problems, etc.

// This fails
$this->assertEquals(
'<div class="infobox" id="infobox-alpha"></div>',
$infobox->render() // <div class="infobox-alpha" class="infobox">...</div>
);

To overcome this, I have a custom assertion method that does some normalization before comparing the strings:

class InfoboxHtmlSnapshotTest extends HtmlSnapshotTestCase
{
public function testDefaultOutput()
{
$infobox = new Infobox(...);

$this->assertHtmlEquals(
file_get_contents(__DIR__ . '/snapshots/default.html'),
$infobox->render()
);
}
}

I'm doing something similar to what Jest calls snapshot testing but the "snapshots", in my case, are created and maintained manually.

Testing the $data with mocking

My initial approach for testing the passed data to the template renderer was mocking it and setting up expectations.

public function testNoIconAndIsOpenWhenNoTitle()
{
$templateRendererMock = $this->createMock(TemplateRenderer::class);

$templateRendererMock->expects($this->once())
->method('render')
->with(
$this->stringContains('index.php'),
[
'title' => '',
'content' => 'In 2009, the International Air Transport Association (IATA) ...',
'initialIsOpen' => 'true',
'iconToggle' => '',
]
);

$infobox = new Infobox(
...,
$templateRendererMock,
...
);

$infobox->render();
}

This approach started to be messy when I created components that rendered child components.

In those cases, the render method was called multiple times. Switching and using the withConsecutive turned out to be hard to reason about and follow.

Using a test implementation

The contract of the TemplateRenderer had to be satisfied, so there was no way around returning something else than a string.

But the option to create a test implementation for the template renderer was open. So that's what I did.

$customImplTemplateRenderer = new class implements TemplateRenderer
{
public function render(string $templateName, array $data = []): string;
{
return json_encode($data);
}
};

This implementation simply returns the data in a JSON encoded string format, which, once decoded, allows for good old array comparison:

$infobox = new Infobox(
...,
$customImplTemplateRenderer,
...
);

$this->assertEquals(
[
'title' => '',
'content' => 'In 2009, the International Air Transport Association (IATA) ...',
'initialIsOpen' => 'true',
'iconToggle' => '',
],
json_decode($infobox->render(), true)
);

When nesting HTML Components, it's just a matter of comparing a multidimensional array, and there's no need for writing the boilerplate for creating a mock.

ELS, product pricing based on pricing policy rules

Electrosystems (ELS) specializes in importing, developing, and distributing electronic security systems in the B2B sector. They have been selling the most advanced technological products at competitive prices in Greece since 2007.

Background

Since it's a long-standing company, they have a robust, established workflow with an ERP system at its core to manage customers and orders.

ELS has an online shop that is used only for creating preliminary orders without asking for payment. The finalization of orders happens offline.

Since they have various partnership deals, their customers get a customized pricing offer for the products. When customers log in to the shop, they see those prices. (They do now!)

Problem

While the previous team of developers was able to set up WooCommerce and the data importing and synchronization, they failed to implement the pricing logic.

The ERP has the option to expose the customer and product data. It does not offer an endpoint to retrieve a product price based on a customer, only the underlyinushg raw rules data that is used for the different discount policies.

The partial and failed pricing policy implementation created more confusion for the customers than helped them.

With customers complaining about seeing different prices than in their final offers, ELS wasted countless hours in ad-hoc verifications and debugging that seemed to go nowhere.

Mistrust and high response time started to characterize the communication with the original developers.

Solution

...

Inpsyde Modularity Properties

If you are building a WordPress plugin, you typically need to reference specific values repeatedly—for example, the version or base path. The same is true for a theme or a library.

A pretty common approach is to define these as constants. There are boilerplates that suggest this approach.

If you have a "main" plugin class, you might have these "constants" as properties too.

This approach immediately introduces duplication because some of the values are typically also found in the Plugin Header, like the version. Hack together a release script and problem solved(?!).

The various Propreties classes of Inpsyde Modularity deal with this topic. They centralize the "constants" in one place, but it also conveniently sets them up.

Instead of defining typical constants one by one:

define('PLUGIN_NAME_VERSION', '1.0.0');
define('PLUGIN_NAME_PATH', __FILE__);

you can do this:

use Inpsyde\Modularity\Properties\PluginProperties;

$pluginPropreties = PluginProperties::new('/path/to/plugin-main-file.php');

and it will parse the plugin's header and set up the properties. You can then access the values by calling methods on the object:

$pluginPropreties->version(); // version of the plugin
$pluginPropreties->name(); // name of the plugin

If you are using Modularity for a theme, you have the ThemeProperties class:

$themeProperties = ThemeProperties::new('/path/to/theme-directory/');

For completeness, there are two more classes, the LibraryPropreties and the BasePropreties.

The BasePropreties doesn't parse any file; it expects you to pass some values manually to it. The LibraryProperties parses a composer.json file. More about these two somewhere else.

For more detailed information, check the documentation.

Next up the ...

Get an SSL certificate for the PHP Apache Docker image variant with Certbot

When prototyping or hacking on something with PHP, I use the PHP Docker image variant that includes Apache.

Even if it's a toy project, I get an SSL certificate for its domain when I put it online. Here's yet another step-by-step instruction on how to do it. It just focuses on the absolute minimal.


We are going to have a Dockerfile with the following:

FROM php:8.1-apache

RUN a2enmod ssl

CMD ["apache2-foreground"]

This is equal to the docker container run php:8.1-apache with the difference that it enables the ssl module. By default, Apache does not have that module enabled.

To build and tag the image:

docker image build -t app .

Let's follow the convention and put our main index.php file in the public folder.

We need two folders related to the process of getting the certificates. One for the actual certificates (certs), the one it's more like a temporary folder (data).

So far, we have this:

.
├── Dockerfile
├── public/index.php
├── letsencrypt/certs/
├── letsencrypt/data/

We also need to customize the default Apache configuration.

The configuration before the certificate will look different than the one after we have the certificates. Instead of modifying the contents of the config file, we can prepare them in advance:

.
├── apache2/000-no-ssl-default.conf
├── apache2/000-default.conf

Here's the content of the 000-no-ssl-default.conf:

<VirtualHost *:80>
    DocumentRoot /var/www/html/public

    Alias /.well-known/acme-challenge /var/www/letsencrypt/data/.well-known/acme-challenge
</VirtualHost>

80 is the default port for HTTP. We set the root to the public instead of the default /var/www/html/ that Apache recommends.

The other part is creating an "alias", which is saying when the /.well-known/acme-challenge URL is loaded, then load whatever is located at the specified path.

With all this, we can start the container with the bind mounts:

docker container run \
-d \
-p 80:80 \
-v ${PWD}/public:/var/www/html/public \
-v ${PWD}/apache2/000-no-ssl-default.conf:/etc/apache2/sites-enabled/000-default.conf \
-v ${PWD}/letsencrypt/:/var/www/letsencrypt \
app

If we don't have a domain, we can get a subdomain free from FreeDNS and point it to the server's IP address.

The next step is getting a certificate issued by Let's Encrypt with Certbot.

Let's Encrypt is a free, automated, and open certificate authority (CA), run for the public's benefit.

Certbot is a free, open source software tool for automatically using Let's Encrypt certificates on manually-administrated websites to enable HTTPS.

docker container run \
-it \
--rm \
-v ${PWD}/letsencrypt/certs:/etc/letsencrypt \
-v ${PWD}/letsencrypt/data:/data/letsencrypt \
certbot/certbot certonly \
--webroot \
--webroot-path=/data/letsencrypt \
-d your-domain.com \
--dry-run

This command might look especially long and complicated, but until the certbot/certbot part it is just the most common Docker flags, and after that are specific flags for the tool.

Here are the relevant pages from the docs:

We bind mount the letsencrypt folders to both containers. The reason is that the Certbot has to have access to write to it, and the Apache has to have access to read from it.

We run it first with the --dry-run to ensure everything runs fine before issuing the certificates. Repeatedly asking for the certificate and running into problems when doing it will ban the domain for several days.

We will be asked to confirm a few things; we just need to follow the instructions. If all is good, we can rerun it without the --dry-run.

We should now see a bunch of files and folders in the /letsencrypt/certs/ folder.

Now that we have the certificates, we should use the 000-default.conf. After stopping the current container, we can start it again but this time with:

docker container run \
-d \
-p 80:80 \
-p 443:443 \
-v ${PWD}/public:/var/www/html/public \
-v ${PWD}/apache2/000-default.conf:/etc/apache2/sites-enabled/000-default.conf \
-v ${PWD}/letsencrypt/:/var/www/letsencrypt \
app

The content of the config can be this simple:

<VirtualHost *:80>
    Redirect / https://your-domain.com/
</VirtualHost>
    
<VirtualHost *:443>
    DocumentRoot /var/www/html/public

    SSLEngine on
    SSLCertificateFile "/var/www/letsencrypt/certs/live/your-domain.com/fullchain.pem"
    SSLCertificateKeyFile "/var/www/letsencrypt/certs/live/your-domain.com/privkey.pem"
</VirtualHost>

We leave the 80 accessible, but we redirect all requests to the HTTPS version. In the HTTPS section, we specified the paths for the certificates.

Laravel inspired package discovery for HTTP Fn

Usually, there's some wiring to do when you want to use packages with frameworks. Laravel manages to reduce the friction, in most cases, to a minimum with their package discovery feature.

When someone installs your package, you will typically want your service provider to be included in this list. Instead of requiring users to manually add your service provider to the list, you may define the provider in the extra section of your package's composer.json file.

Once your package has been configured for discovery, Laravel will automatically register its service providers and facades when it is installed, creating a convenient installation experience for your package's users.

This means that typically you don't have to do anything else but run composer require my-fav-package, and you are good to go.

I wanted the same DX for HTTP Fn.


How does it work

Because we don't have to think about it, it might appear magical compared to other frameworks where we have to take some additional steps. As long as a package developer follows the convention, it's no-config for us, the consumers.

The "package discovery" happens after a package is installed or removed with Composer. When something happens (events) in Composer, we can run some arbitrary code (command). Laravel is taking advantage of this.

Practically: after the installation or removal of a package, Laravel (1) loops over all the packages, (2) filters them down to the ones with the discoverable key, and (3) saves the list of "discovered packages". When Laravel is booted, (4) the stored list of discovered packages is merged with the explicitly defined packages, and then Laravel does what it does.

Here's how the "package discovering" looks like for HTTP Fn:

<?php

declare(strict_types=1);

namespace HttpFn\App\Composer;

use Composer\Script\Event;

class GenerateAutoDiscoveredFnPackageProviderJsonFile
{
public const JSON_FILE_PATH = 'tmp/fn-package-providers.json';
public const EXTRA_NAMESPACE_KEY = 'http-fn';
public const EXTRA_FN_PROVIDER_KEY = 'fnProvider';

public static function run(Event $event): void
{
$providers = [];
$localPackages = $event->getComposer()->getRepositoryManager()->getLocalRepository()->getPackages();

if (empty($localPackages)) {
return;
}

foreach ($localPackages as $package) {
$extra = $package->getExtra();
$provider = $extra[self::EXTRA_NAMESPACE_KEY][self::EXTRA_FN_PROVIDER_KEY] ?? false;

if (!$provider) {
continue;
}

$providers[] = $provider;
}

if (empty($providers)) {
return;
}

file_put_contents(
self::JSON_FILE_PATH,
json_encode(array_unique($providers))
);
}
}

In the composer.json this is registered as a command for the post-autoload-dump event:

{
"scripts": {
"post-autoload-dump": "HttpFn\\App\\Composer\\GenerateAutoDiscoveredFnPackageProviderClassNameJsonFile::run"
}
}

And here is how the "package registration" looks like:

function withAutoDiscoveredFnPackageProviders($fnPackageProviders = []): array
{
$jsonFile = '../' . GenerateAutoDiscoveredFnPackageProviderJsonFile::JSON_FILE_PATH;
$jsonData = file_exists($jsonFile) ? file_get_contents($jsonFile) : false;

if ($jsonData !== false) {
$autoDiscoveredFnPackageProviders = json_decode($jsonData, true) ?? [];

$fnPackageProviders = [
...$fnPackageProviders,
...$autoDiscoveredFnPackageProviders,
];
}

return $fnPackageProviders;
}

$fnPackageProviders = withAutoDiscoveredFnPackageProviders();

After the combined list of package providers, it's a matter of looping over them, checking if they are the right type, etc., and calling their boot method.


In Laravel, all this is a bit more complex, for good reasons. Check the PR introducing this feature if you are curious.