To run time/IO related unit tests (e.g., sleep function calls, database queries, API calls, etc) faster using Swoole.

Overview

counit: to run time/IO related unit tests faster using Swoole

Library Status Latest Stable Version Latest Unstable Version License

This package helps to run time/IO related unit tests (e.g., sleep function calls, database queries, API calls, etc) faster using Swoole.

Table of Contents

How Does It Work

Package counit allows running multiple time/IO related tests concurrently within a single PHP process using Swoole. Counit is compatible with PHPUnit, which means:

  1. Test cases can be written in the same way as those for PHPUnit.
  2. Test cases can run directly under PHPUnit.

A typical test case of counit looks like this:

use Deminy\Counit\TestCase; // Here is the only change made for counit, comparing to test cases for PHPUnit.

class SleepTest extends TestCase
{
  public function testSleep(): void
  {
    $startTime = time();
    sleep(3);
    $endTime = time();

    self::assertEqualsWithDelta(3, ($endTime - $startTime), 1, 'The sleep() function call takes about 3 seconds to finish.');
  }
}

Comparing to PHPUnit, counit could make your test cases faster. Here is a comparison when running the same test suite using PHPUnit and counit for a real project. In the test suite, many tests make calls to method \Deminy\Counit\Counit::sleep() to wait something to happen (e.g., wait data to expire).

  # of Tests # of Assertions Time to Finish
counit (without Swoole), or PHPUnit 44 1148 9 minutes and 18 seconds
counit (with Swoole enabled) 19 seconds

Installation

The package can be installed using Composer:

composer require deminy/counit --dev

Or, in your composer.json file, make sure to have package deminy/counit included:

{
  "require-dev": {
    "deminy/counit": "~0.2.0"
  }
}

Use "counit" in Your Project

  • Write unit tests in the same way as those for PHPUnit. However, to make those tests faster, please write those time/IO related tests in one of the following two styles (details will be discussed in the next sections):
  • Use the binary executable ./vendor/bin/counit instead of ./vendor/bin/phpunit when running unit tests.
  • Have the Swoole extension installed. If not installed, counit will work exactly same as PHPUnit (in blocking mode).
  • Optional steps:

Examples

Folder ./tests/unit/global and ./tests/unit/case-by-case contain some sample tests, where we have following time-related tests included:

  • Test slow HTTP requests.
  • Test long-running MySQL queries.
  • Test data expiration in Redis.
  • Test sleep() function calls in PHP.

Setup Test Environment

To run the sample tests, please start the Docker containers and install Composer packages first:

docker-compose up -d
docker exec -ti $(docker ps -qf "name=swoole") sh -c "composer install -n"

There are five containers started: a PHP container, a Swoole container, a Redis container, a MySQL container, and a web server. The PHP container doesn't have the Swoole extension installed, while the Swoole container has it installed and enabled.

As said previously, test cases can be written in the same way as those for PHPUnit. However, to run time/IO related tests faster with counit, we need to make some adjustments when writing those test cases; these adjustments can be made in two different styles.

The "global" Style (recommended)

In this style, each test case runs in a separate coroutine automatically.

For test cases written in this style, the only change to make on your existing test cases is to use class Deminy\Counit\TestCase instead of PHPUnit\Framework\TestCase as the base class.

A typical test case of the global style looks like this:

use Deminy\Counit\TestCase; // Here is the only change made for counit, comparing to test cases for PHPUnit.

class SleepTest extends TestCase
{
  public function testSleep(): void
  {
    $startTime = time();
    sleep(3);
    $endTime = time();

    self::assertEqualsWithDelta(3, ($endTime - $startTime), 1, 'The sleep() function call takes about 3 seconds to finish.');
  }
}

When customized method setUpBeforeClass() and tearDownAfterClass() are defined in the test cases, please make sure to call their parent methods accordingly in these customized methods.

This style assumes there is no immediate assertions in test cases, nor assertions before a sleep() function call or a coroutine-friendly IO operation. Test cases like following still work, but they will trigger some warning messages when tested:

class GlobalTest extends Deminy\Counit\TestCase
{
  public function testAssertionSuppression(): void
  {
    self::assertTrue(true, 'Trigger an immediate assertion.');
    // ......
  }
}

We can rewrite this test class using the "case by case" style (discussed in the next section) to eliminate the warning messages.

To find more tests written in this style, please check tests under folder ./tests/unit/global (test suite "global").

The "case by case" Style

In this style, you make changes directly on a test case to make it work asynchronously.

For test cases written in this style, we need to use class Deminy\Counit\Counit accordingly in the test cases where we need to wait for PHP execution or to perform IO operations. Typically, following method calls will be used:

  • Use method Deminy\Counit\Counit::create() to wrap the test case.
  • Use method Deminy\Counit\Counit::sleep() instead of the PHP function sleep() to wait for PHP execution. You will need some knowledge on Swoole if you want to make other IO related tests run asynchronously.

A typical test case of the case-by-case style looks like this:

use Deminy\Counit\Counit;
use PHPUnit\Framework\TestCase;

class SleepTest extends TestCase
{
  public function testSleep(): void
  {
    Counit::create(function () { // To create a new coroutine manually to run the test case.
      $startTime = time();
      Counit::sleep(3); // Call this method instead of PHP function sleep().
      $endTime = time();

      self::assertEqualsWithDelta(3, ($endTime - $startTime), 1, 'The sleep() function call takes about 3 seconds to finish.');
    });
  }
}

In case you need to suppress warning message "This test did not perform any assertions" or to make the number of assertions match, you can include a 2nd parameter when creating the new coroutine:

use Deminy\Counit\Counit;
use PHPUnit\Framework\TestCase;

class SleepTest extends TestCase
{
  public function testSleep(): void
  {
    Counit::create( // To create a new coroutine manually to run the test case.
      function () {
        $startTime = time();
        Counit::sleep(3); // Call this method instead of PHP function sleep().
        $endTime = time();

        self::assertEqualsWithDelta(3, ($endTime - $startTime), 1, 'The sleep() function call takes about 3 seconds to finish.');
      },
      1 // Optional. To suppress warning message "This test did not perform any assertions", and to make the counters match.
    );
  }
}

To find more tests written in this style, please check tests under folder ./tests/unit/case-by-case (test suite "case-by-case").

Comparisons

Here we will run the tests under different environments, with or without Swoole.

#1 Run the test suites using PHPUnit:

# To run test suite "global":
docker exec -ti $(docker ps -qf "name=php")    sh -c "./vendor/bin/phpunit --testsuite global"
# or,
docker exec -ti $(docker ps -qf "name=swoole") sh -c "./vendor/bin/phpunit --testsuite global"

# To run test suite "case-by-case":
docker exec -ti $(docker ps -qf "name=php")    sh -c "./vendor/bin/phpunit --testsuite case-by-case"
# or,
docker exec -ti $(docker ps -qf "name=swoole") sh -c "./vendor/bin/phpunit --testsuite case-by-case"

#2 Run the test suites using counit (without Swoole):

# To run test suite "global":
docker exec -ti $(docker ps -qf "name=php")    sh -c "./counit --testsuite global"

# To run test suite "case-by-case":
docker exec -ti $(docker ps -qf "name=php")    sh -c "./counit --testsuite case-by-case"

#3 Run the test suites using counit (with extension Swoole enabled):

# To run test suite "global":
docker exec -ti $(docker ps -qf "name=swoole") sh -c "./counit --testsuite global"

# To run test suite "case-by-case":
docker exec -ti $(docker ps -qf "name=swoole") sh -c "./counit --testsuite case-by-case"

The first two sets of commands take about same amount of time to finish. The last set of commands uses counit and runs in the Swoole container (where the Swoole extension is enabled); thus it's faster than the others:

  Style # of Tests # of Assertions Time to Finish
counit (without Swoole), or PHPUnit global 16 24 48 seconds
case by case 48 seconds
counit (with Swoole enabled) global 7 seconds
case by case 7 seconds

Additional Notes

Since this package allows running multiple tests simultaneously, we should not use same resources in different tests; otherwise, racing conditions could happen. For example, if multiple tests use the same Redis key, some of them could fail occasionally. In this case, we should use different Redis keys in different test cases. Method \Deminy\Counit\Helper::getNewKey() and \Deminy\Counit\Helper::getNewKeys() can be used to generate random and unique test keys.

The package works best for tests that have function call sleep() in use; It can also help to run some IO related tests faster, with limitations apply. Here is a list of limitations of this package:

  • The package makes tests running faster by performing time/IO operations simultaneously. For functions/extensions that work in blocking mode only, this package can't make their function calls faster. Here are some extensions that work in blocking mode only: MongoDB, Couchbase, and some ODBC drivers.
  • The package doesn't work exactly the same as when running under PHPUnit:
    • Tests may not have yet finished even it's marked as finished (by PHPUnit). Because of that, a test marked as "passed" (by PHPUnit) could still fail at a later time under counit. Because of this, the most reliable way to check if all test cases have passed or not is to check the exit code of counit.
    • The # of assertions reported could be different from PHPUnit.
    • Some exceptions/errors are not handled/reported the same.

Local Development

There are pre-built images deminy/counit for running the sample tests. Here are the commands to build the images:

docker build -t deminy/counit:php-only       -f ./dockerfiles/php/Dockerfile    .
docker build -t deminy/counit:swoole-enabled -f ./dockerfiles/swoole/Dockerfile .

Alternatives

This package allows to use Swoole to run multiple time/IO related tests without multiprocessing, which means all tests can run within a single PHP process. To understand how exactly it works, I'd recommend checking this free online talk: CSP Programming in PHP (and here are the slides).

In the PHP ecosystem, there are other options to run unit tests in parallel, most end up using multiprocessing:

TODOs

  • Better integration with PHPUnit.
    • Deal with annotation @doesNotPerformAssertions in the global style.
    • Make # of assertions consistent with the one reported from PHPUnit.
  • Better error/exception handling.

License

MIT license.

You might also like...
Package for unit testing Laravel form request classes
Package for unit testing Laravel form request classes

Package for unit testing Laravel form request classes. Why Colin DeCarlo gave a talk on Laracon online 21 about unit testing Laravel form requests cla

SimpleTest is a framework for unit testing, web site testing and mock objects for PHP

SimpleTest SimpleTest is a framework for unit testing, web site testing and mock objects for PHP. Installation Downloads All downloads are stored on G

Learn unit testing with PHPUnit.

PHPUnit Exercise Running PHPUnit ./vendor/bin/phpunit # with filter which tests to run ./vendor/bin/phpunit --filter pattern Running Pint ./vendor/

This plugin adds basic HTTP requests functionality to Pest tests, using minicli/curly

Curly Pest Plugin This plugin adds basic HTTP requests functionality to Pest tests, using minicli/curly. Installation composer require minicli/pest-pl

Example repo for writing tests in Drupal (using DDEV)

Drupal Test Writing This is a test D9 site which can be used for practicing test writing and running.

Some shorthand functions for skipping and focusing tests.

Pest Plugin: Shorthands This repository contains the Pest Plugin Shorthands. If you want to start testing your application with Pest, visit the main P

Magic Test allows you to write browser tests by simply clicking around on the application being tested, all without the slowness of constantly restarting the testing environment.

Magic Test for Laravel Magic Test allows you to write browser tests by simply clicking around on the application being tested, all without the slownes

Wraps your Pest suite in a Laravel application instance, allowing global use of the framework in tests.

Pest Larastrap Plugin This is currently a highly experimental project and is subject to large pre-release changes. Pest PHP is an awesome PHP testing

TestDummy makes the process of preparing factories (dummy data) for your integration tests as easy as possible

TestDummy TestDummy makes the process of preparing factories (dummy data) for your integration tests as easy as possible. As easy as... Build a Post m

Releases(0.2.0)
Owner
Demin Yin
PHP veteran. Official evangelist of Swoole. Sometime conference speaker.
Demin Yin
Mock HTTP requests on the server side in your PHP unit tests

HTTP Mock for PHP Mock HTTP requests on the server side in your PHP unit tests. HTTP Mock for PHP mocks the server side of an HTTP request to allow in

InterNations GmbH 386 Dec 27, 2022
PHP Test Generator - A CLI tool which generates unit tests

This project make usages of PHPStan and PHPParser to generate test cases for a given PHP File.

Alexander Schranz 7 Dec 3, 2022
A tool to run migrations prior to running tests

cakephp-test-migrator A tool to run migrations prior to running tests The Migrator For CakePHP 3.x composer require --dev vierge-noire/cakephp-test-mi

Vierge Noire 11 Apr 29, 2022
A PHP library for mocking date and time in tests

ClockMock Slope s.r.l. ClockMock provides a way for mocking the current timestamp used by PHP for \DateTime(Immutable) objects and date/time related f

Slope 44 Dec 7, 2022
The modern, simple and intuitive PHP unit testing framework.

atoum PHP version atoum version 5.3 -> 5.6 1.x -> 3.x 7.2 -> 8.x 4.x (current) A simple, modern and intuitive unit testing framework for PHP! Just lik

atoum 1.4k Nov 29, 2022
The PHP Unit Testing framework.

PHPUnit PHPUnit is a programmer-oriented testing framework for PHP. It is an instance of the xUnit architecture for unit testing frameworks. Installat

Sebastian Bergmann 18.8k Jan 4, 2023
PHP unit testing framework with built in mocks and stubs. Runs in the browser, or via the command line.

Enhance PHP A unit testing framework with mocks and stubs. Built for PHP, in PHP! Quick Start: Just add EnhanceTestFramework.php and you are ready to

Enhance PHP 67 Sep 12, 2022
Unit testing tips by examples in PHP

Unit testing tips by examples in PHP Introduction In these times, the benefits of writing unit tests are huge. I think that most of the recently start

Kamil RuczyƄski 894 Jan 4, 2023
Real-world Project to learning about Unit Testing/TDD with Laravel for everybody

KivaNote - a Laravel TDD Sample Project Let me introduce you to KivaNote, a simple real-world application using Laravel to show you how the TDD & Unit

(Seth) Phat Tran 10 Dec 31, 2022