How to Test Your PHP SendGrid Code

April 03, 2025
Written by
Reviewed by

It's pretty easy to send emails with SendGrid in PHP using SendGrid's PHP Helper Library — especially with the guides in the docs and the tutorials on the blog!

You don't need to craft your own network calls, so you can be up and running, integrating with SendGrid in less than a few minutes (well, perhaps a little longer).

However, as easy as it is to start adding SendGrid support into your application, do you know how to add test coverage for the code you write?

If you don't, are not sure how, or would just like to learn, this tutorial is for you. In it, you're going to learn the essentials of adding test coverage using PHPUnit. You'll learn how to mock the core objects, handle errors, and quite a bit more.

It won't be exhaustive, but will give you a good start — along with a host of links to great resources — from which you can continue to learn.

Let's begin!

Prerequisites

To follow along with the application, you're going to need the following:

  • PHP 8.3
  • Composer installed globally
  • Prior experience with unit testing, and ideally some experience with PHPUnit
  • You don't need a SendGrid account. However, if you don't have one already, create one so that you can play with all of the available functionality.

It's really easy to get started with SendGrid

Below, you can see a slightly modified version of the example for sending an email in the Email API Quickstart for PHP.

<?php

declare(strict_types=1);

require 'vendor/autoload.php';

use \SendGrid\Mail\Mail;

$email = new Mail();
$email->setFrom('sender@example.com', 'Example Sender');
$email->setSubject('Sending with Twilio SendGrid is Fun');
$email->addTo('recipient@example.com', 'Example Recipient');
$email->addContent('text/html', '<strong>and fast with the PHP helper library.</strong>');
$sendgrid = new \SendGrid(getenv('SENDGRID_API_KEY'));

try {
    $response = $sendgrid->send($email);
    printf("Response status: %d\n\n", $response->statusCode());
    $headers = array_filter($response->headers());
    echo "Response Headers\n\n";
    foreach ($headers as $header) {
        echo '- ' . $header . "\n";
    }
} catch (Exception $e) {
    echo 'Caught exception: '. $e->getMessage() ."\n";
}

The code instantiates a Mail object, which models an email, setting the recipient, sender, subject, and content. It then initialises a SendGrid object, which provides the transport layer to send the email, through SendGrid's API.

Wrapped in a try/catch block, it then attempts to send the email. If the email was sent successfully, the status code of the response is printed to the terminal along with the response headers. If there was an exception sending the email, the reason why's printed to the terminal.

The code is pretty straight-forward, abstracting away so much of the work required to interact with SendGrid's APIs. However, the example code is just that, example code. It's not how it would be written in a normal PHP web app or API. To be fair, though, it's not meant to be. The intention is to get you up and running quickly, giving you a taste for what you can do.

However, let's assume that you've had a bit of a play with it, that you really enjoyed the experience, so now want to start integrating SendGrid into an existing PHP-based web application. Let's refactor the code by adding tests to ensure that it works as expected.

Set up a small project

Before we can do that, let's set up a small PHP project to fill in for our real world application. Create the project directory wherever you create your PHP code, and change into the new directory with the commands below.

mkdir -p sendgrid-php-testing/{src,test}/Handler
cd sendgrid-php-testing

If you're using Microsoft Windows, use the following commands instead.

mkdir sendgrid-php-testing/src/Handler
mkdir sendgrid-php-testing/test/Handler
cd sendgrid-php-testing

Now, add the required dependencies to the project by running the following commands.

composer require --dev phpunit/phpunit
composer require laminas/laminas-diactoros \
    ph-7/just-http-status-codes \
    psr/http-server-handler \
    sendgrid/sendgrid

If you're on Windows, replace the backslash with a caret (^). I split the command to aid readability.

The development dependencies are: 

  • PHPUnit: PHP's veteran unit testing framework

And the non-dev dependencies are:

Following that, add the following configuration to composer.json:

"autoload": {
    "psr-4": {
        "App\\": "src"
    }
},
"autoload-dev": {
    "psr-4": {
        "AppTest\\": "src"
    }
},
"scripts": {
    "test": "phpunit --colors=always test"
}

These three items add an autoload mapping and development autoload mapping for Composer's auto-generated autoloader, mapping the src directory to the App namespace and the test directory to the AppTest namespace. Then, it adds a custom script named "test" that will run PHPUnit, with coloured output enabled for better readability.

Now, create a new file in src/Handler named SendEmailHandler.php. Then, paste the code below into the new file.

<?php

declare(strict_types=1);

namespace App\Handler;

use Exception;
use Laminas\Diactoros\Response\EmptyResponse;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use SendGrid;
use SendGrid\Mail\Mail;

use function array_filter;
use function printf;

class SendEmailHandler implements RequestHandlerInterface
{
    public const array REQUIRED_POST_PARAMS = [
        'content_html',
        'from_address',
        'from_name',
        'subject',
        'to_address',
        'to_name',
    ];

    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        $parsedBody = $request->getParsedBody();
        $keys = array_intersect(self::REQUIRED_POST_PARAMS, array_keys($parsedBody));
        if (count($keys) !== count(self::REQUIRED_POST_PARAMS)) {
            $missingPostElements = array_values(
                array_diff(
                    array_values(self::REQUIRED_POST_PARAMS),
                    array_keys($parsedBody)
                )
            );
            sort($missingPostElements, SORT_STRING);

            return new JsonResponse(
                [
                    "Error" => "Missing configuration items.",
                    "Missing configuration items." => array_diff(
                        self::REQUIRED_POST_PARAMS,
                        $missingPostElements
                    )
                ]
            );
        }
        $this->sendEmail($parsedBody);

        return new EmptyResponse();
    }

    private function sendEmail(array $config): void
    {
        $email = new Mail();

        $email->setFrom($config['from_address'], $config['from_name']);
        $email->addTo($config['to_address'], $config['to_name']);
        $email->setSubject($config['subject']);
        $email->addContent('text/html', $config['content_html']);

        $sendgrid = new SendGrid($_ENV['SENDGRID_API_KEY']);
        try {
            $response = $sendgrid->send($email);
            printf("Response status: %d\n\n", $response->statusCode());
            $headers = array_filter($response->headers());
            echo "Response Headers\n\n";
            foreach ($headers as $header) {
                echo '- ' . $header . "\n";
            }
        } catch (Exception $e) {
            echo 'Caught exception: ' . $e->getMessage() . "\n";
        }
    }
}

The code above has refactored the original SendGrid example into a standalone class. As mentioned earlier, it could be used with any PSR-15-compliant framework. If you're not familiar with PSR-15, in the context of the example above, the handle() function would be called to handle requests to one of the application's routes. 

The function takes a ServerRequestInterface object, which models the current request. From it, the POST data sent with the request is retrieved using the getParsedBody() function. The array should contain the following keys:

  • content_html: This contains the email's HTML body

  • from_address: This is the sender's email address

  • from_name: This is the sender's name

  • subject: This is the email's subject

  • to_address: This is the recipient's email address

  • to_name: This is the recipient's name

The retrieved POST data is then checked to see if one or more of the required elements is missing. If so, a JSON response is sent back to the client with the JsonResponse object, telling the client which ones were missing, along with an HTTP 400 Bad Request status code. If all of the POST parameters were sent, they're passed to the call to sendEmail().

This function initialises a Mail object, but this time with the provided array data. Otherwise, it's the same code as in the previous example.

The refactored code has several advantages over the original version:

  • It can provide different configuration data to the Mail object, configuration data which can be validated and filtered. This ensures that if malicious actors use tainted data, it will be blocked.

  • If one or more POST keys are missing, then the client will know why

  • The code can be used with a variety of frameworks and packages, increasing its utility value

Refactor 1: Remove direct object instantiation

However, the handle() function still directly instantiates the Mail and SendGrid objects. This limits the ability to test it, and requires a connection to SendGrid's API, which would increase test time while waiting for API responses. Neither of these are desirable. What's more, the code still prints out the response's status code and headers.

So, let's refactor the code to remove these issues. To do that, we'll start by writing our first test. In test/Handler create a new file named SendEmailHandlerTest.php.

<?php

declare(strict_types=1);

namespace AppTest\Handler;

use App\Handler\SendEmailHandler;
use Laminas\Diactoros\Response\EmptyResponse;
use PH7\JustHttp\StatusCode;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
use SendGrid;
use SendGrid\Mail\Mail;

class SendEmailHandlerTest extends TestCase
{
    public function testSendEmail(): void
    {
        $requestBody = [
            'from_address' => 'send.from@example.com',
            'from_name'    => 'Sender',
            'to_address'   => 'send.to@example.com',
            'to_name'      => 'Recipient',
            'subject'      => 'SendGrid Test Email',
            'content_html' => '<p>Test</p>',
        ];

        $mail = $this->createMock(Mail::class);
        $mail
            ->expects($this->once())
            ->method('setFrom')
            ->with($requestBody['from_address'], $requestBody['from_name']);
        $mail
            ->expects($this->once())
            ->method('addTo')
            ->with($requestBody['to_address'], $requestBody['to_name']);
        $mail
            ->expects($this->once())
            ->method('setSubject')
            ->with($requestBody['subject']);
        $mail
            ->expects($this->once())
            ->method('addContent')
            ->with('text/html', $requestBody['content_html']);

        $sendgrid = $this->createMock(SendGrid::class);
        $sendgrid
            ->expects($this->once())
            ->method('send')
            ->with($mail)
            ->willReturn(new SendGrid\Response(
                StatusCode::ACCEPTED,
                '',
                [
                    'Content-Type' => 'application/json',
                ]
            ));

        $handler = new SendEmailHandler($mail, $sendgrid);

        $request = $this->createMock(ServerRequestInterface::class);
        $request
            ->expects($this->once())
            ->method('getParsedBody')
            ->willReturn($requestBody);

        $response = $handler->handle($request);

        $this->assertInstanceOf(EmptyResponse::class, $response);
    }
}

The class contains one test, testSendEmail(), which tests that the class can send emails successfully. It mocks the Mail class, adding expectations that the class will have its setter methods called to set the email data with the faked request body stored in $requestBody.

Then, it mocks the SendGrid class, and adds an expectation that its send() function will be called with the mocked Mail object, and that the function will return a SendGrid\Response object ($response), which models an API response.

The status code is set to HTTP 202 Accepted. It will have an empty body, And, it will have a single header, "Content-Type" set to "application/json". These two mock objects are then used to instantiate a new SendEmailHandler object.

If you're not familiar with mocks, reworking PHPUnit's Mock Objects documentation just a little:

Mocks, or "mocking", is the practice of replacing an object with a test double that verifies expectations, for instance asserting that a method has been called.

And, paraphrasing PHPUnit's test doubles documentation, they're a class that: 

…merely has to provide the same API as the real one so that the SUT (System Under Test) thinks it is the real one. They don’t have to behave exactly like the real class.

Moving on. We then mock a ServerRequestInterface object, setting the expectation that its getParsedBody() function will be called, returning the fake POST data in $requestBody. Then, we call $handler's handle() function, store the response in $response, and assert that $response is an instance of EmptyResponse, which simplifies returning empty responses.

Let's now update Composer's auto-generated autoloader, so that SendEmailHandler will be autoloaded, and run the test, by running the commands below:

composer dump-autoload
composer test

As we've not refactored SendEmailHandler yet, you should see output similar to that below.

> phpunit --colors=always test
PHPUnit 12.0.10 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.3.17

Response status: 401

Response Headers

- HTTP/1.1 401 Unauthorized
- Server: nginx
- Date: Thu, 03 Apr 2025 00:56:28 GMT
- Content-Type: application/json
- Content-Length: 88
- Connection: keep-alive
- Access-Control-Allow-Origin: https://sendgrid.api-docs.io
- Access-Control-Allow-Methods: POST
- Access-Control-Allow-Headers: Authorization, Content-Type, On-behalf-of, x-sg-elas-acl
- Access-Control-Max-Age: 600
- X-No-CORS-Reason: https://sendgrid.com/docs/Classroom/Basics/API/cors.html
- Strict-Transport-Security: max-age=31536000; includeSubDomains
- Content-Security-Policy: frame-ancestors 'none'
- Cache-Control: no-cache
- X-Content-Type-Options: no-sniff
- Referrer-Policy: strict-origin-when-cross-origin
F                                                                   1 / 1 (100%)

Time: 00:00.878, Memory: 10.00 MB

There was 1 failure:

1) AppTest\Handler\SendEmailHandlerTest::testSendEmail
Expectation failed for method name is "setFrom" when invoked 1 time.
Method was expected to be called 1 time, actually called 0 times.

FAILURES!
Tests: 1, Assertions: 2, Failures: 1, Warnings: 1.
Script phpunit --colors=always test handling the test event returned with error code 1

Looking closely at the output, you can see that the test fails as setFrom() is not called. You'll also see that the output is very verbose because of the try/catch block in sendEmail(). This is expected.

Let's start to correct that by updating SendEmailHandler. Update src/Handler/SendEmailHandler.php to match the code below.

<?php

declare(strict_types=1);

namespace App\Handler;

use Laminas\Diactoros\Response\EmptyResponse;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use SendGrid;
use SendGrid\Mail\Mail;

use function array_diff;
use function array_intersect;
use function array_keys;
use function count;

class SendEmailHandler implements RequestHandlerInterface
{
    public const array REQUIRED_POST_PARAMS = [
        'content_html',
        'from_address',
        'from_name',
        'subject',
        'to_address',
        'to_name',
    ];

    public function __construct(private Mail $mail, private SendGrid $sendGrid)
    {
    }

    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        $parsedBody = (array)$request->getParsedBody();
        $keys = array_intersect(self::REQUIRED_POST_PARAMS, array_keys($parsedBody));
        if (count($keys) !== count(self::REQUIRED_POST_PARAMS)) {
            return new JsonResponse(
                [
                    "Error" => "Missing configuration items.",
                    "Missing configuration items." => array_diff(
                        self::REQUIRED_POST_PARAMS,
                        array_keys($parsedBody)
                    ),
                ]
            );
        }

        $response = $this->sendEmail($parsedBody);
        return new EmptyResponse();
    }

    private function sendEmail(array $config = []): SendGrid\Response
    {
        $this->mail->setFrom($config['from_address'], $config['from_name']);
        $this->mail->addTo($config['to_address'], $config['to_name']);
        $this->mail->setSubject($config['subject']);
        $this->mail->addContent('text/html', $config['content_html']);

        return $this->sendGrid->send($this->mail);
    }
}

The revised code adds a class constructor that takes a Mail and SendGrid object, allowing these objects to be mocked. Then, it:

  • Refactors the sendEmail() function to use the new class properties instead of instantiating the two objects directly

  • Replaces the try/catch block that printed out the response's status code and headers with a call to the SendGrid objects send() function

If you re-run the test again, you'll see that the test passes.

Screenshot of Terminal on macOS showing the output of running PHP tests with PHPUnit

Refactor 2: Handle response errors gracefully

Now that we've refactored the code to supply the Mail and SendGrid objects to the class' constructor and initialise the Mail object with POST data, let's refactor it to handle errors returned from requests to SendGrid's API. 

For context, SendGrid sends back seven status codes when requests are unsuccessful, including:

We're going to refactor the handle() function to send back a text response, telling the client what happened, if any of these are encountered. To do that, first update test/Handler/SendEmailHandlerTest.php to match the code below.

<?php

declare(strict_types=1);

namespace AppTest\Handler;

use App\Handler\SendEmailHandler;
use Laminas\Diactoros\Response\EmptyResponse;
use Laminas\Diactoros\Response\TextResponse;
use PH7\JustHttp\StatusCode;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
use SendGrid;
use SendGrid\Mail\Mail;

use function json_encode;

class SendEmailHandlerTest extends TestCase
{
    private array $requestBody = [];
    private Mail&MockObject $mail;
    private SendGrid&MockObject $sendgrid;

    public function setUp(): void
    {
        $this->requestBody = [
            'from_address' => 'send.from@example.com',
            'from_name' => 'Sender',
            'to_address' => 'send.to@example.com',
            'to_name' => 'Recipient',
            'subject' => 'SendGrid Test Email',
            'content_html' => '<p>Test</p>',
        ];
        $this->mail = $this->createMock(Mail::class);
        $this->sendgrid = $this->createMock(SendGrid::class);   
    }

    public function setServerRequest(array $requestBody)
        : ServerRequestInterface&MockObject
    {
        $request = $this->createMock(ServerRequestInterface::class);
        $request
            ->expects($this->once())
            ->method('getParsedBody')
            ->willReturn($requestBody);

        return $request;
    }

    public function testSendEmail(): void
    {
        $request = $this->setServerRequest($this->requestBody);

        $this->sendgrid
            ->expects($this->once())
            ->method('send')
            ->with($this->mail)
            ->willReturn(new SendGrid\Response(
                statusCode: StatusCode::ACCEPTED,
                headers: ['Content-Type' => 'application/json']
            ));

        $this->mail
            ->expects($this->once())
            ->method('setFrom')
            ->with($this->requestBody['from_address'], $this->requestBody['from_name']);
        $this->mail
            ->expects($this->once())
            ->method('addTo')
            ->with($this->requestBody['to_address'], $this->requestBody['to_name']);
        $this->mail
            ->expects($this->once())
            ->method('setSubject')
            ->with($this->requestBody['subject']);
        $this->mail
            ->expects($this->once())
            ->method('addContent')
            ->with('text/html', $this->requestBody['content_html']);

        $handler = new SendEmailHandler($this->mail, $this->sendgrid);
        $response = $handler->handle($request);

        $this->assertInstanceOf(EmptyResponse::class, $response);
    }

    #[TestWith([StatusCode::BAD_REQUEST, "invalid request"])]
    #[TestWith([StatusCode::FORBIDDEN, "access forbidden"])]
    #[TestWith([StatusCode::INTERNAL_SERVER_ERROR, "internal server error"])]
    #[TestWith([StatusCode::METHOD_NOT_ALLOWED, "method not allowed"])]
    #[TestWith([StatusCode::NOT_FOUND, "not found"])]
    #[TestWith([StatusCode::PAYLOAD_TOO_LARGE, "content too large"])]
    #[TestWith([StatusCode::UNAUTHORIZED, "authorization required"])]
    public function testCanHandleErrors(int $statusCode, string $responseMessage): void
    {
        $this->sendgrid
            ->expects($this->once())
            ->method('send')
            ->with($this->mail)
            ->willReturn(new SendGrid\Response(
                $statusCode,
                json_encode([
                    'errors' => [
                        "message" => $responseMessage,
                        "field" => "null",
                    ],
                ]),
                [
                    'Content-Type' => 'application/json',
                ]
            ));

        $request = $this->setServerRequest($this->requestBody);

        $this->mail
            ->expects($this->once())
            ->method('setFrom')
            ->with($this->requestBody['from_address'], $this->requestBody['from_name']);
        $this->mail
            ->expects($this->once())
            ->method('addTo')
            ->with($this->requestBody['to_address'], $this->requestBody['to_name']);
        $this->mail
            ->expects($this->once())
            ->method('setSubject')
            ->with($this->requestBody['subject']);
        $this->mail
            ->expects($this->once())
            ->method('addContent')
            ->with('text/html', $this->requestBody['content_html']);

        $handler = new SendEmailHandler($this->mail, $this->sendgrid);
        $response = $handler->handle($request);

        $this->assertInstanceOf(TextResponse::class, $response);
        $this->assertSame($statusCode, $response->getStatusCode());
        $this->assertSame($responseMessage, (string)$response->getBody());
    }
}

It's a little bit of a change, but will hopefully make sense once we've stepped through it. It starts off by refactoring $requestBody, $request, $mail, and $sendgrid to be private class properties.

The new class properties are intersection types, so that it's easier to know why they call test and native methods; both for us and for your code editor/IDE.

The code then moves most of the original test's initialisation out of testSendEmail() to a new function named setUp. When defined, this function will be called by PHPUnit before it runs each test. 

The reason for doing this is that the testSendEmail() and testCanHandleErrors() both make use of the SendGrid and Mail mocks. So they're initialised in setUp() to avoid duplication. The function also defines a default request body containing all of the required POST parameters for a successful request.

After that, setServerRequest() is defined. This mocks a ServerRequestInterface object, adding an expectation that a call to its getParsedBody() function will return the array provided to the function, and returns the mocked object.

Then, the testCanHandleErrors() function is defined, which takes a status code and response message. The TestWith attributes above the function are PHPUnit data providers that tell PHPUnit to call the test once for each provider, passing the elements of the data provider's array as function parameters. For example, the first call to testCanHandleErrors() will be passed StatusCode::BAD_REQUEST as the status code and "invalid request" as the response message.

The seven test data providers match an error message that SendGrid might send back.

The test itself sets up an expectation for the SendGrid object ($this->sendgrid) to call its send() function. As before, it is expected to return a SendGrid\Response object. This time, however, it will have the status code set to $statusCode's value, and a JSON string as the response body, where $responseMessage will be the value of the $.errors.message element.

The composition of the JSON response body for each error is defined in the Responses section of the SendGrid Mail Send API Overview.

The test finishes up by calling the handle() method, and makes three assertions:

  • That the response is an instance of TextResponse; as the name implies, these model a response with a plain text body.

  • That the response has the same HTTP status code as $statusCode

  • That the response's body is the same as $responseMessage

If you run the tests now, you'll see output similar to the following:

> phpunit --colors=always test
PHPUnit 12.0.10 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.3.17

.FFFFFFF                                                            8 / 8 (100%)

Time: 00:00.037, Memory: 10.00 MB

There were 7 failures:

1) AppTest\Handler\SendEmailHandlerTest::testCanHandleErrors with data set #0 (400, 'invalid request')
Failed asserting that an instance of class Laminas\Diactoros\Response\EmptyResponse is an instance of class Laminas\Diactoros\Response\TextResponse.

/opt/php/SendGrid/sendgrid-php-testing/test/Handler/SendEmailHandlerTest.php:134

2) AppTest\Handler\SendEmailHandlerTest::testCanHandleErrors with data set #1 (403, 'access forbidden')
Failed asserting that an instance of class Laminas\Diactoros\Response\EmptyResponse is an instance of class Laminas\Diactoros\Response\TextResponse.

/opt/php/SendGrid/sendgrid-php-testing/test/Handler/SendEmailHandlerTest.php:134

3) AppTest\Handler\SendEmailHandlerTest::testCanHandleErrors with data set #2 (500, 'internal server error')
Failed asserting that an instance of class Laminas\Diactoros\Response\EmptyResponse is an instance of class Laminas\Diactoros\Response\TextResponse.

/opt/php/SendGrid/sendgrid-php-testing/test/Handler/SendEmailHandlerTest.php:134

4) AppTest\Handler\SendEmailHandlerTest::testCanHandleErrors with data set #3 (405, 'method not allowed')
Failed asserting that an instance of class Laminas\Diactoros\Response\EmptyResponse is an instance of class Laminas\Diactoros\Response\TextResponse.

/opt/php/SendGrid/sendgrid-php-testing/test/Handler/SendEmailHandlerTest.php:134

5) AppTest\Handler\SendEmailHandlerTest::testCanHandleErrors with data set #4 (404, 'not found')
Failed asserting that an instance of class Laminas\Diactoros\Response\EmptyResponse is an instance of class Laminas\Diactoros\Response\TextResponse.

/opt/php/SendGrid/sendgrid-php-testing/test/Handler/SendEmailHandlerTest.php:134

6) AppTest\Handler\SendEmailHandlerTest::testCanHandleErrors with data set #5 (413, 'content too large')
Failed asserting that an instance of class Laminas\Diactoros\Response\EmptyResponse is an instance of class Laminas\Diactoros\Response\TextResponse.

/opt/php/SendGrid/sendgrid-php-testing/test/Handler/SendEmailHandlerTest.php:134

7) AppTest\Handler\SendEmailHandlerTest::testCanHandleErrors with data set #6 (401, 'authorization required')
Failed asserting that an instance of class Laminas\Diactoros\Response\EmptyResponse is an instance of class Laminas\Diactoros\Response\TextResponse.

/opt/php/SendGrid/sendgrid-php-testing/test/Handler/SendEmailHandlerTest.php:134

FAILURES!
Tests: 8, Assertions: 75, Failures: 7.
Script phpunit --colors=always test handling the test event returned with error code 1

This is because handle() does not, yet, have code to handle errors and respond accordingly.

Let's now refactor the function to pass the test. To do that, change the end of handle() from the following:

$response = $this->sendEmail($parsedBody);
return new EmptyResponse();

To this:

$response = $this->sendEmail($parsedBody);
return match ($response->statusCode()) {
    StatusCode::ACCEPTED => new EmptyResponse(),
    StatusCode::BAD_REQUEST,
    StatusCode::FORBIDDEN,
    StatusCode::INTERNAL_SERVER_ERROR,
    StatusCode::METHOD_NOT_ALLOWED,
    StatusCode::NOT_FOUND,
    StatusCode::PAYLOAD_TOO_LARGE,
    StatusCode::UNAUTHORIZED => new TextResponse(
        $this->getErrorMessage($response),
        $response->statusCode()
    ),
    default => new TextResponse(
        "Unknown response", 
        StatusCode::INTERNAL_SERVER_ERROR
    ),
};

Then, add the following function at the bottom of the class; it simplifies extracting the error message from the response.

public function getErrorMessage(SendGrid\Response $response): string
{
    return json_decode(
        json: $response->body(),
        associative: true,
        flags: JSON_OBJECT_AS_ARRAY
    )['errors']['message'];
}

After that, add the following use statements to the top of the class:

use Laminas\Diactoros\Response\TextResponse;
use PH7\JustHttp\StatusCode;

If you run the tests again, you'll see the following output, showing that they succeeded.

> phpunit --colors=always test
PHPUnit 12.0.10 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.3.17

........                                                            8 / 8 (100%)

Time: 00:00.032, Memory: 10.00 MB

OK (8 tests, 110 assertions)

Refactor 3: Handle invalid request data

Let's add one final test. This one will ensure that TypeException's thrown by the Mail object, when setting its properties, can be handled. To do that, add the following test to the end of test/Handler/SendEmailHandlerTest.php.

public function testCanHandleSendGridExceptions(): void
{
    $message = 'Message';
    $this->mail = $this->createMock(Mail::class);
    $this->mail
        ->expects($this->once())
        ->method('setFrom')
        ->willThrowException(
            new TypeException($message)
        );
    $request = $this->setServerRequest($this->requestBody);
    $handler = new SendEmailHandler($this->mail, $this->createMock(SendGrid::class));
    $response = $handler->handle($request);

    $this->assertInstanceOf(TextResponse::class, $response);
    $this->assertSame(StatusCode::BAD_REQUEST, $response->getStatusCode());
    $this->assertSame($message, (string)$response->getBody());
}

Then, add the following to the list of use statements at the top of the file.

use SendGrid\Mail\TypeException;

This test sets the expectation that when $this->mail calls setFrom() to set the email's sender, a TypeException will be thrown. This is a custom exception in the SendGrid PHP Helper Library which is thrown when one of Mail's properties is invalid. Then, a new SendEmailHandler object is initialised similarly to before, its handle() function is called, storing the response in $response.

This method has three assertions:

  • As with the previous test, that $response is a TextResponse object

  • That $response's status code is an HTTP 400 Bad Request

  • That the response's body is the same as the string defined in $errorMessage.
    This string template is defined in Assert::email(), which is called if the calling code attempts to set either the sender or recipient's email to an invalid email address; resulting in the TypeException we've set an expectation for.

If you attempt to run the tests now, you'll see the following output:

> phpunit --colors=always test
PHPUnit 12.0.10 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.3.17

........E                                                           9 / 9 (100%)

Time: 00:00.045, Memory: 10.00 MB

There was 1 error:

1) AppTest\Handler\SendEmailHandlerTest::testCanHandleSendGridExceptions
SendGrid\Mail\TypeException: Message

/opt/php/SendGrid/sendgrid-php-testing/test/Handler/SendEmailHandlerTest.php:148

ERRORS!
Tests: 9, Assertions: 110, Errors: 1.
Script phpunit --colors=always test handling the test event returned with error code 2

You can see that the exception ends execution, as it's not handled. 

So, let's perform one final refactor to handle it. Update the call to $this->sendEmail() in src/Handler/SendEmailHandler.php's handle() function to the following:

try {
    $response = $this->sendEmail($parsedBody);
} catch (TypeException $e) {
    return new TextResponse($e->getMessage(), StatusCode::BAD_REQUEST);
}

And, add the following use statement to the top of the class:

use SendGrid\Mail\TypeException;

Now, run the tests again, where you'll following output:

> phpunit --colors=always test
PHPUnit 12.0.10 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.3.17

.........                                                           9 / 9 (100%)

Time: 00:00.024, Memory: 10.00 MB

OK (9 tests, 115 assertions)

That's how to start testing SendGrid emails sent with PHP

Now, you know how to add test coverage to the code you write for sending emails with SendGrid, using the PHP Helper Library. I hope that you progressively feel more confident about its quality, and your ability to add new features — and to refactor existing functionality.

Testing is an extensive topic, which this tutorial's only scratched the proverbial surface of. And PHPUnit has significant functionality. I strongly encourage you to explore them both, as your time permits. If you use a different PHP testing framework, try migrating the tests and seeing if it's easier or harder to write them. Or, add tests to ensure that a JsonResponse object is returned when one or more POST parameters are missing.

Otherwise, I can't wait to see what you build with SendGrid!

Matthew Setter is a PHP and Go Editor in the Twilio Voices team, and a PHP and Go developer. He’s also the author of Mezzio Essentials and Deploy with Docker Compose. You can find him at msetter[at]twilio.com. He's also on LinkedIn and GitHub.