The following article is a short snippet from the "Building an API Integration Using Saloon" chapter of my book "Consuming APIs In Laravel".
The book teaches you how to write powerful and robust code to build API integrations in your Laravel apps. It's packed with over 440+ pages of actionable advice, real-life code examples, and tips and tricks.
In the book, I teach you how to write code that can help you sleep easy at night having confidence that your API integration works!
Mocking HTTP Responses with Saloon
So far, we've been writing tests that make assertions against the input and output of our service class. However, we still need to test what's actually happening inside the service class itself. This is where we can write tests that make assertions against the HTTP requests being made.
Thankfully, Saloon provides testing features that allow us to mock HTTP responses to make this process easier. Before we continue writing tests, let's look at how we can mock responses in Saloon.
At the beginning of our tests, we can use the Saloon::fake()
method to tell Saloon we should fake any HTTP requests. We can then pass an array of mocked responses (using the MockResponse::make()
method) that should be returned when a request is made. Let's look at an example:
1use Saloon\Http\Faking\MockResponse; 2use Saloon\Laravel\Facades\Saloon; 3 4Saloon::fake([ 5 MockResponse::make(['message' => 'Success'], 200), 6 MockResponse::make(['message' => 'Forbidden'], 403), 7 MockResponse::make(['message' => 'Error'], 500), 8]); 9 10// Make Saloon HTTP calls now...
In the code example, we've mocked three responses. This means the first HTTP request will return a 200
response with the body of {"message": "Success"}
. The second HTTP request will return a 403
response with the body of {"message": "Forbidden"}
. The third HTTP request will return a 500
response with the body of {"message": "Error"}
. This is useful for testing multiple HTTP calls in the same test.
This approach will return the mocked responses in the order they're defined. But there may be times when you want to return a specific response for a specific request. Let's look at an example:
1use App\Http\Integrations\GitHub\Requests\GetAuthUserRepos;2use App\Http\Integrations\GitHub\Requests\GetRepo;3use Saloon\Http\Faking\MockResponse;4use Saloon\Laravel\Facades\Saloon;5 6Saloon::fake([7 GetRepo::class => MockResponse::make(['single-repo-response-body-here']),8 GetAuthUserRepos::class => MockResponse::make(['multiple-repos-response-body-here']),9]);
This now means that any HTTP requests made to the GetRepo
request will return the body of "single-repo-response-body-here"
. Any HTTP requests made to the GetAuthUserRepos
request will return the body of "multiple-repos-response-body-here"
.
There may also be times when you want to return a response for a specific request URI. Let's look at an example:
1use Saloon\Http\Faking\MockResponse;2use Saloon\Laravel\Facades\Saloon;3 4Saloon::fake([5 'github.com/repos/laravel/framework' => MockResponse::make(['laravel-framework-repo-data-here']),6 'github.com/repos/*' => MockResponse::make(['dummy-repo-data-here']),7 '*' => MockResponse::make(['catch-all-data-here']),8]);
In the code example, we state that any requests from Saloon made directly to github.com/repos/laravel/framework
should return the body of ['laravel-framework-repo-data-here']
.
We then use wildcards in the other request URIs so we don't need to write the exact matches. For example, github.com/repos/*
would match request URIs such as github.com/repos/ash-jc-allen/short-url
and github.com/repos/saloonphp/saloon
. Using wildcards is handy if your tests don't require being specific about the request URIs.
Finally, we're using a catch-all wildcard (*
on its own) to match any request URIs that haven't been matched by the previous two.
Now that we know how to mock some of the responses for Saloon requests, let's write a test that uses this.
We'll start by writing a test for the createRepo
method in the App\Services\GitHub\GitHubService
class. Let's remind ourselves of the method:
1namespace App\Services\GitHub; 2 3use App\DataTransferObjects\GitHub\NewRepoData; 4use App\DataTransferObjects\GitHub\Repo; 5use App\Http\Integrations\GitHub\Requests\CreateRepo; 6use App\Interfaces\GitHub; 7 8final readonly class GitHubService implements GitHub 9{10 // ...11 12 public function createRepo(NewRepoData $repoData): Repo13 {14 return $this->connector()15 ->send(new CreateRepo($repoData))16 ->dtoOrFail();17 }18}
The createRepo
method expects a NewRepoData
object to be passed in. This object contains the data that should be used to create the repo. The method then uses the GitHubConnector
(returned via the connector
method) to send a CreateRepo
request to GitHub. This request is then converted into a Repo
data transfer object and returned. If the request fails, an instance of App\Exceptions\Integrations\GitHub\GitHubException
is thrown.
Let's write a test for a successful request first and then discuss what's happening:
1namespace Tests\Feature\Services\GitHub\GitHubService; 2 3use App\DataTransferObjects\GitHub\NewRepoData; 4use App\Http\Integrations\GitHub\Requests\CreateRepo; 5use App\Services\GitHub\GitHubService; 6use Saloon\Enums\Method; 7use Saloon\Http\Faking\MockResponse; 8use Saloon\Laravel\Facades\Saloon; 9use Tests\TestCase;10 11final class CreateRepoTest extends TestCase12{13 #[Test]14 public function repo_can_be_created_in_github(): void15 {16 // Fake a response for the request to create a repo.17 Saloon::fake([18 'user/repos' => MockResponse::make([19 'id' => 123456789,20 'name' => 'repo-name-here',21 'owner' => [22 'login' => 'owner-name-here',23 ],24 'full_name' => 'owner-name-here/repo-name-here',25 'description' => 'description goes here',26 'private' => true,27 'created_at' => '2022-05-18T18:00:07Z'28 ]),29 ]);30 31 // Attempt to create a new repo.32 $repoData = new NewRepoData(33 name: 'repo-name-here',34 description: 'description goes here',35 isPrivate: true,36 );37 38 $repo = (new GitHubService('token'))->createRepo($repoData);39 40 // Assert the correct repo data was returned.41 $this->assertEquals(123456789, $repo->id);42 $this->assertEquals('owner-name-here', $repo->owner);43 $this->assertEquals('repo-name-here', $repo->name);44 $this->assertEquals('owner-name-here/repo-name-here', $repo->fullName);45 $this->assertTrue($repo->private);46 $this->assertEquals('description goes here', $repo->description);47 $this->assertEquals(48 '2022-05-18T18:00:07'49 $repo->createdAt->toDateTimeLocalString()50 );51 52 // Assert the correct request was sent to the GitHub API.53 Saloon::assertSent(static fn(CreateRepo $request): bool =>54 $request->resolveEndpoint() === '/user/repos'55 && $request->method() === Method::POST56 && $request->body()->all() === [57 'name' => 'repo-name-here',58 'description' => 'description goes here',59 'private' => true,60 ]61 );62 }
At the beginning of the test, we instruct Saloon that if a request is made to github.com/user/repos
, we should return the specified body as JSON. Note that in this test, we only specified the fields in the response we will use to build the Repo
DTO. This is purely for brevity in this book. If possible, it is ideal to mock the full response body in your tests. Not only will this make your mock response more realistic, but it will also help in the future if you need to add more fields to the DTO and have to update your tests.
After this, we built our NewRepoData
DTO and passed it to the createRepo
method in our GitHubService
class. Following this, we've made assertions to check that the properties in the DTO are correct, which ensures the DTO is built correctly from the response body.
Finally, we've asserted that the correct request was sent to the GitHub API. We've done this using the Saloon::assertSent
method. This method can accept a closure as its argument. This closure is passed the request that was sent to the API. In the closure, we assert that the request's endpoint is /user/repos
, the method is POST
, and the body contains the correct data.
We can also write tests to simulate our request failing. Let's write a test to simulate the user not being able to create a repository because one already exists with the same name:
1namespace Tests\Feature\Services\GitHub\GitHubService; 2 3use App\DataTransferObjects\GitHub\NewRepoData; 4use App\Exceptions\Integrations\GitHub\GitHubException; 5use App\Http\Integrations\GitHub\Requests\CreateRepo; 6use App\Services\GitHub\GitHubService; 7use Saloon\Http\Faking\MockResponse; 8use Saloon\Laravel\Facades\Saloon; 9use Tests\TestCase;10 11final class CreateRepoTest extends TestCase12{13 #[Test]14 public function exception_is_thrown_if_the_user_cannot_create_the_repo(): void15 {16 $this->expectException(GitHubException::class);17 18 // Fake an error response for the request to create a repo.19 Saloon::fake([20 'user/repos' => MockResponse::make([21 'message' => 'Repository creation failed.',22 'errors' => [23 [24 'resource' => 'Repository',25 'code' => 'custom',26 'field' => 'name',27 'message' => 'name already exists on this account',28 ],29 ],30 ], 422),31 ]);32 33 // Attempt to create a new repo.34 $repoData = new NewRepoData(35 name: 'ashallendesign',36 description: 'description goes here',37 isPrivate: true,38 );39 40 (new GitHubService(config('services.github.token')))->createRepo($repoData);41 }42}
In this test, we prepare our test using the expectException
method. This instructs PHPUnit to expect a GitHubException
to be thrown in the test at some point. If the exception is thrown, the assertion will pass. But if one isn't thrown, the test will fail.
We then mocked the response we would receive from the GitHub API if a repository already existed with the same name.
After that, we called the createRepo
method with a NewRepoData
object passed to it.
You may have noticed we're not inspecting the request body in this test. That's because we can use the assertions from our previous test to assume that the request body is correct. We're only interested in testing the error handling in this test.
Recording HTTP Responses in Saloon
So far, we've manually written out the responses we want to return from our fake requests. This is perfectly fine for simple responses, and it's probably the easiest approach. However, manually writing mock response can have several issues:
- Time-consuming - Writing out the response body for each request takes time, especially if the response body is large.
- Higher likelihood of errors - You may make mistakes when writing the response body, like typos, missing fields/headers, or incorrect HTTP status codes. This could lead to tests failing and spending time debugging the issue.
To help, Saloon provides a way to record the responses of actual API requests so that they can be replayed in your tests. This means you don't have to manually write out the response body for each request.
Let's look at how recording responses work in Saloon and how to use them in your tests. To record a response, you can use the following in your code:
1use Saloon\Http\Faking\MockResponse;2use Saloon\Laravel\Facades\Saloon;3 4Saloon::fake([5 MockResponse::fixture('GitHub/Repo/laravel/framework'),6]);
As you can see, it looks very similar to how we mock the responses, as we're using Saloon::fake()
. However, instead of using MockResponse::make()
, we're using MockResponse::fixture()
. In this fixture we've passed the path where we want to store the request's response and read it from. By default, the fixtures are stored in your project's tests/Fixtures
directory. So our above response (which we assume is returned from the GitHub API with data about the laravel/framework
repository) would be stored in tests/Fixtures/GitHub/Repo/laravel/framework.json
. The JSON file stores the response body, headers, and status code.
When you run your tests, if you attempt to send a request, Saloon first checks to see whether the fixture exists. If it does, it won't make a request to the API. Instead, it'll return the response from the fixture. If the fixture doesn't exist, it'll make a request to the API and store the response in the fixture.
This means that to use fixtures, you must have made a request to the API at least once. Where you choose to do this is up to you. There are two approaches that I typically use for recording responses:
- If the API is publicly available and doesn't require authentication, I'll allow the test to make the request to the API the very first time I run it.
- If the API requires authentication or any complex data to send the request and get a realistic response, I'll temporarily add the
Saloon::fake()
call to my application code and make the request outside of the test (e.g., acting as a user in the web browser). This records the response for me to use in my tests. I can then delete theSaloon::fake()
call from my application code. It's extremely important that you remember to delete theSaloon::fake()
call from your application code if you choose this approach. It will lead to your application code in production using the fake responses rather than making actual requests to the API. We definitely don't want this to happen!
Consider creating a fixture class that can be used to redact sensitive data from the response. For example, if you record your response from your application code outside a test, your response may include some personal data. To do this, you can create a class that extends the Saloon\Http\Faking\Fixture
class. Let's create a basic example class that redacts the 'name' from the laravel/framework
repository API response to show you how this works:
1namespace Tests\Fixtures\Saloon\GitHub\Repos\Laravel\Framework; 2 3use Saloon\Http\Faking\Fixture; 4 5class RepoFixture extends Fixture 6{ 7 protected function defineName(): string 8 { 9 return 'GitHub/Repos/Laravel/Framework/repo';10 }11 12 protected function defineSensitiveJsonParameters(): array13 {14 return [15 'name' => 'REDACTED',16 ];17 }18}
Using the defineName
method, we specified where the response should be stored after it's been fetched. In this case, the file will be stored at tests/Fixtures/GitHub/Repos/Laravel/Framework/repo.json
. We've then used the defineSensitiveJsonParameters
method to specify that we want to redact the 'name' field from the response. This means that when the response is stored in the fixture, the 'name' field will be replaced with 'REDACTED'.
You can also add defineSensitiveHeaders
and defineSensitiveRegexPatterns
methods to your fixture class. These can redact sensitive headers or use regular expressions to redact sensitive data from the response body.
Once you have your fixture class, you can register it as a fake response like so:
1use Saloon\Http\Faking\MockResponse;2use Saloon\Laravel\Facades\Saloon;3use Tests\Fixtures\Saloon\GitHub\Repos\Laravel\Framework\RepoFixture;4 5Saloon::fake([6 new RepoFixture(),7]);
Now that we know how to configure fixtures for recording responses, let's see how to use them in our tests. Imagine we're writing a test for the getRepos
method in our GitHub
class that uses paginated responses to get the repositories for a user. It may look something like this:
1namespace Tests\Feature\Services\GitHub\GitHubService; 2 3use App\DataTransferObjects\GitHub\Repo; 4use App\Http\Integrations\GitHub\Requests\GetAuthUserRepos; 5use App\Services\GitHub\GitHubService; 6use PHPUnit\Framework\Attributes\Test; 7use Saloon\Http\Faking\MockResponse; 8use Saloon\Laravel\Facades\Saloon; 9use Tests\TestCase;10 11final class GetReposTest extends TestCase12{13 #[Test]14 public function repos_can_be_returned_from_paginated_response(): void15 {16 // Mock the responses for each page.17 Saloon::fake([18 MockResponse::fixture('GitHub/Repos/All/Page1'),19 MockResponse::fixture('GitHub/Repos/All/Page2'),20 MockResponse::fixture('GitHub/Repos/All/Page3'),21 MockResponse::fixture('GitHub/Repos/All/Page4'),22 ]);23 24 $gitHubService = new GitHubService(config('services.github.token'));25 26 $repos = $gitHubService->getRepos();27 28 // Assert the correct number of repos were returned.29 $this->assertCount(94, $repos);30 $repos->ensure(Repo::class);31 32 // Assert that all 4 requests were made with the correct query parameters.33 foreach ([1,2,3,4] as $pageNumber) {34 Saloon::assertSent(static fn(GetAuthUserRepos $request): bool =>35 $request->query()->all() === [36 'per_page' => 30,37 'page' => $pageNumber,38 ]39 );40 }41 }42}
In the test, we start by faking the responses for the calls to be made to the API. Four requests will be made to the API, so we've registered four separate fixtures (one for each page). We've then created a new instance of our GitHubService
class and called the getRepos
method.
We've then asserted that the intended number of repositories have been returned and are all instances of our Repo
data transfer object.
Finally, we've used a foreach
loop to assert that all requests were sent with the correct query parameters to get the expected pages. This allows us to have more confidence that we're parsing the paginated responses correctly to determine the number of pages that need to be fetched.
Want to Continue Reading?
Hopefully, this snippet from the book has shown you how you can mock and record API responses for testing using Saloon.
If you've enjoyed it, you might be interested in reading the rest of the book which goes a lot more in depth about APIs and Saloon.
The book teaches you how to write powerful and robust code to build API integrations in your Laravel apps. It's packed with over 440+ pages of actionable advice, real-life code examples, and tips and tricks.
In the book, I teach you how to write code that can help you sleep easy at night having confidence that your API integration works!
๐ Make sure to use the discount code ASHALLEN20 to get 20% off!