Help
Testing your application
Your tests should never email real signers or depend on Adobe being up. This page shows how to replace the HTTP client the SDK uses in the test environment, script Adobe's responses, assert on what your code sent, and drive the webhook and OAuth routes from WebTestCase. The patterns are the ones the bundle's own test suite uses.
Where to fake: the PSR-18 client
You could mock AdobeSign or AdobeSignManager, but you would then be testing your mocks: the
builders, the JSON they produce, the data-centre lookup and the error mapping would never run. Fake one level
lower instead. Every request the SDK makes goes through a single PSR-18 client, and the bundle lets you choose
that client with adobe_sign.http_client. Point it at a mock in the test environment and everything
above it, including the bundle's controllers, runs for real.
Setting up the mock client
composer require --dev php-http/mock-client
when@test:
adobe_sign:
integration_key: 'test-key'
http_client: test.adobe_sign_http
cache_pool: test.adobe_sign_cache
retries:
max: 0 # a queued 429/503 fails at once instead of sleeping
oauth:
client_id: 'test-client-id'
client_secret: 'test-secret'
webhooks:
client_ids: ['test-client-id']
services:
test.adobe_sign_http:
class: Http\Mock\Client
public: true
test.adobe_sign_cache: # fresh per kernel, survives between requests
class: Symfony\Component\Cache\Adapter\ArrayAdapter
public: true
framework:
messenger:
transports:
async: 'in-memory://'
Three details make this work:
- The mock is public. A private service referenced only by the manager would be inlined into it, and you couldn't fetch it to queue responses.
public: truekeeps it addressable asstatic::getContainer()->get('test.adobe_sign_http'). - The cache is a plain
ArrayAdapterservice. With the defaultcache.app(filesystem), the data-centre lookup from one test is still cached in the next, so the number of requests changes between runs. Array pools declared underframework.cache.poolsare cleared after every request, which would break duplicate detection across two webhook requests in one test. A plain service is fresh per kernel and stable within it, which is exactly what tests need. - Retries are off. The SDK retries 429 and 503 with real waits. With
max: 0a queued error surfaces immediately as an exception.
namespace App\Tests;
use Http\Mock\Client as MockClient;
use Nyholm\Psr7\Response;
use Psr\Http\Message\RequestInterface;
trait AdobeSignTestTrait
{
protected static function adobe(): MockClient
{
return static::getContainer()->get('test.adobe_sign_http');
}
/** Queue a response: a fixture file from tests/Fixtures/adobe, or a literal JSON body. */
protected static function queue(int $status, ?string $body = null, array $headers = []): void
{
$file = __DIR__."/Fixtures/adobe/$body.json";
$contents = $body === null ? '' : (is_file($file) ? (string) file_get_contents($file) : $body);
if ($body !== null) {
$headers += ['Content-Type' => 'application/json'];
}
static::adobe()->addResponse(new Response($status, $headers, $contents));
}
protected static function sent(int $index): RequestInterface
{
$requests = static::adobe()->getRequests();
self::assertArrayHasKey($index, $requests, "No request #$index was sent to Adobe.");
return $requests[$index];
}
}
{"apiAccessPoint": "https://api.na4.adobesign.com/", "webAccessPoint": "https://secure.na4.adobesign.com/"}
Know the request order
The mock answers requests in the order you queued them, so you need to know what the SDK sends. For an Integration Key client in a fresh kernel:
- GET https://api.adobesign.com/api/rest/v6/baseUris: the data-centre lookup, once per kernel (then cached in
test.adobe_sign_cache). - Your calls, in order, against
https://api.na4.adobesign.com/api/rest/v6/…(the host from the fixture).
A forUser() client whose stored token has an api_access_point skips the lookup. A token that
is about to expire triggers a refresh (POST /oauth/v2/token) before the call;
store tokens that expire in the future in tests. signatureEvidence() and
signatureShortfalls() send three calls: the agreement, its members, its events.
If you queue too few responses, Http\Mock\Client quietly answers the extra request with an empty 200,
which then fails somewhere less obvious. Make unexpected calls fail loudly in setUp():
self::adobe()->setDefaultException(new \LogicException('Unexpected request to Acrobat Sign.')).
Testing a controller that sends an agreement
namespace App\Tests\Functional;
use App\Tests\AdobeSignTestTrait;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\File\UploadedFile;
final class ContractControllerTest extends WebTestCase
{
use AdobeSignTestTrait;
public function test_sending_a_contract_creates_the_agreement_in_order(): void
{
$client = static::createClient();
$client->disableReboot(); // keep the same container (and mock) for the whole test
$client->loginUser($this->staffUser());
$client->setServerParameter('HTTPS', 'on'); // WebhookInfo only accepts https:// URLs
self::queue(200, 'base_uris');
self::queue(201, '{"transientDocumentId":"tdoc-1"}');
self::queue(201, '{"id":"agr-1"}');
self::queue(201, '{"id":"wh-1"}');
$client->request('POST', '/contracts', [
'title' => 'MSA',
'client_email' => 'client@example.com',
'_token' => $this->csrfToken($client, 'send-contract'),
], ['contract' => new UploadedFile(__DIR__.'/../Fixtures/contract.pdf', 'msa.pdf', 'application/pdf', test: true)]);
self::assertResponseRedirects('/contracts/new');
// What did we send?
self::assertStringEndsWith('/transientDocuments', (string) self::sent(1)->getUri());
$agreement = json_decode((string) self::sent(2)->getBody(), true);
self::assertSame('MSA', $agreement['name']);
self::assertSame('client@example.com', $agreement['participantSetsInfo'][0]['memberInfos'][0]['email']);
self::assertSame(1, $agreement['participantSetsInfo'][0]['order']);
self::assertSame(2, $agreement['participantSetsInfo'][1]['order']);
$webhook = json_decode((string) self::sent(3)->getBody(), true);
self::assertSame('agr-1', $webhook['resourceId']);
self::assertStringEndsWith('/adobe-sign/webhook', $webhook['webhookUrlInfo']['url']);
}
}
staffUser() and csrfToken() stand for your own helpers. Asserting on the JSON body is the
most valuable part: it proves the builder produced what Adobe expects, not just that your code called a method.
The HTTPS server parameter matters because the webhook URL is generated from the test request, and
the core's WebhookInfo rejects http:// URLs before sending anything.
Simulating Adobe errors
use Conceptlz\AdobeSign\Exception\{AccountCapabilityException, AuthenticationException, RateLimitException};
self::queue(401, '{"code":"INVALID_ACCESS_TOKEN","message":"Access token provided is invalid or has expired."}');
// -> AuthenticationException
self::queue(400, '{"code":"HYBRID_ROUTING_NOT_ALLOWED","message":"Hybrid routing is not allowed."}');
// -> AccountCapabilityException
self::queue(429, '{"code":"THROTTLING_TOO_MANY_REQUESTS","message":"Slow down."}', ['Retry-After' => '120']);
// -> RateLimitException with $retryAfter === 120
self::adobe()->addException(new \Http\Client\Exception\NetworkException('Connection refused', new \Nyholm\Psr7\Request('GET', '/')));
// -> TransportException
Then assert on what your app does with them: the status code your exception listener chose, the flash message,
the message re-dispatched with a DelayStamp. The core's
errors page lists which codes map to which class.
Testing webhooks
The webhook route doesn't call Adobe at all, so these tests need no queued responses. POST a payload with the
client-id header, exactly as the bundle's own WebhookTest does:
use Conceptlz\AdobeSign\Symfony\Event\WebhookReceivedEvent;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
final class WebhookTest extends WebTestCase
{
private static function post(KernelBrowser $client, string $body, string $clientId = 'test-client-id'): void
{
$client->request('POST', '/adobe-sign/webhook', server: [
'HTTP_X_ADOBESIGN_CLIENTID' => $clientId,
'CONTENT_TYPE' => 'application/json',
], content: $body);
}
public function test_verification_echoes_the_client_id(): void
{
$client = static::createClient();
$client->request('GET', '/adobe-sign/webhook', server: ['HTTP_X_ADOBESIGN_CLIENTID' => 'test-client-id']);
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('X-AdobeSign-ClientId', 'test-client-id');
self::assertSame(['xAdobeSignClientId' => 'test-client-id'], json_decode($client->getResponse()->getContent(), true));
}
public function test_a_retry_is_dispatched_only_once(): void
{
$client = static::createClient();
$client->disableReboot();
$received = [];
static::getContainer()->get('event_dispatcher')
->addListener(WebhookReceivedEvent::class, function (WebhookReceivedEvent $e) use (&$received): void {
$received[] = $e;
});
$payload = file_get_contents(__DIR__.'/../Fixtures/adobe/webhook_workflow_completed.json');
self::post($client, $payload);
self::post($client, $payload); // Adobe retries the same notification
self::assertResponseIsSuccessful();
self::assertCount(1, $received);
}
public function test_an_unknown_client_is_rejected(): void
{
$client = static::createClient();
self::post($client, '{"event":"AGREEMENT_CREATED"}', 'someone-else');
self::assertResponseStatusCodeSame(403);
self::assertFalse($client->getResponse()->headers->has('X-AdobeSign-ClientId'));
}
}
disableReboot() matters in the second test: by default the test client reboots the kernel between
requests, which would give you a fresh cache and a fresh dispatcher, and the retry would look new.
Asserting on the Messenger transport
use Conceptlz\AdobeSign\Symfony\Message\AdobeSignWebhookMessage;
use Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport;
self::post($client, $payload);
/** @var InMemoryTransport $transport */
$transport = static::getContainer()->get('messenger.transport.async');
self::assertCount(1, $transport->getSent());
$message = $transport->getSent()[0]->getMessage();
self::assertInstanceOf(AdobeSignWebhookMessage::class, $message);
self::assertSame('CBJCHBCAABAAnotif1', $message->notificationId());
Assert straight after the request: the in-memory transport is a resettable service and starts empty again
after the next request. To check that a duplicate produced no second message, assert
getSent() is empty after posting the same payload again.
Testing your own handlers
Handlers are plain services. Build the message yourself, queue Adobe's answers, call the handler, and assert on
the result. A KernelTestCase gives you the real services with the mock client:
use App\MessageHandler\StoreSignedAgreement;
use App\Tests\AdobeSignTestTrait;
use Conceptlz\AdobeSign\Symfony\Message\AdobeSignWebhookMessage;
use Nyholm\Psr7\Response;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
final class StoreSignedAgreementTest extends KernelTestCase
{
use AdobeSignTestTrait;
public function test_a_signed_agreement_is_downloaded_and_stored(): void
{
self::bootKernel();
$contract = $this->createSentContract(agreementId: 'agr-1'); // your fixture helper
self::queue(200, 'base_uris');
self::queue(200, '{"id":"agr-1","name":"MSA","status":"SIGNED","signatureType":"ESIGN","participantSetsInfo":[]}');
self::adobe()->addResponse(new Response(200, ['Content-Type' => 'application/pdf'], '%PDF-1.7 signed'));
$handler = static::getContainer()->get(StoreSignedAgreement::class);
$handler(new AdobeSignWebhookMessage([
'event' => 'AGREEMENT_WORKFLOW_COMPLETED',
'webhookNotificationId' => 'n-1',
'agreement' => ['id' => 'agr-1', 'status' => 'SIGNED'],
]));
self::assertSame('signed', $contract->getStatus());
self::assertStringContainsString('/combinedDocument', (string) self::sent(2)->getUri());
self::assertStringContainsString('attachAuditReport=true', (string) self::sent(2)->getUri());
}
public function test_a_second_run_does_nothing(): void
{
self::bootKernel();
$this->createSignedContract(agreementId: 'agr-1');
$handler = static::getContainer()->get(StoreSignedAgreement::class);
$handler(new AdobeSignWebhookMessage(['event' => 'AGREEMENT_WORKFLOW_COMPLETED', 'agreement' => ['id' => 'agr-1']]));
self::assertSame([], self::adobe()->getRequests()); // idempotent: no API calls at all
}
}
The second test is often the more valuable one: it pins down idempotency, which is what saves you when Messenger redelivers a message in production.
Testing OAuth code
The connect flow
Drive both routes with a logged-in user, read the state from the redirect, and queue Adobe's token response.
This mirrors the bundle's OAuthTest:
use Conceptlz\AdobeSign\Symfony\AdobeSignManager;
public function test_connecting_stores_the_token(): void
{
$client = static::createClient();
$client->disableReboot();
$client->loginUser($user = $this->userWithEmail('jane@example.com'));
$client->request('GET', '/adobe-sign/oauth/redirect');
self::assertResponseRedirects();
parse_str((string) parse_url($client->getResponse()->headers->get('Location'), PHP_URL_QUERY), $query);
self::assertSame('test-client-id', $query['client_id']);
self::queue(200, '{"access_token":"at","refresh_token":"rt","expires_in":3600,"token_type":"Bearer","api_access_point":"https://api.eu1.adobesign.com/"}');
$client->request('GET', '/adobe-sign/oauth/callback?'.http_build_query([
'code' => 'auth-code',
'state' => $query['state'],
'api_access_point' => 'https://api.eu1.adobesign.com/',
]));
self::assertResponseRedirects('/'); // oauth.redirect_after
self::assertSame('https://api.eu1.adobesign.com/oauth/v2/token', (string) self::sent(0)->getUri());
self::assertTrue(static::getContainer()->get(AdobeSignManager::class)->isConnected($user));
}
Worth adding: a forged state (state=attacker) answers 400 and sends nothing to Adobe; a callback without
a logged-in user answers 401; error=ACCESS_DENIED answers 403.
Code that uses forUser()
Skip the flow and put a token in the store directly:
use Conceptlz\AdobeSign\Auth\AccessToken;
$manager = static::getContainer()->get(AdobeSignManager::class);
$manager->tokens()->put('jane@example.com', new AccessToken(
'user-token', 'refresh', new \DateTimeImmutable('+1 hour'), 'https://api.na4.adobesign.com/',
));
self::queue(200, 'user_me'); // no /baseUris: the token carries the access point
$manager->forUser('jane@example.com')->users()->me();
self::assertSame('Bearer user-token', self::sent(0)->getHeaderLine('Authorization'));
With the default token_store: cache and cache.app, the store in tests is whatever
cache.app is in your test environment. To isolate tests, point oauth.token_cache_pool at
test.adobe_sign_cache too, or use token_store: doctrine with your test database.
Changing configuration inside a test
Configuration is compiled, so a test can't change signature_level at runtime. Options: a separate
environment (static::createClient(['environment' => 'test_qes']) with its own config files), or a custom
test kernel that takes configuration as a constructor argument, which is what the bundle's own
tests/App/TestKernel.php does. For one-off checks of the manager, constructing it directly isn't
supported: its constructor is internal.
Common questions
Can I use Guzzle's MockHandler instead of php-http/mock-client?
Yes. Register a GuzzleHttp\Client built with a HandlerStack around a MockHandler as the http_client service, and add a Middleware::history() to inspect requests. php-http/mock-client is simpler because it records requests itself.
Can I use Symfony's MockHttpClient?
Yes, wrapped as PSR-18: new Psr18Client(new MockHttpClient([...])) registered as the http_client service. Its MockResponse objects work, but you inspect requests through the response callback rather than a request list.
Should I ever test against the real Acrobat Sign API?
Occasionally, in a separate opt-in suite with a developer account and addresses you control, because real calls send real emails. Cancel every agreement you create. Run it before releases, not on every push.
My queued responses disappear between two requests. Why?
The test client reboots the kernel between requests, creating a new container and a new mock. Call $client->disableReboot() right after createClient().
Creating a webhook in a test throws "only delivers webhooks to HTTPS URLs". Why?
The URL was generated from the test request, which is http://localhost. Set $client->setServerParameter('HTTPS', 'on'), or configure framework.router.default_uri: 'https://localhost' for the test environment.