How to Test Your PHP SendGrid Code
Time to read: 10 minutes
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.
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.
If you're using Microsoft Windows, use the following commands instead.
Now, add the required dependencies to the project by running the following commands.
The development dependencies are:
PHPUnit: PHP's veteran unit testing framework
And the non-dev dependencies are:
The SendGrid's PHP Helper Library: This simplifies interacting with SendGrid's APIs
laminas-diactoros: This provides several utility classes for simplifying sending responses
ph-7/just-http-status-codes: This provides constants for every HTTP status code. While the status codes are pretty well understood, human-readable names are often much more maintainable.
psr/http-server-handler: This provides interfaces required to have the code be PSR-15-compliant, such as if you used it with any PSR-15-compliant frameworks or middleware, such as Mezzio, Slim, and Guzzle-PSR7, as well as in Symfony and Laravel when using the PSR-7 Bridge. We won't be integrating it with a framework. The code will be compliant just so that it is more meaningful.
Following that, add the following configuration to composer.json:
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.
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.
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:
As we've not refactored SendEmailHandler
yet, you should see output similar to that below.
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.
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 directlyReplaces the try/catch block that printed out the response's status code and headers with a call to the
SendGrid
objectssend()
function
If you re-run the test again, you'll see that the test passes.


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:
406 Not Acceptable (because the request is missing an Accept header); and
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.
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 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 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 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:
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:
To this:
Then, add the following function at the bottom of the class; it simplifies extracting the error message from the response.
After that, add the following use statements to the top of the class:
If you run the tests again, you'll see the following output, showing that they succeeded.
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.
Then, add the following to the list of use
statements at the top of the file.
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 aTextResponse
objectThat
$response
's status code is an HTTP 400 Bad RequestThat 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 theTypeException
we've set an expectation for.
If you attempt to run the tests now, you'll see the following output:
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:
And, add the following use statement to the top of the class:
Now, run the tests again, where you'll following output:
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.
Related Posts
Related Resources
Twilio Docs
From APIs to SDKs to sample apps
API reference documentation, SDKs, helper libraries, quickstarts, and tutorials for your language and platform.
Resource Center
The latest ebooks, industry reports, and webinars
Learn from customer engagement experts to improve your own communication.
Ahoy
Twilio's developer community hub
Best practices, code samples, and inspiration to build communications and digital engagement experiences.