Tutoriel · Salesforce · Symfony · Tests

Tester une intégration Salesforce avec Symfony

Six tests de contrat pour le client du middleware : vérifier les requêtes et les réponses, isoler le jeton OAuth et éviter les reprises infinies, sans appeler une org.

Tester un contrat observable

Un test qui vérifie seulement qu’un mock renvoie un tableau ne protège pas une intégration. Il faut contrôler ce que le client envoie et ce qu’il fait face aux réponses : requête SOQL, corps JSON, authentification, réponse vide, erreur métier et session expirée.

Prérequis : PHP 8.2 ou supérieur, Symfony HttpClient 7.4, PHPUnit et le SalesforceClient avec son exception présentés dans l’article API façade Symfony devant Salesforce. Le code de ce client utilise une version d’API Salesforce configurable, dont v62.0 est ici la valeur par défaut testée. Adaptez l’assertion de chemin si vous changez cette configuration.

Notre service fictif utilise https://acme.example et des identifiants inventés. Aucune clé réelle n’est nécessaire ; les tests remplacent le transport HTTP. Leur objectif est le contrat du client, pas la validation du schéma d’une org.

Isoler la fourniture du jeton

Le fournisseur OAuth de la série est une classe finale. Au lieu de contourner cette propriété pour la doubler, introduisez une petite interface dont le client dépend. Le fournisseur concret conserve sa logique de cache et de renouvellement.

<?php

namespace App\Salesforce;

interface SalesforceCredentials
{
    /** @return array{access_token: string, instance_url: string} */
    public function token(): array;

    public function invalidate(): void;
}

Dans SalesforceTokenProvider, ajoutez implements SalesforceCredentials. Dans le constructeur de SalesforceClient, remplacez uniquement le type SalesforceTokenProvider $tokens par SalesforceCredentials $tokens. Les méthodes token() et invalidate() gardent leur contrat. Déclarez ensuite le service concret :

# config/services.yaml
services:
    App\Salesforce\SalesforceCredentials: '@App\Salesforce\SalesforceTokenProvider'

Le test du fournisseur OAuth reste séparé : cache, expiration et requête de jeton relèvent de l’authentification Salesforce. Ici, on vérifie seulement que le client utilise le jeton fourni et demande son invalidation au bon moment.

Vérifier les lectures et les écritures

<?php

namespace App\Tests\Salesforce;

use App\Salesforce\SalesforceClient;
use App\Salesforce\SalesforceCredentials;
use App\Salesforce\SalesforceException;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;

class SalesforceClientTest extends TestCase
{
    private const TOKEN = [
        'access_token' => 'test-access-token',
        'instance_url' => 'https://acme.example',
    ];

    public function testSendsTheExpectedQueryAndBearer(): void
    {
        $response = new MockResponse(json_encode([
            'totalSize' => 1, 'done' => true,
            'records' => [['Id' => '003000000000001AAA', 'Name' => 'Camille Démo']],
        ], JSON_THROW_ON_ERROR));
        $http = new MockHttpClient($response);
        $tokens = $this->createMock(SalesforceCredentials::class);
        $tokens->expects(self::once())->method('token')->willReturn(self::TOKEN);
        $tokens->expects(self::never())->method('invalidate');
        $client = new SalesforceClient($http, $tokens);
        $soql = "SELECT Id, Name FROM Contact WHERE LastName = 'Démo'";

        $data = $client->request('GET', '/query', ['query' => ['q' => $soql]]);

        self::assertSame('Camille Démo', $data['records'][0]['Name']);
        self::assertSame('GET', $response->getRequestMethod());
        self::assertSame('/services/data/v62.0/query', parse_url($response->getRequestUrl(), PHP_URL_PATH));
        parse_str((string) parse_url($response->getRequestUrl(), PHP_URL_QUERY), $query);
        self::assertSame($soql, $query['q']);
        self::assertContains('Authorization: Bearer test-access-token', $response->getRequestOptions()['headers']);
        self::assertSame(1, $http->getRequestsCount());
    }

    public function testAcceptsAnEmpty204AfterAnUpsert(): void
    {
        $response = new MockResponse('', ['http_code' => 204]);
        $http = new MockHttpClient($response);
        $tokens = $this->createStub(SalesforceCredentials::class);
        $tokens->method('token')->willReturn(self::TOKEN);
        $client = new SalesforceClient($http, $tokens);

        $result = $client->request('PATCH', '/sobjects/Contact/Ref_Externe__c/CLI-000042', [
            'json' => ['LastName' => 'Démo'],
        ]);

        self::assertNull($result);
        self::assertSame('PATCH', $response->getRequestMethod());
        self::assertStringEndsWith('/Contact/Ref_Externe__c/CLI-000042', $response->getRequestUrl());
        self::assertSame(['LastName' => 'Démo'], json_decode($response->getRequestOptions()['body'], true, 512, JSON_THROW_ON_ERROR));
    }

    // Ajouter ici les quatre méthodes de la section suivante.
}

Les assertions lisent les requêtes capturées par MockResponse. Le SOQL est comparé après décodage de l’URL, pour ne pas dépendre d’une forme d’encodage particulière. Le corps d’un PATCH est décodé comme JSON. Une réponse 204 ne doit pas passer dans un décodeur qui exige un document JSON.

Ces contrôles portent sur des comportements utiles à un appelant : un champ ou un verbe HTTP erroné, un jeton manquant ou une tentative de décoder un corps vide doit faire échouer le test.

Distinguer données, authentification et transport

// Méthodes à insérer dans SalesforceClientTest, avant son accolade finale.
public function testPreservesAValidationErrorWithoutRetrying(): void
{
    // L’API Salesforce renvoie ici un refus métier HTTP 400, pas un HTTP 422.
    $response = new MockResponse(json_encode([[
        'errorCode' => 'FIELD_CUSTOM_VALIDATION_EXCEPTION',
        'message' => 'Valeur refusée dans la démonstration.',
        'fields' => ['LastName'],
    ]], JSON_THROW_ON_ERROR), ['http_code' => 400]);
    $http = new MockHttpClient($response);
    $tokens = $this->createMock(SalesforceCredentials::class);
    $tokens->method('token')->willReturn(self::TOKEN);
    $tokens->expects(self::never())->method('invalidate');
    $client = new SalesforceClient($http, $tokens);

    try {
        $client->request('PATCH', '/sobjects/Contact/003000000000001AAA', [
            'json' => ['LastName' => 'Démo'],
        ]);
        self::fail('Le refus Salesforce aurait dû être propagé.');
    } catch (SalesforceException $error) {
        self::assertSame(400, $error->httpStatus);
        self::assertSame('FIELD_CUSTOM_VALIDATION_EXCEPTION', $error->errorCode);
        self::assertSame(['LastName'], $error->fields);
    }
    self::assertSame(1, $http->getRequestsCount());
}

public function testRefreshesTheTokenOnceAfter401(): void
{
    $expired = new MockResponse('[{"errorCode":"INVALID_SESSION_ID","message":"Expired"}]', ['http_code' => 401]);
    $success = new MockResponse('{"records":[],"done":true,"totalSize":0}');
    $http = new MockHttpClient([$expired, $success]);
    $tokens = $this->createMock(SalesforceCredentials::class);
    $tokens->expects(self::exactly(2))->method('token')->willReturnOnConsecutiveCalls(
        self::TOKEN,
        ['access_token' => 'renewed-test-token', 'instance_url' => 'https://acme.example'],
    );
    $tokens->expects(self::once())->method('invalidate');
    $client = new SalesforceClient($http, $tokens);

    self::assertSame([], $client->request('GET', '/query')['records']);
    self::assertSame(2, $http->getRequestsCount());
    self::assertContains('Authorization: Bearer renewed-test-token', $success->getRequestOptions()['headers']);
}

public function testStopsAfterASecond401(): void
{
    $http = new MockHttpClient([
        new MockResponse('[{"errorCode":"INVALID_SESSION_ID","message":"Expired"}]', ['http_code' => 401]),
        new MockResponse('[{"errorCode":"INVALID_SESSION_ID","message":"Still invalid"}]', ['http_code' => 401]),
    ]);
    $tokens = $this->createMock(SalesforceCredentials::class);
    $tokens->expects(self::exactly(2))->method('token')->willReturn(self::TOKEN);
    $tokens->expects(self::once())->method('invalidate');
    $client = new SalesforceClient($http, $tokens);

    try {
        $client->request('GET', '/query');
        self::fail('Un deuxième 401 doit interrompre la reprise.');
    } catch (SalesforceException $error) {
        self::assertSame(401, $error->httpStatus);
    }
    self::assertSame(2, $http->getRequestsCount());
}

public function testPropagatesATransportFailure(): void
{
    $http = new MockHttpClient(new MockResponse('', ['error' => 'Host unreachable']));
    $tokens = $this->createStub(SalesforceCredentials::class);
    $tokens->method('token')->willReturn(self::TOKEN);
    $client = new SalesforceClient($http, $tokens);

    $this->expectException(TransportExceptionInterface::class);
    $client->request('GET', '/query');
}
Signal observéContrat testéResponsabilité suivante
Refus de validation SalesforceCode et champs conservés, une seule requêteTraduire vers la réponse de l’API façade, par exemple 422 selon son contrat.
Premier HTTP 401Jeton invalidé, un nouvel appel avec le jeton suivantObserver les incidents d’authentification sans journaliser les secrets.
Deuxième HTTP 401Exception, arrêt après deux requêtesCorriger la configuration ou les droits ; éviter une boucle infinie.
Échec de transportException de transport propagéeDécider de la reprise selon l’idempotence et le traitement appelant.

Un timeout d’écriture ne prouve pas que Salesforce n’a rien enregistré. Pour rendre les nouvelles tentatives sûres, utilisez un identifiant externe et un upsert, ou un autre mécanisme d’idempotence adapté. La politique de reprise de Symfony Messenger se teste séparément du transport HTTP.

Exécuter et compléter la suite

Assemblez les six méthodes dans la classe de test, avec les classes du middleware et l’interface indiquées. Lancez le filtre SalesforceClientTest avec la commande PHPUnit de votre projet. Dans un dépôt qui impose un routeur de commandes, utilisez sa commande de test nommée. Aucun accès Salesforce ni identifiant de production ne doit être fourni à cette suite.

  • Ajoutez un cas JSON mal formé et un cas 403 : ils ne doivent pas déclencher un renouvellement aveugle du jeton.
  • Testez une indisponibilité HTTP et les règles de reprise au niveau qui en est responsable, pas en activant des retries partout.
  • Un test fonctionnel de la façade doit contrôler la réponse utilisateur et l’absence d’identifiants sensibles dans l’erreur.
  • Complétez par une vérification dans une sandbox dédiée : objets, champs, permission sets et limites réelles ne sont pas simulés par MockHttpClient.

Ne copiez pas un export HTTP réel contenant des jetons dans les fixtures. Les réponses minimales inventées rendent les attentes plus lisibles et évitent de diffuser des données de clients.

Sources et articles associés

La documentation Symfony HttpClient 7.4 décrit MockHttpClient, les réponses simulées et les données de requête capturées. Les attentes de doubles suivent la documentation PHPUnit ; choisissez une version de PHPUnit compatible avec votre version de PHP.

Pour replacer ces tests dans la chaîne : client et API façade, fournisseur OAuth, synchronisation asynchrone et validation et déploiement Salesforce en CI/CD.

🍪 Gestion des cookies
Ce site utilise des cookies pour mesurer l'audience (Google Analytics) et lire des vidéos (YouTube). Vous pouvez personnaliser votre choix.
Mentions légales
Réglages des cookies
Essentiels Nécessaires au fonctionnement du site — ne peuvent pas être désactivés.
Google Analytics Analyse d'audience (pages vues, sources de trafic). Aucune donnée personnelle identifiante.
YouTube Permet de lire les vidéos YouTube intégrées sur cette page.