Customizing Browser Globally
Keep on Learning!
If you liked what you've learned so far, dive in! Subscribe to get access to this tutorial plus video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeOur test works... but the API is sending us back JSON, not JSON-LD. Why?
When we made the GET request earlier, we did not include an Accept header to indicate which format we wanted back. But... JSON-LD is our API's default format, so it sent that back.
However, when we make a ->post() request with the json key, that adds a Content-Type header set to application/json - which is fine - but it also adds an Accept header set to application/json. Yup, we're telling the server that we want plain JSON back, not JSON-LD.
I want to use JSON-LD everywhere. How can we do that? The second argument to ->post() can be an array or an object called HttpOptions. Say HttpOptions::json()... and then pass the array directly. Let me... get my syntax right:
| // ... lines 1 - 7 | |
| use Zenstruck\Browser\HttpOptions; | |
| // ... lines 9 - 12 | |
| class DragonTreasureResourceTest extends KernelTestCase | |
| { | |
| // ... lines 15 - 42 | |
| public function testPostToCreateTreasure(): void | |
| { | |
| // ... lines 45 - 52 | |
| ->post('/api/treasures', HttpOptions::json([ | |
| 'name' => 'A shiny thing', | |
| 'description' => 'It sparkles when I wave it in the air.', | |
| 'value' => 1000, | |
| 'coolFactor' => 5, | |
| 'owner' => '/api/users/'.$user->getId(), | |
| ])) | |
| // ... lines 60 - 62 | |
| ; | |
| } | |
| } |
So far, this is equivalent to what we had before. But now we can change some options by saying ->withHeader() passing Accept and application/ld+json:
| // ... lines 1 - 12 | |
| class DragonTreasureResourceTest extends KernelTestCase | |
| { | |
| // ... lines 15 - 42 | |
| public function testPostToCreateTreasure(): void | |
| { | |
| // ... lines 45 - 52 | |
| ->post('/api/treasures', HttpOptions::json([ | |
| 'name' => 'A shiny thing', | |
| 'description' => 'It sparkles when I wave it in the air.', | |
| 'value' => 1000, | |
| 'coolFactor' => 5, | |
| 'owner' => '/api/users/'.$user->getId(), | |
| ])->withHeader('Accept', 'application/ld+json')) | |
| // ... lines 60 - 62 | |
| ; | |
| } | |
| } |
We could have also done this with the array of options: it has a key called headers. But the object is kind of nice.
Let's make sure this fixes things. Run the test:
symfony php bin/phpunit --filter=testPostToCreateTreasure
Globally Sending the Header
And... we're back to JSON-LD! It's got the right fields and the application/ld+json response Content-Type header.
So.... that's cool... but doing this every time we make a request to our API in the tests is... mega lame. We need this to happen automatically.
A nice way to do that is to leverage a base test class. Inside of tests/, actually inside of tests/Functional/, create a new PHP class called ApiTestCase. I'm going to make this abstract and extend KernelTestCase:
| // ... lines 1 - 2 | |
| namespace App\Tests\Functional; | |
| use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; | |
| // ... lines 6 - 9 | |
| abstract class ApiTestCase extends KernelTestCase | |
| { | |
| // ... lines 12 - 25 | |
| } |
Inside, add the HasBrowser trait. But we're going to do something sneaky: we're going to import the browser() method but call it baseKernelBrowser:
| // ... lines 1 - 7 | |
| use Zenstruck\Browser\Test\HasBrowser; | |
| abstract class ApiTestCase extends KernelTestCase | |
| { | |
| use HasBrowser { | |
| browser as baseKernelBrowser; | |
| } | |
| // ... lines 15 - 25 | |
| } |
Why the heck are we doing that? Re-implement the browser() method... then call $this->baseKernelBrowser() passing it $options and $server. But now call another method: ->setDefaultHttpOptions(). Pass this HttpOptions::create() then ->withHeader(), Accept, application/ld+json:
| // ... lines 1 - 5 | |
| use Zenstruck\Browser\HttpOptions; | |
| // ... lines 7 - 9 | |
| abstract class ApiTestCase extends KernelTestCase | |
| { | |
| // ... lines 12 - 15 | |
| protected function browser(array $options = [], array $server = []) | |
| { | |
| return $this->baseKernelBrowser($options, $server) | |
| ->setDefaultHttpOptions( | |
| HttpOptions::create() | |
| ->withHeader('Accept', 'application/ld+json') | |
| ) | |
| ; | |
| } | |
| } |
Done! Back in our real test class, extend ApiTestCase: get the one that's from our app:
| // ... lines 1 - 11 | |
| class DragonTreasureResourceTest extends ApiTestCase | |
| { | |
| // ... lines 14 - 63 | |
| } |
That's it! When we say $this->browser(), it now calls our browser() method, which changes that default option. Celebrate by removing withHeader()... and you could revert back to the array of options with a json key if you want.
Let's try it.
symfony php bin/phpunit --filter=testPostToCreateTreasure
And... uh oh. That's a strange error:
Cannot override final method
_resetBrowserClients()
This... is because we're importing the trait from the parent class and our class... which makes the trait go bananas. Remove the one inside our test class:
| // ... lines 1 - 8 | |
| use Zenstruck\Browser\Test\HasBrowser; | |
| // ... lines 10 - 11 | |
| class DragonTreasureResourceTest extends ApiTestCase | |
| { | |
| use HasBrowser; | |
| // ... lines 15 - 63 | |
| } |
we don't need it anymore. I'll also do a little cleanup on my use statements.
And now:
symfony php bin/phpunit --filter=testPostToCreateTreasure
Got it! We get back JSON-LD with zero extra work. Remove that dump():
| // ... lines 1 - 11 | |
| class DragonTreasureResourceTest extends ApiTestCase | |
| { | |
| // ... lines 14 - 41 | |
| public function testPostToCreateTreasure(): void | |
| { | |
| // ... lines 44 - 45 | |
| $this->browser() | |
| // ... lines 47 - 59 | |
| ->dump() | |
| // ... line 61 | |
| ; | |
| } | |
| } |
Next: let's write another test that uses our API token authentication.
13 Comments
I created an abstract
ApiTestCaseclass, but while running the tests, I got the following error:I fixed that. You should use the proper namespace in your test classes:
namespace App\Tests\Functional;Yep, otherwise the files can't be found. Cheers!
Hello and first of all thank you for the great course.
I currently have a question:
I wrote a unit test that checks the user login for a disabled user. In my CustomAuthenticator (i login against ldap and database) an AuthenticationException is thrown in the 'authenticate' method. This is converted into a 'JsonResponse' in 'onAuthenticationFailure' with the error message and http status code 401.
Although the Accept header in the test method is set to 'application/ld+json', the result is only 'application/json'.
How can I get Hydra when I return a JsonResponse with http status 4xx myself?
For successful API queries I correctly get 'application/ld+json'.
I would be very happy about your help.
Many greetings from Germany,
Thomas
Hey @Thomas-G
You can use examples from this page https://symfony.com/doc/current/controller/error_pages.html to achieve what you want. The best section for you I think will be error listener, at this point you will be able to modify headers.
Cheers!
Hello and thank you for your answer.
Is this really the right way to solve this problem?
What exactly would I have to do in this error listener to get a Hydra error message? Do you perhaps have an example?
Best regards,
Thomas
hm. I'm sorry I probably wasn't attentive...
Looking deeper into the issue, I did some tests on API Platform demo, and it looked like it should work fine. Have you tried to make the request in a different way? For example, directly from Postman or curl?
Cheers.
No, I didn't make any requests with Postman or curl. Don't think this will change anything, but I'll try it out next Monday when I get back to the office.
Have a nice weekend and thank you again for your efforts.
Greetings,
Thomas
Hello again,
I have now tested requests with Postman. As expected, no Ld+Json is provided here either.
Have you implemented a custom authenticator as a test, which returns a JsonResponse with an error message and a status code 401 in the "onAuthenticationFailure" method? Then you will notice that only “normal” JSON is delivered to the client.
I probably have a mistake in my thinking and need to do something to make it work the way I want it to.
Best regards,
Thomas
Hi,
I have this warning for each test where i loggin the user with factory :
Unfortunately, I can't find the solution if anyone has any ideas...
Otherwise, it might be an ORM-internal inconsistency, please report it.
Hey Julien,
Seems that is indirect deprecation which means it should be fixed itself in the future after you upgrade your dependencies to a newer version. In your case, it seems it will be gone after you upgrade to ORM 3.0, so you can just ignore it (though it may also require more dependencies to be upgraded as well like e.g. Doctrine bundle that may depend on ORM), so far no work on your side is needed :)
Cheers!
I have encountered the same issue, but instead of just a warning I got an exception and my entire testsuite failed executing.
I've looked around for a while, since the error seems rather cryptic, until I finally checked the documentation at https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#database-reset again and realized, that I didn't add the
use Factories;line at the top of my test case. Since the previous examples (running with--filter=....) ran fine, I didn't think it was necessary.So if you're using a newer version of Symfony/Doctrine/Foundry, you should add
use Factories;to your test case or else the EntityManager will not be cleared between execution of your test cases and you might get an error like that.Hey @Stefan-G,
Thanks for pointing that out! We did notice this a while back and added a note about it to a previous chapter.
Darn, we've been trying to make this error more helpful but it's a bit hacky and we can't seem to catch every scenario.
Regardless, I'm glad you solved the issue!
Kevin
"Houston: no signs of life"
Start the conversation!