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
| Layer | Tool | Network | Runs in CI |
|---|---|---|---|
| Your business logic around the SDK | A fake PSR-18 client with queued responses | none | yes |
| Retry and token-refresh paths | The same, plus a recording Sleeper and a frozen clock | none | yes |
| Your webhook endpoint | WebhookHandler with a hand-built PSR-7 request | none | yes |
| Payload correctness | Validate requests against Adobe's OpenAPI spec | none | yes |
| The real thing | Live tests against a developer account | Adobe | no: 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):
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:
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:
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 anETagheader).$request->getHeaderLine('x-api-user'): thatasUser()targeted the right person, e.g.email:jane@example.com.$request->getUri()->getQuery(): list parameters such aspageSizeorincludeNextParticipantSet=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:
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:
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->isVerificationis 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/ContractCheckingClientwraps the mock client and passes each request and response totests/Support/SpecContract.SpecContractfinds the matching operation inspec/adobe-sign-v6.jsonand 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 usesjustinrainbow/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 = falseonly 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.
- Get the spec
Download
spec/adobe-sign-v6.json(andspec/normalize.php) from the SDK's GitHub repository into your test fixtures, or use Adobe's own copy. - Add a validator
composer require --dev justinrainbow/json-schema, then validate each request body against the schema its operation declares. - Wrap the client
Implement
Psr\Http\Client\ClientInterface, record violations insendRequest(), and delegate to the mock client. - Fail on violations
Assert the list is empty in
assertPostConditions(), as the SDK'sTestCasedoes.
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.
- 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 - Create a .env
The suite reads
.envfrom 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 - Run
composer test:liveFrom 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 class | What it checks against the real API |
|---|---|
ConnectionTest | The key is valid, the data centre is discovered and then used; agreement lists page lazily |
SigningOrderTest | Sequential, parallel and mixed orders, checked server-side by who can sign at each step |
AgreementOperationsTest | Send, download and save, remind, inspect, cancel with If-Match; draft then send |
SignatureLevelTest | AES request stored and reported by evidence; email OTP stored; a refused identity method becomes AccountCapabilityException; evidence on a completed agreement |
ErrorsTest | Real 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.phpis a compact example to copy. - Poll for status after creating, because Adobe processes documents asynchronously.
- Retry a cancel once on
PreconditionFailedExceptionafter a freshget(). - 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.