Mockery: 链式调用属性和方法

huangapple go评论83阅读模式
英文:

Mockery: Chaining properties and methods

问题

目标是创建一个测试,验证正确的数据是否发送到外部API,而不实际发出请求。

我正在尝试模拟一个混合了属性和方法的链。部分原因是这是Klaviyo API的工作方式,部分原因是因为我的自定义ApiClient。

Demeter链和流畅接口对我不起作用,似乎它只对方法起作用。我也尝试了各种set()的组合,例如->set('request, Klaviyo::class)都没有成功。

因此,该链看起来像这样:

$client
    ->request // 公共属性(也可以是方法,但无法解决"Profiles"属性)
    ->Profiles // 公共属性 - Klaviyo SDK
    ->subscribeProfiles($body) // 公共方法 - Klaviyo SDK

Klaviyo如何初始化Profiles

代码示例:

use KlaviyoAPI\KlaviyoAPI;

class ApiClient
{
    public readonly KlaviyoAPI $request;

    public function __construct(
        string $apiKey,
        private int $retries = 3,
        private int $waitSeconds = 3,
        private array $guzzleOptions = [],
    )
    {
        $this->request = new KlaviyoAPI(
            $apiKey,
            $this->retries,
            $this->waitSeconds,
            $this->guzzleOptions,
        );
    }
}
use ApiClient;

class Newsletter
{
    public function resubscribe(ApiClient $client, string $listId, string $email)
    {
        $body = [
            'data' => [
                'type'       => 'profile-subscription-bulk-create-job',
                'attributes' => [
                    'list_id'       => $listId,
                    'custom_source' => 'Resubscribe user',
                    'subscriptions' => [
                        [
                            'channels' => [
                                'email' => [
                                    'MARKETING',
                                ],
                            ],
                            'email' => $email,
                        ],
                    ],
                ],
            ],
        ];

        return $client->request->Profiles->subscribeProfiles($body);
    }
}
use ApiClient;
use Newsletter;
use Mockery;

public function testSubscribeUser(): void
{
    $apiKey = 'api_key_12345';
    $listId = 'list_id_12345';
    $email = 'test@test.com';

    $body = [
        'data' => [
            'type'       => 'profile-subscription-bulk-create-job',
            'attributes' => [
                'list_id'       => $listId,
                'custom_source' => 'Resubscribe user',
                'subscriptions' => [
                    [
                        'channels' => [
                            'email' => [
                                'MARKETING',
                            ],
                        ],
                        'email' => $email,
                    ],
                ],
            ],
        ],
    ];

    // 这不会验证"$body"是否正确,并且仍然会向Klaviyo API发出请求
    $client = Mockery::mock(ApiClient::class, [$apiKey]);

    $response = resolve(Newsletter::class)->resubscribe($client, $listId, $email);
    static::assertNull($response); // 成功时返回"null"

    // 我正在尝试使用Demeter链和流畅接口
    // 但它不起作用,因为我混合了属性和方法
    // 我收到错误消息"ErrorException: Undefined property: Mockery\CompositeExpectation::$request"
    $client = Mockery::mock(ApiClient::class, [$apiKey]);
    $client->shouldReceive('request->Profiles->subscribeProfiles')
         ->with($body) // 我假设这是如何验证"$body"的方式
         ->once()
         ->andReturnNull();

    $response = resolve(Newsletter::class)->resubscribe($client, $listId, $email);
    static::assertNull($response); // 成功时返回"null"

}
英文:

The goal is to create a test that validates the correct data is send to an external API without actually making the request.

I am trying to mock a chain a mix of properties and a method. Partly because it is how the Klaviyo API works and because of my custom ApiClient.

Demeter Chains And Fluent Interfaces doesn't work for me and it seems that it only works for methods. I have also tried to various combinations of set() e.g. ->set('request, Klaviyo::class) with no luck.

So the chain looks like this:

$client
    ->request // Public property (could be amethod, but wouldn't address the "Profiles" property)
    ->Profiles // Public property - Klaviyo SDK
    ->subscribeProfiles($body) // Public method - Klaviyo SDK

How Klaviyo initialize Profiles.

Code examples:

use KlaviyoAPI\KlaviyoAPI;

class ApiClient
{
    public readonly KlaviyoAPI $request;

    public function __construct(
        string $apiKey,
        private int $retries = 3,
        private int $waitSeconds = 3,
        private array $guzzleOptions = [],
    )
    {
        $this->request = new KlaviyoAPI(
            $apiKey,
            $this->retries,
            $this->waitSeconds,
            $this->guzzleOptions,
        );
    }
}
use ApiClient;

class Newsletter
{
    public function resubscribe(ApiClient $client, string $listId, string $email)
    {
        $body = [
            'data' => [
                'type'       => 'profile-subscription-bulk-create-job',
                'attributes' => [
                    'list_id'       => $listId,
                    'custom_source' => 'Resubscribe user',
                    'subscriptions' => [
                        [
                            'channels' => [
                                'email' => [
                                    'MARKETING',
                                ],
                            ],
                            'email' => $email,
                        ],
                    ],
                ],
            ],
        ];

        return $client->request->Profiles->subscribeProfiles($body);
    }
}
use ApiClient;
use Newsletter;
use Mockery;

public function testSubscribeUser(): void
{
    $apiKey = 'api_key_12345';
    $listId = 'list_id_12345';
    $email = 'test@test.com';

    $body = [
        'data' => [
            'type'       => 'profile-subscription-bulk-create-job',
            'attributes' => [
                'list_id'       => $listId,
                'custom_source' => 'Resubscribe user',
                'subscriptions' => [
                    [
                        'channels' => [
                            'email' => [
                                'MARKETING',
                            ],
                        ],
                        'email' => $email,
                    ],
                ],
            ],
        ],
    ];

    // This does not validate that "$body" is correct and still makes a request to the Klaviyo API
    $client = Mockery::mock(ApiClient::class, [$apiKey]);

    $response = resolve(Newsletter::class)->resubscribe($client, $listId, $email);
    static::assertNull($response); // When succesful "null" is returned

    // I am trying to use Demeter Chains And Fluent Interfaces
    // but it doesn't work since I am mixing properties and methods
    // and I get the error "ErrorException: Undefined property: Mockery\CompositeExpectation::$request"
    $client = Mockery::mock(ApiClient::class, [$apiKey]);
    $client->shouldReceive('request->Profiles->subscribeProfiles')
         ->with($body) // I assume this is how I validate "$body"
         ->once()
         ->andReturnNull();

    $response = resolve(Newsletter::class)->resubscribe($client, $listId, $email);
    static::assertNull($response); // When succesful "null" is returned

}

答案1

得分: 1

你的问题在于你没有正确地编码你的 ApiClient

一个“正常”的 API 客户端会像这样:

$client = new CompanyAPIClient;

$response = $client->doSomething();

而你创建的是这样的:

$client = new APIClient;

$client->literalClient->whatever();

这是不正确的。一个 API 客户端 类的概念是调用一个能完成你想要的操作的方法,你实际上是将真正的 API 客户端 (KlaviyoAPI) 包装在另一个类中,而这个类除了共享一个存储实例的属性外什么也不做。你需要完全包装这个 API,这样你才能做到像 ApiClient->SubscribeProfile = KlaviyoAPI->Profiles->subscribeProfiles 这样的操作,你需要将这个 API 包装起来。

我会将你的类改成这样:

class ApiClient
{
    private readonly KlaviyoAPI $client;

    public function __construct(
        string $apiKey,
        private int $retries = 3,
        private int $waitSeconds = 3,
        private array $guzzleOptions = [],
    ) {
        $this->client = resolve(
            KlaviyoAPI::class,
            [
                $apiKey,
                $this->retries,
                $this->waitSeconds,
                $this->guzzleOptions,
            ]
        );
    }

    public function doSomething()
    {
        return $this->client->method();
    }
}

这样,你的测试可以简化为:

public function testSubscribeUser(): void
{
    $listId = 'list_id_12345';
    $email = 'test@test.com';

    $body = [
        'data' => [
            'type'       => 'profile-subscription-bulk-create-job',
            'attributes' => [
                'list_id'       => $listId,
                'custom_source' => 'Resubscribe user',
                'subscriptions' => [
                    [
                        'channels' => [
                            'email' => [
                                'MARKETING',
                            ],
                        ],
                        'email' => $email,
                    ],
                ],
            ],
        ],
    ];

    $client = Mockery::mock(KlaviyoAPI::class, ['api_key_12345']);
    $client->shouldReceive('Profiles->subscribeProfiles')
         ->with($body)
         ->once()
         ->andReturnNull();

    $response = resolve(Newsletter::class)->resubscribe($client, $listId, $email);
    $this->assertNull($response); // 当成功时返回 "null"
}

注意我还将 new KlaviyoAPI 改为了 resolve(KlaviyoAPI::class,这样你可以模拟真正的 API(以防止调用),而不是模拟中间的任何自定义代码。

最后一点要“做得更好”的是按照我在前一步中分享的代码来做,但是不要测试 Newsletter 是否调用了 ApiClient 并模拟 KlaviyoAPI,只需模拟 ApiClient 并期望它调用所需的方法。

这也会将测试拆分成两个测试,一个测试 Newsletter 是否调用了正确的 ApiClient 方法,另一个是单元测试(而不是特性测试),将完全测试 KlaviyoAPI 是否按预期工作(测试 ApiClient,当你调用所需的方法时)。

所以,你会有一个像这样的测试:

class ApiClient
{
    // ...

    public function subscribeProfile(string $listId, string $email)
    {
        $body = [
            'data' => [
                'type'       => 'profile-subscription-bulk-create-job',
                'attributes' => [
                    'list_id'       => $listId,
                    'custom_source' => 'Resubscribe user',
                    'subscriptions' => [
                        [
                            'channels' => [
                                'email' => [
                                    'MARKETING',
                                ],
                            ],
                            'email' => $email,
                        ],
                    ],
                ],
            ],
        ];

        return $this->client->Profiles->subscribeProfiles($body);
    }
}
class Newsletter
{
    public function resubscribe(ApiClient $client, string $listId, string $email)
    {
        return $client->subscribeProfile($listId, $email);
    }
}
public function testSubscribeUser(): void
{
    $client = $this->spy(ApiClient::class); // 如果由于缺少参数而导致错误,请传入任何值,因为它将被模拟

    $listId = 'list_id_12345';
    $email = 'test@test.com';

    $response = resolve(Newsletter::class)->resubscribe($client, $listId, $email);

    $client->shouldHaveReceived('subscribeProfile')
         ->with($listId, $email) // 它应该是这样的,否则是 [$listId, $email]
         ->once();

    // 上述 shouldHaveRecceived 行将明确断言是否正确调用了带有正确参数的方法,否则测试将失败
}

所以,为什么要像这样修改 ApiClientNewsletter

  • Newsletter:应该只调用 ApiClient 和所需的方法,不应该访问底层代码,因此在将来,如果你仍然只需要一个列表ID和一个电子邮件,你永远不需要再次更改这段代码
  • ApiClient:这是最重要的部分,它封装了你想要的行为,因此任何使用内部 API(在这种情况下是 KlaviyoAPI)的人都不需要知道它,只需要知道它有 subscribeProfiles 可用,并将其调用即可。你不需要关心 ApiClient 方法内部是如何传递参数给真正的 API 并调用的。

现在你明白了责任的分工吗?如果需要更详细地解释或添加更多信息以便你理解,请告诉我。

英文:

Your issue is that you do not have correctly coded your ApiClient.

A "normal" API client would be something like this:

$client = new CompanyAPIClient;

$response = $client->doSomething();

What you have created is this:

$client = new APIClient;

$client->literalClient->whatever();

That is not correct. The idea of an API Client class is to call a method that will do what you want, you are literally wrapping the real API client (KlaviyoAPI) into another class that does nothing but share a property where the instance is stored. You have to fully wrap the API, so you can do something like: ApiClient -> SubscribeProfile = KlaviyoAPI -> Profiles -> subscribeProfiles, you have to wrap the API.


I would change your class to this:

class ApiClient
{
    private readonly KlaviyoAPI $client;

    public function __construct(
        string $apiKey,
        private int $retries = 3,
        private int $waitSeconds = 3,
        private array $guzzleOptions = [],
    ) {
        $this->client = resolve(
            KlaviyoAPI::class,
            [
                $apiKey,
                $this->retries,
                $this->waitSeconds,
                $this->guzzleOptions,
            ]
        );
    }

    public function doSomething()
    {
        return $this->client->method();
    }
}

This way, your test can be as simple as:

public function testSubscribeUser(): void
{
    $listId = 'list_id_12345';
    $email = 'test@test.com';

    $body = [
        'data' => [
            'type'       => 'profile-subscription-bulk-create-job',
            'attributes' => [
                'list_id'       => $listId,
                'custom_source' => 'Resubscribe user',
                'subscriptions' => [
                    [
                        'channels' => [
                            'email' => [
                                'MARKETING',
                            ],
                        ],
                        'email' => $email,
                    ],
                ],
            ],
        ],
    ];

    $client = Mockery::mock(KlaviyoAPI::class, ['api_key_12345']);
    $client->shouldReceive('Profiles->subscribeProfiles')
         ->with($body)
         ->once()
         ->andReturnNull();

    $response = resolve(Newsletter::class)->resubscribe($client, $listId, $email);
    $this->assertNull($response); // When succesful "null" is returned
}

See that I have also changed new KlaviyoAPI to resolve(KlaviyoAPI::class, so you can mock the real API (to prevent calls) and not fake any custom code you have in the middle.


One last thing to "do better" would be to have the code as I shared in the previous step, but instead of you testing if Newsletter is calling ApiClient and mocking KlaviyoAPI, just mock ApiClient and expect it to call the needed method.

This will also split the test into 2 tests, one testing that Newsletter is calling the right ApiClient method, and another UNIT (not Feature) test that will fully test the KlaviyoAPI that works as expected (test ApiClient, when you call the desired method).

So, you would have a test like this:

class ApiClient
{
    // ...

    public function subscribeProfile(string $listId, string $email)
    {
        $body = [
            'data' => [
                'type'       => 'profile-subscription-bulk-create-job',
                'attributes' => [
                    'list_id'       => $listId,
                    'custom_source' => 'Resubscribe user',
                    'subscriptions' => [
                        [
                            'channels' => [
                                'email' => [
                                    'MARKETING',
                                ],
                            ],
                            'email' => $email,
                        ],
                    ],
                ],
            ],
        ];

        return $this->client->Profiles->subscribeProfiles($body);
    }
}
class Newsletter
{
    public function resubscribe(ApiClient $client, string $listId, string $email)
    {
        return $client->subscribeProfile($listId, $email);
    }
}
public function testSubscribeUser(): void
{
    $client = $this->spy(ApiClient::class); // If it gives errors due to missing parameters, pass anything, as it will be mocked anyways

    $listId = 'list_id_12345';
    $email = 'test@test.com';

    $response = resolve(Newsletter::class)->resubscribe($client, $listId, $email);

    $client->shouldHaveReceived('subscribeProfile')
         ->with($listId, $email) // It should be like this, else it is [$listId, $email]
         ->once();

    // The previous line shouldHaveRecceived will literally assert the right
    // method with the right parameters was called once, else test fails
}

So, why modify ApiClient and Newsletter like this?

  • Newsletter: Should only call ApiClient and the desired method, not have access to the underlying code, so in the future, if you still only need a list ID and an email, you never change this code again
  • ApiClient: This is the most important one, it is the one encapsulating the behavior you want, so anyone consuming the internal API (KlaviyoAPI in this case), does not need to know about it, just that it has subscribeProfiles available and will do just that, you do not care how (from the Newsletter point of view, it just does it and works). Now, on ApiClient, you should define everything that it can do using the real API (KlaviyoAPI), but have it consistent, do not ask for weird arrays and KlaviyoAPI things it will require (do not have a parameter on the ApiClient ask for a "body"), ApiClient's method must know it and ask for the needed parameters, like List ID and Email in this case, then it must know how to pass that to the real API and consume it

Do you see now the separation of concerns? Let me know if you need my answer better explained or add more info so you understand it.

huangapple
  • 本文由 发表于 2023年7月3日 18:33:16
  • 转载请务必保留本文链接:https://go.coder-hub.com/76603909.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定