In this article I’ll explain how Docker can simplify testing, when engineering WordPress components. But before I even dive into WordPress testing with Docker, let’s just go through some basic stuff, to make sure we’re on the same page:
TL;DR? Click here to skip to the how-to part.
A list of lists of things to worry about as a WordPress developer
So you’ve built your theme or plugin and “It Works™”.
Being a pro ninja developer, you’ve thought of, analyzed, designed, developed, and tested, everything. Here are some issues you’ve had to address, in no particular order:
- Your UI has been optimized with respect to the user experience. Any Settings pages or Customizer tabs are in place and easy to navigate. Perhaps you’ve even made use of the Admin Pointers API. Good job! Your efforts will mean less effort in support later on.
- You’ve interfaced with the right WordPress APIs for the job, using recommended practices. Perhaps you’ve extended the TinyMCE editor to add a button or two for your special functionality. Perhaps you’ve even catered for people who use WordPress with the CKEditor. Or perhaps you’ve decided that they’re not worth the extra effort. In any case, you’ve made your educated guesses in your analysis, and you have followed through in your implementation.
- You’ve made sure that your product plays nicely with prominent plugins and themes in the wild jungle that is the WordPress ecosystem. You’ve tested your deliverable side-by-side with titans such as Visual Composer, WooCommerce and the likes, and you’re satisfied with the synergy.
- You’ve made correct use of HTML5, and CSS3, while silently serving polyfills to the Internet Explorer lusers and anyone still browsing the web on an Android Gingerbread.
- You’ve used the built-in caching mechanisms appropriately. Remember, cache invalidation is one of the only two hard problems in Computer Science. You’ve made sure that power users can tweak your cache setting, while novices don’t need to know or care.
- If you are creating your own tables on the database, you have a plan in place for upgrading the schema in future versions. Perhaps you’re saving the installed version number on the
wp_options
table? Good job! Very pro move! - You’ve hooked into
plugin_activation_hook()
and friends. You’ve come to terms with the humbling fact that users might, at some point, and for whatever reason, choose to uninstall your product! You’re kind enough to clean up after yourself. The community thanks you. - Your slugs don’t clash with any other well-known slugs. Also, your post types don’t clash with other known post types. Remember, naming things is the other hard problem in Computer Science! Congratulate yourself for having tackled it, within the context of your project.
- Your design reflects the WordPress mantras. Study them here.
- You’ve thought about code quality. Your code conforms to the PHP and JavaScript WordPress coding standards. You only use the good parts of JavaScript. Also, your code is readable and idiot-proof enough that even a non-programmer can hack it without breaking too much stuff. Perhaps you’ve incorporated PHP_CodeSniffer, JSHint and friends into your IDE or build workflow. Bonus points for using PHPdoc comments on any APIs you expose.
- You’ve invested time in tooling/automation. Perhaps you use grunt or gulp or some other modern task management tool to automate testing, packaging (code, assets, translations, documentation, etc), deployment; you know, all the boring stuff. Perhaps you’re managing your dependencies with composer. The bottom line here is: you understand that shorter and easier iterations make you a more productive and insightful developer. If you can test and build a new release with one click, pat yourself on the back! You’re amazing and your effort will save you heaps of trouble later on.
- You’ve done at least something about testing. Perhaps you’ve decided your plugin’s functionality is simple enough that some smoke testing is enough. Or you have a rigorous “script” for the human that tests functionality via the browser. Bonus points if you’ve used a solution such as PHPUnit, perhaps with some WP_Mock on the side. Or perhaps you’ve even gone as far as doing automated browser testing on real devices and browsers, using karma or some other bleeding-edge solution. You’re insane! I love you!
- To double-check you’ve thought of everything, you’ve scoured the web for other listicles of last-minute things to check. Here’s an example. Go find more.
- If it’s a contract job, your client is happy. If it’s not, you’ve done your corridor testing and other WordPress developers or end users have given you their feedback.
(Version) hell is other programs
If you’ve tackled all of the above and more, surely you’re “done”, right?
WRONG! Get ready to go through your test scenarios and checklists again on WordPress 4.0.10. And what about WordPress 3.7.13? Perhaps it’s too old to support, but you want to know if you can make the claim that your product works on it, right? And all of this has to work on PHP 5.4. Perhaps also on PHP 5.3? If you’ve done any custom DB stuff, do your SQL queries work as expected on MySQL 5.5? How about MySQL 4.x? And for every one of these version combinations, your product must work on the multitude of browsers and devices that you’re testing on. Oh, my!
Every line of code you’ve written is a potential point of failure on some combination of platform, user data, and other pieces of software that share the same platform and data. Programming is easy, software engineering is hard!
Remember, on day 1 when you go live, you will have a number of bugs coming in from users who use your product in combination with themes and plugins you haven’t even thought of. Your code will run on server environments so stupidly configured, you’ll want to find out where their administrator lives just so you can go punch them in the face. No matter how large or small your dev team is, you’ll have to tackle bugs very quickly in the first few days. On the internet, first impressions matter. So:
The more bugs you can identify before your users do, the better for the reputation of your product and team.
The good news is that if you’ve been proactive with your tooling and test automation (and, being a professional developer, why wouldn’t you?), the easier and quicker it will be to repeat your tests on different platforms. But you still need to setup these platforms and deploy and test your product on them. Doing this using Virtual Machines is ultra-tedious, error-prone, and slow.
Enter docker
The age-old problem of software development can be summarized in the phrase “But it worked on my computer”. Today this problem has a solution, and it is called Docker. If you’ve been living under a rock for the past few years, go read what Docker is and how it works before we move on. Go on, I’ll wait here!
Traditionally, lightweight containers are used like so: the developer “dockerizes” their product and delivers the resulting container to the system administrator, thus simplifying the deployment phase as well as any software migrations. Because deployment instructions are written by the developer in an unambiguous, repeatable, machine-executable form, this also eliminates human error and the aforementioned need for anyone to punch anyone in the face.
This is not what we’ll do here. In the context of WordPress component development, we will use a lightweight container to test, not deploy.
What I did (and how you can do it too)
Let me just come out and say it: I do not know, or want to know, how to install several versions of PHP side-by-side on one system. There is a multitude of how-to articles on the topic. I hate all of them because I do not understand Linux package management their authors are all stupid and lame. Instead, I started from this article about a multi-PHP Docker image. You can optionally go read it, if you promise to come back here once you’re finished :-p
The bottom line is that there is a Docker image that already does half of what we want: It provides an environment where we can easily switch between a reasonable selection of popular PHP versions.
I then extended the image so that it installs WordPress, and my plugin, and runs it.
My plugin does not directly access the MySQL database. I only store data using the Options API. Therefore I ran a single version of a MySQL docker container like so:
$ docker run --name wordpress-mysql -e MYSQL_ROOT_PASSWORD=password -d mysql:5.5
Now that our MySQL 5.5 database is up and running, let’s write a Dockerfile that describes an image based on that base image:
FROM eriksencosta/php-dev:latest
It is traditional to include some metadata:
MAINTAINER Alexandros Georgiou <alex.georgiou@gmail.com> LABEL Description="Container for testing themes and plugins on all the PHPs and WordPresses" Version="1.0"
Then I declare two environment variables where I set the desired PHP and WP versions for my test. Note the use of the ENV
instructions. (We can later override these values from the command line without the need to rebuild the image).
ENV WP_VER=4.0 ENV PHP_VER=5.3
We will want to be able to use php
, phpenv
, composer
, etc. so let’s set the environment’s PATH
to something sensible:
ENV PATH /opt/phpenv/shims:/opt/phpenv/bin:/opt/php-build/bin/:/opt/composer/vendor/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Now let’s use the WORKDIR
instruction to say to docker that we want to do some stuff in the root user’s home directory. This is analogous to the cd
command.
WORKDIR /root
Here we will download and install the WP-CLI.
Having a command line interface to our WordPress instance is essential because we need an automated way (i.e. script) to deploy and test our software. (If you are a WordPress developer and you do not know or are not already using WP-CLI, please stop reading this blog, step away from the computer, sell your computer, and change your career to something other than engineering.) Part of being an engineer is about doing things in an automated, repeatable and testable way.
Because my project is based on grunt, I use apt-get
to install node
and npm
. This will allow me to run stuff like npm install
and grunt test
later on. I also install some basic utilities, so when a bug shows up, I can drop into the container’s shell and investigate. Here I installed the MySQL client, Midnight Commander (my favorite orthogonal file manager), and two text editors, vim
and nano
. Depending on your project’s needs and personal preferences you will probably want to have other things installed via apt-get
, but I bet you will need wp-cli
.
RUN \ curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar && \ chmod +x wp-cli.phar && \ mv wp-cli.phar /usr/local/bin/wp && \ curl -sL https://deb.nodesource.com/setup | bash - && \ apt-get install -y nodejs npm mysql-client vim nano mc
Note the backslashes. We’ve stringed together the shell commands into one as a performance optimization. Every command creates a new intermediate image in docker. We could just as well have issued commands in separate RUN instructions, but we know better than that!
The following ADD
instruction says that any files in the same directory as this Dockerfile are to be copied into the image’s root user home directory.
ADD . /root
The way I’ve set things up, this will bring in a script that I call install.sh
. This will carry out the deliverable’s deployment and possibly add content and run any automated testing we have available. Remember that the CMD
instuction defines the utility command of the container. You can later override this from the command line. This is very useful for debugging.
CMD /bin/bash -c /root/install.sh
In that same home directory I have a plugins
directory. This will contain the zip files for any plugins I want to install. My themes
directory will contain the themes that I will want to install. I have a wp
directory where I have downloaded tarballs of every WordPress version since 3.7. Adding all of the versions into the image, we make sure that we can use our environment variable to switch between images without rebuilding the docker image. Arguably one could use the wp-cli core install
command but that would involve re-downloading stuff every time.
Of course we need to make sure that we can access the image’s nginx web server:
EXPOSE 80
Let’s look at the entire Dockerfile
again before we dive into install.sh
:
FROM eriksencosta/php-dev:latest MAINTAINER Alexandros Georgiou <alex.georgiou@gmail.com> LABEL Description="Container for testing themes and plugins on all the PHPs and WordPresses" Version="1.0" ENV WP_VER=4.0 ENV PHP_VER=5.3 ENV PATH /opt/phpenv/shims:/opt/phpenv/bin:/opt/php-build/bin/:/opt/composer/vendor/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin WORKDIR /root RUN \ curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar && \ chmod +x wp-cli.phar && \ mv wp-cli.phar /usr/local/bin/wp && \ curl -sL https://deb.nodesource.com/setup | bash - && \ apt-get install -y nodejs npm mysql-client vim nano mc ADD . /root CMD /bin/bash -c /root/install.sh EXPOSE 80
Neat!
We then start writing our install script. Now, dockerfiles are a relatively new thing and warrant some explanation, but fortunately we all speak fluent bash, so I hopefully don’t need to say much about the following code.
Remember how we’ve set environment variables for WordPress and PHP? Now is the time to use them.
#!/bin/bash -x echo "Installing on WordPress ${WP_VER} on PHP ${PHP_VER}" phpenv global $PHP_VER
We’ve switched to the right PHP, now on to install the right version of WordPress from our directory of WordPress tarballs:
cd /var/www tar xfv /root/wp/wordpress-${WP_VER}.tar.gz touch /var/www/wordpress/wp-content/debug.log chown -R root:root /var/www/wordpress
Next, we create the wp-config.php
file, with the help of WP-CLI
. We will want to enable logging, to spot any errors resulting from running our code on different versions. We will also expose the image’s 80 port to port 81 of our local machine. This is necessary if you are already running a web server on your dev machine. Change 81 to any port that you have free.
cd /var/www/wordpress wp core config --allow-root --dbhost=db --dbname=wpdb --dbuser=root --dbpass=password --extra-php <<PHP define( 'WP_DEBUG', true ); define( 'WP_DEBUG_LOG', true ); define('WP_HOME','http://localhost:81/wordpress'); define('WP_SITEURL','http://localhost:81/wordpress'); PHP
Throughout my script you’ll notice the liberal use of --allow-root
in wp-cli
commands. In our container we do stuff as the user root, hence the need for the switch.
Let’s make sure that we start with a clean database:
wp db drop --allow-root --yes wp db create --allow-root wp core install \ --allow-root \ --url=http://localhost:81 \ --title="Testing on WordPress ${WP_VER} on PHP ${PHP_VER}" \ --admin_user=admin \ --admin_password=password \ --admin_email=alex.georgiou@gmail.com
Now let’s install the plugins we want in our test:
wp plugin install /root/plugins/*.zip --allow-root wp plugin activate --all --allow-root
Perhaps you’ll need to import some content into your WordPress instance. Here we import a post that could contain new shortcodes or whatever:
wp post create --allow-root --post_status=publish --post_type=page --post_title='Test post' /root/post.txt
Depending on your project’s needs, you can go wild here. Use wp-cli
to tweak your test site as needed. This is also where you can run your automated tests.
mkdir /root/src cd /root/src git clone http://dev.lan/git/myproject.git cd myproject npm install grunt phpunit
Now let’s make sure that nginx is running, and that we are viewing the debug logs and web server logs:
webserver restart tail -f /var/log/nginx/*.log /var/www/wordpress/wp-content/debug.log
That’s it!
Let’s review the install script in its entirety:
#!/bin/bash -x echo "Installing on WordPress ${WP_VER} on PHP ${PHP_VER}" phpenv global $PHP_VER cd /var/www tar xfv /root/wp/wordpress-${WP_VER}.tar.gz cp /root/.htaccess /var/www/wordpress touch /var/www/wordpress/wp-content/debug.log chown -R root:root /var/www/wordpress cd /var/www/wordpress wp core config --allow-root --dbhost=db --dbname=wpdb --dbuser=root --dbpass=password --extra-php <<PHP define( 'WP_DEBUG', true ); define( 'WP_DEBUG_LOG', true ); define('WP_HOME','http://localhost:81/wordpress'); define('WP_SITEURL','http://localhost:81/wordpress'); PHP wp db drop --allow-root --yes wp db create --allow-root wp core install \ --allow-root \ --url=http://localhost:81 \ --title="Testing on WordPress ${WP_VER} on PHP ${PHP_VER}" \ --admin_user=admin \ --admin_password=password \ --admin_email=alex.georgiou@gmail.com wp plugin install /root/plugins/*.zip --allow-root wp plugin activate --all --allow-root wp post create --allow-root --post_status=publish --post_type=page --post_title='Test post' /root/post.txt mkdir /root/src cd /root/src git clone http://dev.lan/git/myproject.git cd myproject npm install grunt phpunit webserver restart tail -f /var/log/nginx/*.log /var/www/wordpress/wp-content/debug.log
Beautiful!
We’re already running one docker image with a standard MySQL database. Now we need to build our webserver image and run it.
First, build the image:
docker build -t alexg/wp-test:latest .
Then, run it, making sure to link the database image, map the 80 port to local 81, and set the version variables:
docker run --link wordpress-mysql:db -p 81:80 -e PHP_VER=5.6 -e WP_VER=4.4.2 -i -t alexg/wp-test:latest
This command will now fire up a WordPress version of your choosing, and run the install script. The script will install your theme or plugin, add content, run your automated tests, and finally expose the entire thing at http://localhost:81/wordpress. Visit http://localhost:81/wordpress/wp-login.php and login with admin/password
or whatever credentials you’ve set in the wp-cli core install
command.
Your shell should now be tailing the nginx and WP logs. Do any automated or manual testing via your browser(s), then hit Ctrl-C to stop the container and run the command with different versions, like so:
docker run --link wordpress-mysql:db -p 81:80 -e PHP_VER=5.3 -e WP_VER=4.0 -i -t alexg/wp-test:latest
Hit http://localhost:81/wordpress again and voila! Your product is now running on a completely different environment.
Now say you’ve spotted an error that did not show in your development environment. It is probably a version-related issue. How do you investigate?
You could override the install script by setting the utility command to be the Linux shell (note the part in bold):
docker run --link wordpress-mysql:db -p 81:80 -e PHP_VER=5.6 -e WP_VER=4.4.2 -i -t alexg/slate-test:latest /bin/bash
Now docker loads your image and drops you in a shell. Here you can do fun stuff like connect to your database via the command line (remember, we’ve installed the mysql-client package):
mysql -h db -u root -p use wpdb;
Once you’re ready you can run the install script yourself, or even run the deployment manually if you need to tweak stuff.
/root/install.sh
That’s it!
Conclusion
Thanks to Docker, we’ve managed to parametrise our test system to the point where we know for sure that a particular deliverable will behave in a combination of PHP and WordPress versions. Want to test on different MySQL versions? Easy. Just tweak the command where we fire up the database.
You might think that all of this is too much. You might think that testing on a couple of versions is OK. But you would be wrong. You’ll be amazed at what issues crop up when you do rigorous testing. For instance, I was using shortcodes where their attributes had dashes (-) in their names. This is fine in WordPress 4.3.0 and greater, but not in earlier versions. Thanks to docker testing, I was able to find this out and fix it by changing any dashes to underscores. And I was able to do all this, without having to read this part of the documentation that expressly warns about the issue:
Attribute names are optional and should contain only the following characters for compatibility across all platforms:
- Upper-case and lower-case letters: A-Z a-z
- Digits: 0-9
- Underscore: _
- Hyphen: – (Not allowed before version 4.3.0)
In conclusion, does my solution suck? Yes, it sucks bad, because this was my first real use of Docker. Being a lightweight container “rookie”, I’ve probably done more than a few things “The Wrong Way”. Feel free to flame away in the comment section below, preferably but not necessarily in a constructive manner.
But, did my solution work? You bet your computer’s rear USB port it did!
Or, at least, it worked on my machine :-p
I hadn’t thought of using containers but that’s a great idea. Thanks so much for sharing!