模拟DateTime所有实例用于测试目的的时间

27

我希望能够在 PHPUnit 或 Behat 测试期间为每个实例化的 DateTime 设置时间。

我正在测试与时间相关的业务逻辑。例如,类中的某个方法仅返回过去或未来的事件。

如果可能的话,我不想执行以下操作:

  1. 编写一个围绕 DateTime 的包装器,并在整个代码中使用它代替 DateTime。这将需要对当前代码库进行一些重写。

  2. 每次运行测试/套件时动态生成数据集。

所以问题是:是否可以覆盖 DateTime 的行为,始终在请求时提供特定的时间?


你还没有接受任何答案。能否请您澄清一下您在答案中寻找什么以及为什么给出的答案不能满足您的需求。 - Gordon
遇到了完全相同的问题,@shouze 的 PHP Timecop 扩展起到了非常好的作用。 - Mark E
6个回答

27

在测试中,您应该对需要使用的 DateTime 方法进行存根化,以返回预期的值。

$stub = $this->getMock('DateTime');
$stub->expects($this->any())
     ->method('theMethodYouNeedToReturnACertainValue')
     ->will($this->returnValue('your certain value'));

请参阅https://phpunit.de/manual/current/en/test-doubles.html

如果您无法存根方法,因为它们已硬编码到您的代码中,请查看Sebastian Bergmann的Stubbing Hard-Coded Dependencies, 该文章解释了如何在调用new时调用回调。然后,您可以使用具有固定时间的自定义DateTime类替换DateTime类。另一个选择是使用http://antecedent.github.io/patchwork


谢谢Gordon - DateTime依赖在我的大部分代码中都是硬编码的。我犯了使用它作为原始类型的错误。所有其他依赖项都是注入的,因此很容易进行模拟。我不想使用扩展来进行模拟,因为这会降低代码的可移植性。尽管这可能是唯一的选择!感谢您的答案。 - Ben Waine
这种方式很难进行日期比较的检查。 - julio

8

我想要...但是目前我没有权限这样做,需要重新注册 ;) - shouze
TimeTraveler 上次我见到它已经坏了,而 timecop 这个替代品要好得多。 - Jean Carlo Machado

2
你可以更改你的实现方式,使用time()显式地实例化DateTime():
new \DateTime("@".time());

这不会改变您的类的行为。但是现在您可以通过提供一个命名空间函数来 模拟time()

namespace foo;
function time() {
    return 123;
}

您也可以使用我的包php-mock/php-mock-phpunit来实现这一点:
namespace foo;

use phpmock\phpunit\PHPMock;

class DateTimeTest extends \PHPUnit_Framework_TestCase {

    use PHPMock;

    public function testDateTime() {
        $time = $this->getFunctionMock(__NAMESPACE__, "time");
        $time->expects($this->once())->willReturn(123);

        $dateTime = new \DateTime("@".time());
        $this->assertEquals(123, $dateTime->getTimestamp());
    }
}

2

我使用Symfony的WebTestCase和PHPUnit测试包进行功能测试时,很快就发现模拟DateTime类的所有用法变得不切实际。

我想要测试应用程序如何随着时间处理请求,例如测试cookie或缓存过期等。

我找到的最好方法是实现自己的DateTime类来扩展默认类,并提供一些静态方法,以允许将默认时间偏移添加/减去到从那时起创建的所有DateTime对象中。

这是一个非常容易实现的功能,不需要安装自定义库。

caveat emptor: 这种方法的唯一缺点是Symfony框架(或您正在使用的任何框架)将不使用您的库,因此它本身预计要处理的任何行为,例如内部缓存/ cookie过期,都不会受到这些更改的影响。

namespace My\AppBundle\Util;

/**
 * Class DateTime
 *
 * Allows unit-testing of DateTime dependent functions
 */
class DateTime extends \DateTime
{
    /** @var \DateInterval|null */
    private static $defaultTimeOffset;

    public function __construct($time = 'now', \DateTimeZone $timezone = null)
    {
        parent::__construct($time, $timezone);
        if (self::$defaultTimeOffset && $this->isRelativeTime($time)) {
            $this->modify(self::$defaultTimeOffset);
        }
    }

    /**
     * Determines whether to apply the default time offset
     *
     * @param string $time
     * @return bool
     */
    public function isRelativeTime($time)
    {
        if($time === 'now') {
            //important, otherwise we get infinite recursion
            return true;
        }
        $base = new \DateTime('2000-01-01T01:01:01+00:00');
        $base->modify($time);
        $test = new \DateTime('2001-01-01T01:01:01+00:00');
        $test->modify($time);

        return ($base->format('c') !== $test->format('c'));
    }

    /**
     * Apply a time modification to all future calls to create a DateTime instance relative to the current time
     * This method does not have any effect on existing DateTime objects already created.
     *
     * @param string $modify
     */
    public static function setDefaultTimeOffset($modify)
    {
        self::$defaultTimeOffset = $modify ?: null;
    }

    /**
     * @return int the unix timestamp, number of seconds since the Epoch (Jan 1st 1970, 00:00:00)
     */
    public static function getUnixTime()
    {
        return (int)(new self)->format('U');
    }

}

使用起来很简单:

public class myTestClass() {
    public function testMockingDateTimeObject()
    {
        echo "fixed:  ". (new DateTime('18th June 2016'))->format('c') . "\n";
        echo "before: ". (new DateTime('tomorrow'))->format('c') . "\n";
        echo "before: ". (new DateTime())->format('c') . "\n";

        DateTime::setDefaultTimeOffset('+25 hours');

        echo "fixed:  ". (new DateTime('18th June 2016'))->format('c') . "\n";
        echo "after:  ". (new DateTime('tomorrow'))->format('c') . "\n";
        echo "after:  ". (new DateTime())->format('c') . "\n";

        // fixed:  2016-06-18T00:00:00+00:00 <-- stayed same
        // before: 2016-09-20T00:00:00+00:00
        // before: 2016-09-19T11:59:17+00:00
        // fixed:  2016-06-18T00:00:00+00:00 <-- stayed same
        // after:  2016-09-21T01:00:00+00:00 <-- added 25 hours
        // after:  2016-09-20T12:59:17+00:00 <-- added 25 hours
    }
}

2

除了@Gordon已经指出的方法外,还有一种相当hackish的测试依赖于当前时间的代码的方式:

我只需要模拟一个受保护的方法,让你获得“全局”值,就可以解决需要创建一个类自己询问诸如当前时间之类的东西的问题(这样做更加清晰,但在php中,人们不想这样做是可以理解的/可以争论的)。

它看起来会像这样:

class Calendar {
    public function getCurrentTimeAsISO() {
        return $this->currentTime()->format('Y-m-d H:i:s');
    }

    protected function currentTime() {
        return new DateTime();
    }
}

class CalendarTest extends PHPUnit_Framework_TestCase {
    public function testCurrentDate() {
        $cal = $this->getMockBuilder('Calendar')
            ->setMethods(array('currentTime'))
            ->getMock();
        $cal->expects($this->once())
            ->method('currentTime')
            ->will($this->returnValue(
                new DateTime('2011-01-01 12:00:00')
            )
        );
        $this->assertSame(
            '2011-01-01 12:00:00',
            $cal->getCurrentTimeAsISO()
        );
    }
}

0
我在这里提出了一种不同的方法,基于一个名为ClockMock的测试库。 这个想法是以透明的方式在引擎级别(实际上该库使用了一个php扩展)中模拟所有与日期时间相关的函数、类和方法,而无需进行任何特定的黑客或代码更改来使测试中的时间可以被模拟。
例如:
$nowYmd = ClockMock::executeAtFrozenDateTime(new \DateTime('1986-06-05'), function () {
    // Code executed in here will use the above date and time as "current"
    return date('Y-m-d');
});

$this->assertEquals('1986-06-05', $nowYmd);

更多信息请阅读本文

声明:我是作者和维护者。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接