0

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

}
matiaslauriti
  • 7,065
  • 4
  • 31
  • 43
Cudos
  • 5,733
  • 11
  • 50
  • 77
  • The call will always be made because you are mocking the `ApiClient`, not the `KlaviyoAPI` object. So you should have to mock both, `ApiClient` returning the `KlaviyoAPI` mock for that call you need – matiaslauriti Jul 03 '23 at 14:39

1 Answers1

1

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.

matiaslauriti
  • 7,065
  • 4
  • 31
  • 43
  • Great answer! Not only does it solve my problem, but explains why it should be done in a specific way. – Cudos Jul 04 '23 at 07:24