Troubleshoot a Symfony ClockMock test
Review of known pitfalls to quickly debug a ClockMock test
The Symfony ClockMock is an amazing yet tricky feature to mock date & time in your PHPUnit test suite.
You followed the documentation and yet date and time don't get mocked in your tests?
Let's review how it works and the common pitfalls we know of at Springly.
How it works
The ClockMock relies on PHP name resolution rules to define date & time functions in your app namespace when you run tests annotated @time-sensitive
namespace App\Tests\Something
use PHPUnit\Framework\TestCase;
use Symfony\Component\Stopwatch\Stopwatch;
/**
* @group time-sensitive
*/
class MyTest extends TestCase
{
public function testSomething()
{
$stopwatch = new Stopwatch();
$stopwatch->start('event_name');
sleep(10);
$duration = $stopwatch->stop('event_name')->getDuration();
self::assertEquals(10000, $duration);
}
}
The following functions are defined with the previous code block:
App\Tests\Something\time()
&App\Something\time()
App\Tests\Something\microtime()
&App\Something\microtime()
App\Tests\Something\sleep()
&App\Something\sleep()
App\Tests\Something\usleep()
&App\Something\usleep()
App\Tests\Something\date()
&App\Something\date()
App\Tests\Something\gmdate()
&App\Something\gmdate()
For the most curious, this is done in Symfony\Bridge\PhpUnit\ClockMock
with an eval()
call.
Common pitfalls
Use the right binary
ClockMock only works if you run your test suite with the vendor/bin/simple-phpunit
binary.
Don't use Composer vendor/autoload.php
or the base vendor/bin/phpunit
binary when running tests. For instance, the right setup with Docker & PHPStorm is
Alternatively, you can also use this configuration to explicitly set up the right listener:
<!-- phpunit.xml.dist -->
<!-- ... -->
<listeners>
<listener class="\Symfony\Bridge\PhpUnit\SymfonyTestsListener"/>
</listeners>
Not every time classes and functions are mocked
Only the following functions are mockable:
time()
microtime()
sleep()
usleep()
date()
gmdate()
The DateTime
class is not mockable nor other PHP classes.
However, you can use this as a workaround:
$real = new DateTime('now');
$mocked = new DateTime('@' . time());
Fully qualified function name are not mocked
Time-based function mocking follows the PHP namespace resolutions rules so "fully qualified function calls" (e.g \time()
) cannot be mocked.
Functions are mocked in a limited set of namespaces
Following the first example, the PHPUnit bridge will mock time functions in these namespaces:
App\Something
App\Tests\Something
Check that your time functions calls are actually done within one of these mocked namespace or call ClockMock::register(MyClass::class)
beforehand.
Workaround PHP internal cache system
Newest versions of PHP cache name resolution results to improve performance as detailed in the following bug reports:
It means that the following test suite won't work as you expect.
namespace App\Tests\Something
use PHPUnit\Framework\TestCase;
use Symfony\Component\Stopwatch\Stopwatch;
class MyTest extends TestCase
{
public function testSomething()
{
$now = time();
self::assertTrue($now > 0);
ClockMock::register(MyTest::class);
ClockMock::withClockMock(0);
// This will fail as the namespace resolution result was cached
// by the previous call to time()
self::assertEquals(0, time());
}
}
You can either use a bootstrap.php
or a custom PHPUnit configuration to work around it for common used classes in your codebase (like a base Doctrine entity trait to set createdAt
& updatedAt
properties).
<!-- phpunit.xml.dist -->
<!-- ... -->
<listeners>
<listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener">
<arguments>
<array>
<element key="time-sensitive">
<string>App\Tests\Something</string>
</element>
</array>
</arguments>
</listener>
</listeners>
or a bootstrap file:
# bootstrap.php to setup in phpunit.xml
ClockMock::register('App\\Something\\');
⚠️ Use the namespace and not the fully qualified class name.
If your class is App\Something\MyClass
then use App\Something
in the example above and not App\Something\MyClass
.
Happy testing!