rockylars/faker

类似于 Mockery 和 Prophecy 的简单类/方法模拟工具,一次使测试专注于一个服务

1.3.2 2024-05-05 09:25 UTC

This package is auto-updated.

Last update: 2024-09-30 22:20:04 UTC


README

简介

欢迎来到单服务测试的世界,这是我最喜欢的项目 Faker。它很简单,但能满足您的需求,只需创建一个类并让它继承 Faker 并实现服务接口。我建议在测试文件夹中创建一个名为 "fake" 的单独文件夹,以防止它们在测试文件中丢失。我不建议在功能测试中使用它们,只用于集成和单元测试。

历史

Faker 是我在 2023 年 3 月左右在 Future500 公司为前公司 FinanceMatters/BondCenter 项目工作时,通过类方法制作的。在 Future500 和 FinanceMatters/BondCenter 都有一些非常好的家伙。接下来的一个月,添加了更多功能,由于一些类的大小,方法被复制以简化程序。随着 PHP 8 将抛出转换为语句,代码非常紧凑,但更多的测试表明代码需要稍微少一些压缩。几个月过去了,由于我无法控制的意外情况,我与这些公司的合作关系结束了长达 5 年的关系。幸运的是,那里的人们真的很友好,也很遗憾他们对我在 Faker 上的乐趣并不那么感兴趣,所以他们允许我保留它并将其作为包分发。

所以,这就是我构建的框架,因为我非常讨厌使用 prophecy 和发现 mocking 太复杂,不够简单。这个简单的抽象类允许您随心所欲地构建假类,而 PHPStan 从不会在您耳边大喊大叫。它们被设计得尽可能简单,后来添加了一些 getAllCalls.. 方法,这样就不需要在添加新方法时进行技术上的新检查。

用法

对于单元测试,我建议模拟一切,但有些类不应该被模拟,而应该保持半活跃状态。例如,一个带有 updateTime() 方法的假时钟就是一个半活跃类。

对于集成测试,只需模拟您需要模拟的内容,例如在测试存储库时不要模拟连接,但您可以使用像 Guzzle 客户端这样的假对象。关于这一点,实际上不要使用 Faker 进行日志记录,您将对日志有很多调用,但您已经在测试输出,而不太关心重复的输入。对于日志记录器,最好的做法是只收集数组并将其“模拟”成这样,其他类似的不属于您更深层的类也应该得到相同的数组收集处理。然而,尽管如此,您做您想做的。

未来

我将添加一个方法,在设置的反应数量和执行的调用数量之间添加一个计数检查,类似于 Mockery 提供的功能。当然,如果您有很多反应或设置数据但错过了实际上没有看到任何内容,这在您为单个服务设置了大量的假类时会发生。

示例

final class FakeUserRepository extends Faker implements UserRepositoryInterface
{
    public const FUNCTION_GET_USER_BY_ID = 'getUserById';
    public const FUNCTION_GET_USERS = 'getUsers';
    public const FUNCTION_UPDATE_LAST_LOGIN = 'updateLastLogin';
    public const FUNCTION_DELETE_USER = 'deleteUser';

    public function getUserById(int $userId): User
    {
        return $this->fakeCall(__FUNCTION__, [
            'userId' => $userId,
        ]);
    }

    /** @return array<int, User> */
    public function getUsers(): array
    {
        return $this->fakeCall(__FUNCTION__, [
            'a call was made',
        ]);
    }

    public function updateLastLogin(int $userId): void
    {
        $this->fakeCall(__FUNCTION__, [
            'userId' => $userId,
        ]);
    }

    public function deleteUser(int $userId): void
    {
        $this->fakeCall(__FUNCTION__, [
            'userId' => $userId,
        ]);
    }
}
final class DeleteUserServiceCest
{
    private DeleteUserService $deleteUserService;
    private FakeLogger $fakeLogger;
    private FakeUserRepository $fakeUserRepository;

    public function _before(UnitTester $tester): void
    {
        $this->deleteUserService = new DeleteUserService(
            $this->fakeLogger = new FakeLogger(),
            $this->fakeUserRepository = new FakeUserRepository()
        );
    }

    public function deleteUserWillCheckAndDeleteUserByIdIfNothingIsLinked(UnitTester $tester): void
    {
        $this->fakeUserRepository->setResponsesFor(FakeUserRepository::FUNCTION_GET_USER_BY_ID, [
            [Faker::ACTION_RETURN => new User(
                id: 1,
                name: 'Rocky',
                isAdmin: false,
                lastLogin: DateTimeImmutable::createFromFormat(
                    '!Y-m-d H:i:s',
                    '2023-02-17 12:13:14',
                    new \DateTimeZone('Europe/Amsterdam')
                ),
            )],
        ]);
        $this->fakeUserRepository->setResponsesFor(FakeUserRepository::FUNCTION_DELETE_USER, [
            [Faker::ACTION_VOID => null],
        ]);

        $this->deleteUserService->deleteUser(1);

        $tester->assertSame(
            [
                [
                    'level' => 'debug',
                    'message' => 'User 1 was deleted',
                    'context' => [],
                ],
            ],
            $this->fakeLogger->getLogs(),
        );
        $tester->assertSame(
            [
                FakeUserRepository::FUNCTION_DELETE_USER => [
                    [
                        'userId' => 1,
                    ],
                ],
                FakeUserRepository::FUNCTION_GET_USER => [
                    [
                        'userId' => 1,
                    ],
                ],
            ],
            $this->fakeUserRepository->getAllCallsInStyleSorted()
        );
    }
}

请注意,如果您使用 expectExceptionexpectExceptionMessage 而不是 expectThrowable,您应该使用以下代码来包装您的调用,否则它们将静默忽略其下方的断言。

// Using expectException and expectExceptionMessage will stop the test at the error, so a try catch is used instead.
$exceptionWasCaught = false;
try {
    // Call that will throw an exception
} catch (\Throwable $throwable) {
    // Do not assert like this on natural exceptions as those generate traces and such you can't just replicate.
    self::assertEquals(
        new \RuntimeException(
            "something happened",
            301
        ),
        $throwable
    );
    $exceptionWasCaught = true;
}
self::assertTrue($exceptionWasCaught);

在 Linux 上设置项目以进行提交

  1. 确保 Docker 运行正常,您不需要为此创建账户。
  2. 创建一个 GitHub 账户(显然)以进行提交。
  3. 获取并设置一个 SSH token(最好是 id_ed25519),并将其连接到您的 GitHub 账户。
    • 如果没有,您将无法正确地拉取/推送任何内容。
  4. 下载项目并 cd 到该文件夹。
    • 如果您计划提交任何 PR 且没有权限,请先创建一个分支,获取它,然后尝试合并该分支的 PR。
  5. 确保运行 git config --global --listgit config --list 都会显示 user.email=YOUR_GITHUB_EMAILuser.name=YOUR_GITHUB_USER_NAME
    • 如果没有,以下是修复步骤
    • 为项目设置值并取消本地设置的值,否则仅设置本地值。
    • 如果这样做,您的提交将不会链接到账户。
  6. 确保运行 groups 会显示其中的 docker
    • 如果没有,以下是修复步骤
    • 运行 sudo usermod -aG docker $USER 然后重新启动您的电脑。
    • 如果没有这样做,您将无法运行所需的 Docker 命令。
  7. 确保运行 ls -la ~/.composer 会显示您的用户而不是 root
    • 如果没有,以下是修复步骤
    • 运行 sudo chown -R $USER:$USER ~/.composer
    • 如果没有这样做,您将无法存储库身份验证和 Composer 缓存。
  8. 确保已安装 make 扩展。
  9. 运行 make setup,然后您就完成了。

[可选] 获取对 GitHub 上您有权限访问的私有仓库的访问权限

  1. 在 GitHub 上生成一个只有仓库权限的访问令牌。
  2. 运行 make composer 并添加 config --global github-oauth.github.com YOUR_GENERATED_TOKEN