Testing API Clients

In this article I will describe an approach I use to test API clients. Since an API client is a boundary between your code and the outside world you should write as little code as possible to implement it. The client should strictly do its job by sending a request and returning a response. That is why we don’t really test the clients themselves but rather the code that processes the response from the client. Clients in the test code should be mocked in order not to hit a real API endpoint while running tests.

The most frequent mistake in coding API clients is not covering all the scenarios. It is easy enough to write application code that processes a normal response from the remote server. But what happens if things go bad? SoapFault is thrown, important fields on the response are missing? That is why testing is important. It lets you go through all of the scenarios and make sure your application is ready to handle different cases.

Setup

We will be testing a SOAP API client. Scenario is the following: we need to submit an invoice for tax purposes. I will be using Laravel framework because it lets you put things together a little bit faster, but the testing approach is generic and does not require any framework. Another reason I am using Laravel is to elaborate on the statement Facades Vs. Dependency Injection. Let’s take a look at the setup.

We are creating two tables: orders and invoce_records. In Laravel we also create two models Order and InvoiceRecord to access data from those tables.

       Schema::create('orders', function (Blueprint $table) {
            $table->increments('id');
            $table->unsignedInteger('source_id');
            $table->unsignedInteger('customer_id');
            $table->string('status')->default('Pending');
            $table->string('customer_email');
            $table->double('total', 8, 2);
            $table->double('tax_total', 8, 2);
            $table->timestamps();
        });

        Schema::create('invoice_records', function (Blueprint $table) {
            $table->increments('id');
            $table->unsignedInteger('order_id');
            $table->string('uuid', 50)->default('');
            $table->boolean('error')->default(false);
            $table->text('error_message')->nullable();
            $table->timestamps();

            $table->foreign('order_id')
                ->references('id')->on('orders')
                ->onDelete('cascade');
        });

class Order extends Model
{
    protected $guarded = [];

    public function invoiceRecord()
    {
        return $this->hasOne(InvoiceRecord::class);
    }
}

class InvoiceRecord extends Model
{
    protected $guarded = [];
}

Below is our implementation of invoice connector that uses PHP’s native SoapClient class.

// app/Connectors/InvoiceConnector.php
<?php

namespace App\Connectors;

class InvoiceConnector
{
    private $client;

    public function __construct() 
    {
        $arrContextOptions = array(
            "ssl"=>array( 
                "verify_peer"=>false, 
                "verify_peer_name"=>false,
                'crypto_method' => STREAM_CRYPTO_METHOD_TLS_CLIENT)
            );
        
        $options = array(
                'soap_version'=>SOAP_1_2,
                'exceptions'=>true,
                'trace'=>1,
                'cache_wsdl'=>WSDL_CACHE_NONE,
                'stream_context' => stream_context_create($arrContextOptions)
        );
        
        $this->client = new \SoapClient('https://url/dir/file.wsdl', $options);
    }
    public function submitInvoice($order)
    {
        $result = $this->client->uploadInvoce([
            'InvoiceID' => $order->source_id,
            'TaxID' => $order->customer_id,
            'Total' => $order->total, 
            'TaxTotal' => $order->tax_total
        ]);

        return $result;
    }
}

Finally our connector manager class under test looks like this. There is not much there but we will keep adding code to it as we write tests.

// app/Connectors/ConnectorManager.php
<?php

namespace App\Connectors;

class ConnectorManager
{
    public function createInvoiceRecord($order)
    {
        
    }
}

Testing

The test checks if the code that receives responses from the API client creates a record in the invoice_records table. If we receive a response back with UUID of the invoice got accepted by the remote system.

We also created an OrderFactory to facilitate the test.

// tests/Unit/ConnectorManagerTest.php
<?php

namespace Tests\Unit;

use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Order;
use App\Connectors\ConnectorManager;

class ConnectorManagerTest extends TestCase
{
    use RefreshDatabase;

    /** @test */
    public function it_creates_invoice_record()
    {
        $order = factory(Order::class)->create();

        $connectorManager = new ConnectorManager;
        $connectorManager->createInvoiceRecord($order);
       
        $this->assertDatabaseHas('invoice_records', [
            'order_id' => $order->id,
            'uuid' => 'ABCD'
        ]);
    }
}
// database/factories/OrderFactory.php
<?php

use Faker\Generator as Faker;

$factory->define(App\Order::class, function (Faker $faker) {
    return [
        'source_id' => $faker->randomNumber(8),
        'customer_id' => $faker->randomNumber(6),
        'customer_email' => $faker->email,
        'total' => $faker->randomFloat(2, 100, 1000),
        'tax_total' => $faker->randomFloat(2, 5, 20)
    ];
});

Obviously the test is going to fail because we do not have any code in the connector manager. Let’s try to go from red to green by writing code responsible for processing API response.

// app/Connectors/ConnectorManager.php
....
public function createInvoiceRecord($order)
    {
        $connector = new InvoiceConnector;

        $result = $connector->submitInvoice($order);

        if ($result->response->status == 200) {
            $order->invoiceRecord()->create([
                'uuid' => $result->response->uuid
            ]);
        }
    }

The code above won’t work because as you may have noticed I put a fake WSDL in the InvoiceConnector.

1) Tests\Unit\ConnectorManagerTest::it_creates_invoice_record
SoapFault: SOAP-ERROR: Parsing WSDL: Couldn't load from 'https://url/dir/file.wsdl' : failed to load external entity "https://url/dir/file.wsdl"

We can use dependency injection. Instead of instantiating InvoiceConnector class in submitInvoice method, we can pass already instantiated InvoiceConnector object as an argument to the method. To keep things more structured, however, let’s pass the object in the constructor of ConnectorManager instead of submitInvoice method.

// app/Connectors/ConnectorManager.php
<?php

namespace App\Connectors;

class ConnectorManager
{
    private $connector;

    public function __construct(InvoiceConnector $connector)
    {
        $this->connector = $connector;
    }

    public function createInvoiceRecord($order)
    {
        $result = $this->connector->submitInvoice($order);

        if ($result->response->status == 200) {
            $order->invoiceRecord()->create([
                'uuid' => $result->response->uuid
            ]);
        }
    }
}
// tests/Unit/ConnectorManagerTest.php
<?php

namespace Tests\Unit;

use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Order;
use App\Connectors\ConnectorManager;
use App\Connectors\InvoiceConnector;

class ConnectorManagerTest extends TestCase
{
    use RefreshDatabase;

    /** @test */
    public function it_creates_invoice_record()
    {
        $order = factory(Order::class)->create();

        $connector = new InvoiceConnector;

        $connectorManager = new ConnectorManager($connector);
        $connectorManager->createInvoiceRecord($order);
       
        $this->assertDatabaseHas('invoice_records', [
            'order_id' => $order->id,
            'uuid' => 'ABCD'
        ]);
    }
}

We are still going to have the same parsing WSDL error because we are still instantiating InvoiceConnector class. Now we are doing it outside the ConnectorManager class, in the client code. If only we could swap InvoiceConnector implementation. That is what interfaces are for. Let’s create InvoiceConnectorInterface.

// app/Contracts/InvoiceConnectorInterface.php
<?php

namespace App\Contracts;

interface InvoiceConnectorInterface
{
    public function submitInvoice($order);
}
// app/Connectors/ConnectorManager.php
<?php

namespace App\Connectors;

use App\Contracts\InvoiceConnectorInterface;

class ConnectorManager
{
    private $connector;

    public function __construct(InvoiceConnectorInterface $connector)
    {
        $this->connector = $connector;
    }

// rest of code...
// app/Connectors/InvoiceConnector.php
<?php

namespace App\Connectors;

use App\Contracts\InvoiceConnectorInterface;

class InvoiceConnector implements InvoiceConnectorInterface
{

// rest of code ...

We have to create a test double that we can use instead of InvoiceConnector when testing.

// tests/Doubles/InvoiceConnectorDouble.php
<?php

namespace Tests\Doubles;

use App\Contracts\InvoiceConnectorInterface;

class InvoiceConnectorDouble implements InvoiceConnectorInterface
{
    public function submitInvoice($order)
    {
        return (object) [
            'response' => (object) [
                'status' => 200, 
                'message' => 'OK',
                'uuid' => 'ABCD'
            ]
        ];
    }
}
// tests/Unit/ConnectorManagerTest.php
<?php

namespace Tests\Unit;

use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Order;
use App\Connectors\ConnectorManager;
use Tests\Doubles\InvoiceConnectorDouble;

class ConnectorManagerTest extends TestCase
{
    use RefreshDatabase;

    /** @test */
    public function it_creates_invoice_record()
    {
        $order = factory(Order::class)->create();

        $connector = new InvoiceConnectorDouble;

        $connectorManager = new ConnectorManager($connector);
        $connectorManager->createInvoiceRecord($order);
       
        $this->assertDatabaseHas('invoice_records', [
            'order_id' => $order->id,
            'uuid' => 'ABCD'
        ]);
    }
}

Now we run phpunit and our test is passing.

PHPUnit 7.4.3 by Sebastian Bergmann and contributors.

.                                                                   1 / 1 (100%)

Time: 366 ms, Memory: 18.00MB

OK (1 test, 1 assertion)

Great. Now let’s write a test for a case when an error response is returned from the remote server. Does it mean we have to create another test double? What if we have five or six cases? Do we have to create a different test double for each that will fake the response we need? Not really. Let’s mock our test double using PHP Mockery framework that comes with Laravel. Since Laravel uses Mockery internally for facades, Laravel already has integrated Mockery with phpunit. If you would like to know how to integrate Mockery with phpunit you can read about it here.

// tests/Unit/ConnectoManagerTest.php
<?php

namespace Tests\Unit;

use Mockery;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Order;
use App\Connectors\ConnectorManager;
use Tests\Doubles\InvoiceConnectorDouble;

class ConnectorManagerTest extends TestCase
{
    use RefreshDatabase;

    /** @test */
    public function it_creates_invoice_record_with_error()
    {
        $order = factory(Order::class)->create();

        $connector = Mockery::mock(InvoiceConnectorDouble::class);
        $connector->shouldReceive('submitInvoice')
            ->with($order)
            ->andReturn((object) [
                'response' => (object) [
                    'status' => 623,
                    'message' => 'Invoice invalid format'
                ]
            ]);

        
        $connectorManager = new ConnectorManager($connector);
        $connectorManager->createInvoiceRecord($order);
       
        $this->assertDatabaseHas('invoice_records', [
            'order_id' => $order->id,
            'uuid' => '',
            'error' => true,
            'error_message' => 'Invoice invalid format'
        ]);
    }
}

The code above creates a mock from InvoceConnectorDouble and sets expectations for submitInvoice method. Then we are asserting that we are expecting to see an error recorded int the database. When running the test we will get the following:

There was 1 failure:

1) Tests\Unit\ConnectorManagerTest::it_creates_invoice_record_with_error
Failed asserting that a row in the table [invoice_records] matches the attributes {
    "order_id": 1,
    "uuid": "",
    "error": true,
    "error_message": "Invoice invalid format"
}.

The table is empty.

C:\LaravelProjects\laravel57\vendor\laravel\framework\src\Illuminate\Foundation\Testing\Concerns\InteractsWithDatabase.php:23
C:\LaravelProjects\laravel57\tests\Unit\ConnectorManagerTest.php:55

FAILURES!
Tests: 1, Assertions: 2, Failures: 1.

This is expected because we do not have code that will process error response. Let’s write it.

// app/Connectors/ConnectorManager.php

// previous code ....

    public function createInvoiceRecord($order)
    {
        $result = $this->connector->submitInvoice($order);

        if ($result->response->status == 200) {
            $order->invoiceRecord()->create([
                'uuid' => $result->response->uuid
            ]);
        } else {
            $order->invoiceRecord()->create([
               'error' => true,
               'error_message' => 'Invoice invalid format' 
            ]);
        }
    }

Now our test is passing.

PHPUnit 7.4.3 by Sebastian Bergmann and contributors.

.                                                                   1 / 1 (100%)

Time: 4.67 seconds, Memory: 18.00MB

OK (1 test, 2 assertions)

There are two more test cases I can think of that we need to cover. First is when a SoapFault is thrown. The second one is when result returned from InvoiceConnectorManager object wont have “response” property, so $result->response will blow up. I won’t go into details about testing these cases and just show you my test and connector manger classes below because the idea is the same.

// tests/Unit/ConnectorManagerTest.php
<?php

namespace Tests\Unit;

use Mockery;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Order;
use App\Connectors\ConnectorManager;
use Tests\Doubles\InvoiceConnectorDouble;

class ConnectorManagerTest extends TestCase
{
    use RefreshDatabase;

    public function setUp()
    {
        parent::setUp();
        $this->order = factory(Order::class)->create();
        $this->connector = Mockery::mock(InvoiceConnectorDouble::class);
    }

    private function createInvoiceRecord()
    {
        $connectorManager = new ConnectorManager($this->connector);
        $connectorManager->createInvoiceRecord($this->order);
    }

    /** @test */
    public function it_creates_invoice_record()
    {
        $this->connector->shouldReceive('submitInvoice')
            ->with($this->order)
            ->andReturn((object) [
                'response' => (object) [
                    'status' => 200,
                    'message' => 'OK',
                    'uuid' => 'ABCD'
                ]
            ]);

        $this->createInvoiceRecord();
       
        $this->assertDatabaseHas('invoice_records', [
            'order_id' => $this->order->id,
            'uuid' => 'ABCD'
        ]);
    }

    /** @test */
    public function it_creates_invoice_record_with_error()
    {
        $this->connector->shouldReceive('submitInvoice')
            ->with($this->order)
            ->andReturn((object) [
                'response' => (object) [
                    'status' => 623,
                    'message' => 'Invoice invalid format'
                ]
            ]);

        $this->createInvoiceRecord();
       
        $this->assertDatabaseHas('invoice_records', [
            'order_id' => $this->order->id,
            'uuid' => '',
            'error' => true,
            'error_message' => 'Invoice invalid format'
        ]);
    }

    /** @test */
    public function it_throws_soap_fault()
    {
        $this->connector->shouldReceive('submitInvoice')
            ->with($this->order)
            ->andThrow(new \SoapFault('test', 'There was an error connecting'));

        $this->createInvoiceRecord();

        $this->assertDatabaseHas('invoice_records', [
            'order_id' => $this->order->id,
            'uuid' => '',
            'error' => true,
            'error_message' => 'There was an error connecting'
        ]);
    }

    /** @test */
    public function it_records_error_if_response_property_does_not_exist()
    {
        $this->connector->shouldReceive('submitInvoice')
            ->with($this->order)
            ->andReturn((object) []);

        $this->createInvoiceRecord();
       
        $this->assertDatabaseHas('invoice_records', [
            'order_id' => $this->order->id,
            'uuid' => '',
            'error' => true,
            'error_message' => 'Response is malformed'
        ]);
    }

}
// app/Connectors/ConnectorManager.php
<?php

namespace App\Connectors;

use App\Contracts\InvoiceConnectorInterface;

class ConnectorManager
{
    private $connector;

    public function __construct(InvoiceConnectorInterface $connector)
    {
        $this->connector = $connector;
    }

    public function createInvoiceRecord($order)
    {
        try {
            $result = $this->connector->submitInvoice($order);
        } catch (\Throwable $e) {
            $order->invoiceRecord()->create([
                'error' => true,
                'error_message' => $e->getMessage()
            ]);
            return;
        }

        if (!$result || empty($result->response)) { 
            $order->invoiceRecord()->create([
                'error' => true,
                'error_message' => 'Response is malformed'
            ]);

            return;
        }

        $result = $result->response;
        
        if ($result->status == 200) {
            $order->invoiceRecord()->create([
                'uuid' => $result->uuid
            ]);
        } else {
            $order->invoiceRecord()->create([
                'error' => true,
                'error_message' => $result->message
            ]);
        }

        
    }
}

As you may have noticed we wrote tests first before we wrote our code. In a way, tests drove our design. We used dependency injection and isolated InvoiceConnector with an interface. We did that not because dependency injection and inversion of control are the right things to do. We did that because we wanted to make our code testable.

Facades Vs. Dependency Injection

Now let’s take a look at “Facades Vs. Dependency Injection”. Since Laravel has concept of facades, we could have used real time facade instead of dependency injection. Our code would look something like this:

// in app/Connectors/ConnectorManager.php

use Facades\App\Connectors\InvoiceConnector;
// some code ...
public function createInvoiceRecord($order)
{
        try {
            InvoiceConnector::submitInvoice($order);
        } catch (\Throwable $e) {
            $order->invoiceRecord()->create([
                'error' => true,
                'error_message' => $e->getMessage()
            ]);
            return;
        }
// rest of the code ....

}

Then in hour test class we would have something like this:

// tests/Unit/ConnectorManagerTest.php

// some code ...
use Facades\App\Connectors\InvoiceConnector;
// some code ...

        InvoiceConnector::shouldReceive('submitInvoice')
            ->with($this->order)
            ->andReturn((object) [
                'response' => (object) [
                    'status' => 200,
                    'message' => 'OK',
                    'uuid' => 'ABCD'
                ]
            ]);
// rest of the code ...

I consider Laravel’s real-time facades to be useful for testing because you don’t have to think about how to better use dependency injection (should it be a constructor dependency injection, or method dependency injection). Laravel’s IoC container will resolve the class behind the scenes and hand to the method. When testing, it uses Mockery under the hood and gives you a mock instance of your class. Unfortunately, when IoC resolves a mock it first instantiates the class, and then gets the name of the class to create a mock. If class is instantiated before it gets mocked the constructor method gets triggered. In our case this will cause an issue, because we are instantiating SoapClient in the constructor and using fake WSDL. If we were to use a real WSDL we would have made an external call to check get the description. This is not desirable when running tests. If you run the tests on a computer without internet connection, the tests will blow up. That is why I prefer to use dependency injection to Laravel’s real-time facades.

Share this article

Posted

in

,

by

Tags: