Adobe Sign PHP SDKconceptlz/adobe-sign GitHub

Help

Testing your integration

Your tests should never email a real person or depend on Adobe being up. Because the SDK talks to the network only through a PSR-18 client you can replace, testing code that uses it is a matter of queueing the responses Adobe would send and checking the requests your code made. This page shows how, plus how to test webhook handlers, check requests against Adobe's spec, and run real end-to-end tests when you want them.

What to test, and how

LayerToolNetworkRuns in CI
Your business logic around the SDKA fake PSR-18 client with queued responsesnoneyes
Retry and token-refresh pathsThe same, plus a recording Sleeper and a frozen clocknoneyes
Your webhook endpointWebhookHandler with a hand-built PSR-7 requestnoneyes
Payload correctnessValidate requests against Adobe's OpenAPI specnoneyes
The real thingLive tests against a developer accountAdobeno: run by hand

A fake HTTP client

Any PSR-18 implementation that returns responses you choose will do. php-http/mock-client is the simplest, and is what the SDK's own suite uses:

composer require --dev php-http/mock-client nyholm/psr7

Build the client with the fake injected, and a sleeper that doesn't sleep (explained below):

tests/AdobeSignTestCase.php
use Conceptlz\AdobeSign\AdobeSign;
use Http\Mock\Client as MockClient;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7\Response;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseInterface;

abstract class AdobeSignTestCase extends TestCase
{
    protected MockClient $http;
    protected Psr17Factory $psr17;
    protected RecordingSleeper $sleeper;

    protected function setUp(): void
    {
        $this->psr17 = new Psr17Factory();
        $this->http = new MockClient($this->psr17);
        $this->http->setDefaultException(new \LogicException('Unexpected request: no queued response left.'));
        $this->sleeper = new RecordingSleeper();
    }

    protected function sign(): AdobeSign
    {
        return AdobeSign::withIntegrationKey(
            'test-integration-key',
            httpClient: $this->http,
            requestFactory: $this->psr17,
            streamFactory: $this->psr17,
            sleeper: $this->sleeper,
        );
    }

    /** @param array<string, string> $headers */
    protected function json(int $status, array $body, array $headers = []): ResponseInterface
    {
        return new Response($status, ['Content-Type' => 'application/json'] + $headers, json_encode($body));
    }

    protected function queueBaseUris(): void
    {
        $this->http->addResponse($this->json(200, [
            'apiAccessPoint' => 'https://api.na1.adobesign.com/',
            'webAccessPoint' => 'https://secure.na1.adobesign.com/',
        ]));
    }
}

The default exception makes a test fail loudly when your code sends more requests than you queued, instead of receiving an empty 200.

The first request is always GET /baseUris

A fresh client with an Integration Key discovers its data centre before its first real call, so queue that answer first. If one test builds several clients, give them the same PSR-16 cache (pass cache:) and only the first one asks. OAuth clients whose token carries an api_access_point skip discovery entirely.

Queueing responses and asserting on requests

Say your application has a small service that uploads a contract and sends it:

src/ContractSender.php
final class ContractSender
{
    public function __construct(private readonly AdobeSign $sign) {}

    public function send(string $pdfPath, string $clientEmail): string
    {
        $documentId = $this->sign->transientDocuments()->upload($pdfPath);

        return $this->sign->agreements()->create(
            AgreementInfo::create('Services agreement')
                ->file($documentId)
                ->signers($clientEmail)
                ->externalId('contract-' . basename($pdfPath, '.pdf')),
        );
    }
}

Queue one response per request, in order, then inspect what was sent:

tests/ContractSenderTest.php
final class ContractSenderTest extends AdobeSignTestCase
{
    public function test_uploads_then_sends_to_the_client(): void
    {
        $pdf = tempnam(sys_get_temp_dir(), 'contract') . '.pdf';
        file_put_contents($pdf, "%PDF-1.4\n%%EOF\n");

        $this->queueBaseUris();
        $this->http->addResponse($this->json(201, ['transientDocumentId' => 'doc-1']));
        $this->http->addResponse($this->json(201, ['id' => 'agr-1']));

        $id = (new ContractSender($this->sign()))->send($pdf, 'client@example.com');

        self::assertSame('agr-1', $id);

        [, $upload, $create] = $this->http->getRequests();
        self::assertSame('https://api.na1.adobesign.com/api/rest/v6/transientDocuments', (string) $upload->getUri());
        self::assertSame('POST', $create->getMethod());
        self::assertSame('/api/rest/v6/agreements', $create->getUri()->getPath());

        $create->getBody()->rewind();
        $payload = json_decode((string) $create->getBody(), true);
        self::assertSame('doc-1', $payload['fileInfos'][0]['transientDocumentId']);
        self::assertSame('client@example.com', $payload['participantSetsInfo'][0]['memberInfos'][0]['email']);
        self::assertSame('IN_PROCESS', $payload['state']);
    }
}

Useful things to assert on a request:

  • $request->getHeaderLine('If-Match'): that an update was guarded with the ETag from a preceding GET (queue the GET response with an ETag header).
  • $request->getHeaderLine('x-api-user'): that asUser() targeted the right person, e.g. email:jane@example.com.
  • $request->getUri()->getQuery(): list parameters such as pageSize or includeNextParticipantSet=true.
  • The JSON body, after rewind(): the payload your builder calls produced.

Test the payload, not the builder

Assert on the JSON your code produced rather than on AgreementInfo calls. The JSON is the contract with Adobe; the builder is just one way to produce it.

Simulating errors

Queue an error body exactly as Adobe sends it, and assert on the exception your code ends up with:

use Conceptlz\AdobeSign\Exception\{NotFoundException, TransportException};
use Psr\Http\Client\ClientExceptionInterface;

public function test_unknown_agreement(): void
{
    $this->queueBaseUris();
    $this->http->addResponse($this->json(404, [
        'code' => 'INVALID_AGREEMENT_ID',
        'message' => 'The Agreement ID specified is invalid.',
    ]));

    try {
        $this->sign()->agreements()->get('nope');
        self::fail('Expected NotFoundException');
    } catch (NotFoundException $e) {
        self::assertSame('INVALID_AGREEMENT_ID', $e->errorCode);
        self::assertSame('Bearer ***', $e->request?->getHeaderLine('Authorization'));
    }
}

public function test_network_failure(): void
{
    $this->queueBaseUris();
    $this->http->addException(new class ('Connection refused') extends \RuntimeException implements ClientExceptionInterface {});

    $this->expectException(TransportException::class);
    $this->sign()->agreements()->get('agr-1');
}

Codes worth covering: RESOURCE_MODIFIED (412) for your retry-on-stale-ETag path, AGREEMENT_NOT_EXPOSED (404) for "still processing", HYBRID_ROUTING_NOT_ALLOWED (403) if you offer mixed signing orders, and INVALID_ACCESS_TOKEN (401) for a revoked key. The full list is on Errors & exceptions.

Retries without waiting

The retry middleware sleeps through the public Conceptlz\AdobeSign\Http\Sleeper interface. Pass an implementation that records the requested delays instead of sleeping, and a test of the 429 path takes milliseconds:

tests/RecordingSleeper.php
use Conceptlz\AdobeSign\Http\Sleeper;

final class RecordingSleeper implements Sleeper
{
    /** @var list<float> */
    public array $sleeps = [];

    public function sleep(float $seconds): void
    {
        $this->sleeps[] = $seconds;
    }
}
public function test_waits_as_long_as_adobe_asks(): void
{
    $this->queueBaseUris();
    $this->http->addResponse($this->json(429, ['code' => 'THROTTLING_TOO_MANY_REQUESTS', 'message' => 'Too many requests.'], ['Retry-After' => '3']));
    $this->http->addResponse($this->json(200, ['id' => 'agr-1', 'name' => 'NDA', 'status' => 'OUT_FOR_SIGNATURE']));

    $agreement = $this->sign()->agreements()->get('agr-1');

    self::assertSame('OUT_FOR_SIGNATURE', $agreement->status);
    self::assertSame([3.0], $this->sleeper->sleeps);
}

The SDK's own suite has the same class under the name FakeSleeper. It lives in the package's tests/ directory, which isn't installed with the package, so copy the dozen lines above rather than referencing it. If your code shouldn't retry at all in a test, pass options: new ClientOptions(maxRetries: 0) instead.

Controlling time for OAuth

Token expiry is decided by a PSR-20 clock. A frozen clock lets you test refresh behaviour without waiting an hour:

tests/FrozenClock.php
use Psr\Clock\ClockInterface;

final class FrozenClock implements ClockInterface
{
    public function __construct(private \DateTimeImmutable $now = new \DateTimeImmutable('2026-11-03T12:00:00Z')) {}

    public function now(): \DateTimeImmutable
    {
        return $this->now;
    }

    public function advance(int $seconds): void
    {
        $this->now = $this->now->modify("+{$seconds} seconds");
    }
}
use Conceptlz\AdobeSign\AdobeSign;
use Conceptlz\AdobeSign\Auth\{AccessToken, InMemoryTokenStore, OAuthClient, OAuthConfig, OAuthTokenProvider};

public function test_refreshes_a_token_that_is_about_to_expire(): void
{
    $clock = new FrozenClock();
    $store = new InMemoryTokenStore();
    $store->put('user-1', new AccessToken(
        accessToken: 'old-access-token',
        refreshToken: 'test-refresh-token',
        expiresAt: $clock->now()->modify('+30 seconds'),      // inside the 60-second margin
        apiAccessPoint: 'https://api.na1.adobesign.com/',      // so there is no GET /baseUris
    ));

    $oauth = new OAuthClient(
        new OAuthConfig('test-client-id', 'test-client-secret', 'https://app.example.com/adobe/callback'),
        $this->http, $this->psr17, $this->psr17, $clock,
    );
    $sign = new AdobeSign(
        new OAuthTokenProvider($oauth, $store, 'user-1', $clock),
        httpClient: $this->http, requestFactory: $this->psr17, streamFactory: $this->psr17,
    );

    $this->http->addResponse($this->json(200, ['access_token' => 'new-access-token', 'expires_in' => 3600, 'token_type' => 'Bearer']));
    $this->http->addResponse($this->json(200, ['email' => 'jane@example.com']));

    $sign->users()->me();

    [$refresh, $me] = $this->http->getRequests();
    self::assertSame('https://api.na1.adobesign.com/oauth/v2/refresh', (string) $refresh->getUri());
    self::assertSame('Bearer new-access-token', $me->getHeaderLine('Authorization'));
    self::assertSame('new-access-token', $store->get('user-1')?->accessToken);
}

Call $clock->advance(3600) between two calls to test a token that expires during a long-running job. The values here are test values; never put a real client secret in a test.

Testing your webhook handler

WebhookHandler::handle() takes a PSR-7 server request and returns the response plus the parsed event, so you can test your endpoint without Adobe or a tunnel. Build the request by hand, with a body shaped like Adobe's notifications:

use Conceptlz\AdobeSign\Webhook\{AgreementEvent, WebhookHandler};
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7\ServerRequest;

public function test_completed_agreement_is_acknowledged_and_parsed(): void
{
    $psr17 = new Psr17Factory();
    $handler = new WebhookHandler(['test-client-id']);

    $request = new ServerRequest('POST', 'https://app.example.com/adobe-sign/webhook', [
        'X-AdobeSign-ClientId' => 'test-client-id',
        'Content-Type' => 'application/json',
    ], json_encode([
        'event' => 'AGREEMENT_WORKFLOW_COMPLETED',
        'webhookNotificationId' => 'notif-1',
        'eventDate' => '2026-11-03T14:22:10Z',
        'participantUserEmail' => 'jane@example.com',
        'agreement' => ['id' => 'agr-1', 'name' => 'Mutual NDA', 'status' => 'SIGNED'],
    ]));

    $result = $handler->handle($request, $psr17, $psr17);

    self::assertTrue($result->isAccepted());
    self::assertSame(200, $result->response->getStatusCode());
    self::assertSame('test-client-id', $result->response->getHeaderLine('X-AdobeSign-ClientId'));
    self::assertInstanceOf(AgreementEvent::class, $result->event);
    self::assertTrue($result->event->isWorkflowCompleted());
    self::assertSame('agr-1', $result->event->agreementId());
    self::assertSame('notif-1', $result->event->notificationId);
}

public function test_unknown_client_is_rejected_without_echo(): void
{
    $psr17 = new Psr17Factory();
    $request = new ServerRequest('POST', '/adobe-sign/webhook', ['X-AdobeSign-ClientId' => 'someone-else'], '{}');

    $result = (new WebhookHandler(['test-client-id']))->handle($request, $psr17, $psr17);

    self::assertFalse($result->isAccepted());
    self::assertSame(403, $result->response->getStatusCode());
    self::assertFalse($result->response->hasHeader('X-AdobeSign-ClientId'));
}

Cases worth a test each:

  • The verification GET (no body): $result->isVerification is true and the echo is present.
  • A duplicate delivery with the same webhookNotificationId: your code should ignore the second one.
  • An unknown event type: it arrives as UnknownEvent, and your code should acknowledge it rather than fail.
  • An unreadable body: the handler answers 400, so Adobe retries.

If your handler acts on events by calling the API (it should re-fetch the agreement rather than trust the payload), combine this with the fake HTTP client above.

Checking requests against Adobe's spec

A fake client tests your logic, but it can't tell you whether the JSON you send is something Adobe would accept. The SDK's own suite answers that by validating every request it sends and every canned response it is fed against Adobe's published OpenAPI document:

  • tests/Support/ContractCheckingClient wraps the mock client and passes each request and response to tests/Support/SpecContract.
  • SpecContract finds the matching operation in spec/adobe-sign-v6.json and checks that it exists, that every query parameter is declared, that JSON bodies match the request schema, that multipart parts are known, and that 2xx JSON responses match the declared response schema. It uses justinrainbow/json-schema.
  • The spec is first cleaned up by spec/normalize.php, the same normaliser the model generator uses, so the tests and the models read the spec the same way.
  • After each test, any violation fails it. A test opts out with $checkSpecContract = false only when it deliberately sends something the spec forbids.

This is why the fixtures in the SDK's test suite are realistic: a fixture that drifts from the spec fails the build. Where the live API differs from the spec, the fixture uses the real shape and the difference is documented.

Doing the same in your project

tests/ and spec/ are left out of the installed package to keep vendor/ small, so you can't reference these classes from your project. The approach is easy to copy, though: a decorator around your fake client that validates traffic before passing it on.

  1. Get the spec

    Download spec/adobe-sign-v6.json (and spec/normalize.php) from the SDK's GitHub repository into your test fixtures, or use Adobe's own copy.

  2. Add a validator

    composer require --dev justinrainbow/json-schema, then validate each request body against the schema its operation declares.

  3. Wrap the client

    Implement Psr\Http\Client\ClientInterface, record violations in sendRequest(), and delegate to the mock client.

  4. Fail on violations

    Assert the list is empty in assertPostConditions(), as the SDK's TestCase does.

final class SpecCheckingClient implements ClientInterface
{
    /** @var list<string> */
    public array $violations = [];

    public function __construct(private readonly ClientInterface $inner, private readonly MySpecValidator $spec) {}

    public function sendRequest(RequestInterface $request): ResponseInterface
    {
        $request->getBody()->rewind();
        array_push($this->violations, ...$this->spec->checkRequest($request));
        $request->getBody()->rewind();

        return $this->inner->sendRequest($request);
    }
}

The SDK already guarantees that its own builders produce spec-valid payloads. This is most useful when your code sends raw arrays or uses with() to add fields.

Live tests against a real account

Mocked tests can't catch everything: some behaviour only shows against the real API. The SDK has an opt-in suite in tests/Live that runs against a real Acrobat Sign account. It is never part of composer test or CI.

Live tests send real emails

Every agreement they create is sent to the addresses in ADOBE_SIGN_TEST_SIGNERS, and Adobe emails those people. Use only addresses you control, ideally on a free developer account. Every agreement a test creates is cancelled afterwards, even when the test fails, but the emails have already gone.

  1. Clone and install

    Live tests run from a checkout of the SDK, not from vendor/.

    git clone https://github.com/conceptlz/adobe-sign.git
    cd adobe-sign
    composer install
  2. Create a .env

    The suite reads .env from the package directory (or, in the development monorepo, from its root). Keep the file out of version control and readable only by you.

    ADOBE_SIGN_INTEGRATION_KEY=your-integration-key
    ADOBE_SIGN_TEST_SIGNERS=signer-one@example.com,signer-two@example.com,signer-three@example.com
    
    # Optional
    ADOBE_SIGN_LIVE_DEBUG=1                         # log method, path and status to stderr
    ADOBE_SIGN_SIGNED_AGREEMENT_ID=CBJCHBCAABAA...  # an agreement your test signers completed
  3. Run
    composer test:live

    From the monorepo root: composer --working-dir=packages/adobe-sign test:live. Without a key the whole suite is skipped; tests that need more signers than you listed are skipped individually.

Test classWhat it checks against the real API
ConnectionTestThe key is valid, the data centre is discovered and then used; agreement lists page lazily
SigningOrderTestSequential, parallel and mixed orders, checked server-side by who can sign at each step
AgreementOperationsTestSend, download and save, remind, inspect, cancel with If-Match; draft then send
SignatureLevelTestAES request stored and reported by evidence; email OTP stored; a refused identity method becomes AccountCapabilityException; evidence on a completed agreement
ErrorsTestReal error codes map to the right exceptions, and the key is redacted from them

The live suite is where the SDK's knowledge of the API's quirks comes from: nextParticipantSets missing unless asked for, ETags going stale after a state change, providerName instead of cloudProviderName. Each one found this way also has a mocked regression test, so CI keeps it fixed.

Live tests for your own application

The same pattern works for your integration:

  • Skip unless a key is configured, so CI and other developers aren't affected.
  • Keep a list of every agreement a test creates and cancel them in a teardown that runs even on failure; the SDK's tests/Live/LiveTestCase.php is a compact example to copy.
  • Poll for status after creating, because Adobe processes documents asynchronously.
  • Retry a cancel once on PreconditionFailedException after a fresh get().
  • Never sign on anyone's behalf from a test; signing is for the people who receive the emails.

For a hands-on run where a person actually signs, use examples/live-lifecycle.php (Example scripts).

Testing in Laravel

The Laravel bridge binds the client in the container, so you can swap in a client built as above. Its own testing guide covers facades, fake webhooks and queued listeners: Testing your application.

Common questions

Do I need a mock server, like WireMock?

No. Because the SDK takes any PSR-18 client, a fake client in the same process does the job with no ports, no containers and no flakiness. A mock server only makes sense if you want to test across processes.

Where can I get realistic response bodies?

The SDK's repository has spec-validated fixtures in tests/Fixtures (agreements, members, events, webhooks, errors). Or record your own: run a live call with a middleware that dumps the response body, then strip any personal data before committing it.

How do I assert that my code didn't make extra requests?

self::assertCount(3, $this->http->getRequests()). Set a default exception on the mock client (as in the base test case) so an unexpected extra request fails the test immediately.

Should I run live tests in CI?

Generally not. They send real emails, depend on Adobe's availability and use your rate limit. Run them by hand before a release or after changing how you call the API, against a developer account.

Is a free developer account enough for testing?

For most things, yes. Agreement names get a [DEMO USE ONLY] prefix, and features that depend on plan or settings (hybrid routing, government ID, digital-signature providers) may be off, in which case you get AccountCapabilityException.