Functional Test for the Upload Endpoint

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.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

How can we write automated tests for all of this? Well... I have so many answers for that. First, you could unit test your message classes. I don't normally do this... because those classes tend to be so simple... but if your class is a bit more complex or you want to play it safe, you can totally unit test this.

More important are the message handlers: it's definitely a good idea to test these. You could write unit tests and mock the dependencies or write an integration test... depending on what's most useful for what each handler does.

The point is: for message and message handler classes... testing them has absolutely nothing to do with messenger or transports or async or workers: they're just well-written PHP classes that we can test like anything else. That's really one of the beautiful things about messenger: above all else, you're just writing nice code.

But functional tests are more interesting. For example, open src/Controller/ImagePostController.php. The create() method is the upload endpoint and it does a couple of things: like saving the ImagePost to the database and, most important for us, dispatching the AddPonkaToImage object.

Writing a functional test for this endpoint is actually fairly straightforward. But what if we wanted to be able to test not only that this endpoint "appears" to have worked, but also that the AddPonkaToImage object was, in fact, sent to the transport? After all, we can't test that Ponka was actually added to the image because, by the time the response is returned, it hasn't happened yet!

Test Setup

Let's get the functional test working first, before we get all fancy. Start by finding an open terminal and running:

composer require phpunit --dev

That installs Symfony's test-pack, which includes the PHPUnit bridge - a sort of "wrapper" around PHPUnit that makes life easier. When it finishes, it tells us to write our tests inside the tests/ directory - brilliant idea - and execute them by running php bin/phpunit. That little file was just added by the recipe and it handles all the details of getting PHPUnit running.

Ok, step one: create the test class. Inside tests, create a new Controller/ directory and then a new PHP Class: ImagePostControllerTest. Instead of making this extend the normal TestCase from PHPUnit, extend WebTestCase, which will give us the functional testing superpowers we deserve... and need. The class lives in FrameworkBundle but... be careful because there are (gasp) two classes with this name! The one you want lives in the Test namespace. The one you don't want lives in the Tests namespace... so it's super confusing. It should look like this. If you choose the wrong one, delete the use statement and try again.

... lines 1 - 2
namespace App\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class ImagePostControllerTest extends WebTestCase
{
... lines 9 - 12
}

But.... while writing this tutorial and getting mad about this confusing part, I created an issue on the Symfony repository. And I'm thrilled that by the time I recorded the audio, the other class has already been renamed! Thanks to janvt who jumped on that. Go open source!

Anyways, because we're going to test the create() endpoint, add public function testCreate(). Inside, to make sure things are working, I'll try my favorite $this->assertEquals(42, 42).

... lines 1 - 2
namespace App\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class ImagePostControllerTest extends WebTestCase
{
public function testCreate()
{
$this->assertEquals(42, 42);
}
}

Running the Test

Notice that I didn't get any auto-completion on this. That's because PHPUnit itself hasn't been downloaded yet. Check it out: find your terminal and run the tests with:

php bin/phpunit

This little script uses Composer to download PHPUnit into a separate directory in the background, which is nice because it means you can get any version of PHPUnit, even if some of its dependencies clash with those in your project.

Once it's done... ding! Our one test is green. And the next time we run:

php bin/phpunit

it jumps straight to the tests. And now that PHPUnit is downloaded, once PhpStorm builds its cache, that yellow background on assertEquals() will go away.

Testing the Upload Endpoint

To test the endpoint itself, we first need an image that we can upload. Inside the tests/ directory, let's create a fixtures/ directory to hold that image. Now I'll copy one of the images I've been uploading into this directory and name it ryan-fabien.jpg.

There it is. The test itself is pretty simple: create a client with $client = static::createClient() and an UploadedFile object that will represent the file being uploaded: $uploadedFile = new UploadedFile() passing the path to the file as the first argument - __DIR__.'/../fixtures/ryan-fabien.jpg - and the filename as the second - ryan-fabien.jpg.

... lines 1 - 5
use Symfony\Component\HttpFoundation\File\UploadedFile;
class ImagePostControllerTest extends WebTestCase
{
public function testCreate()
{
$client = static::createClient();
$uploadedFile = new UploadedFile(
__DIR__.'/../fixtures/ryan-fabien.jpg',
'ryan-fabien.jpg'
);
... lines 18 - 22
}
}

Why the, sorta, "redundant" second argument? When you upload a file in a browser, your browser sends two pieces of information: the physical contents of the file and the name of the file on your filesystem.

Finally, we can make the request: $client->request(). The first argument is the method... which is POST, then the URL - /api/images - we don't need any GET or POST parameters, but we do need to pass an array of files.

... lines 1 - 5
use Symfony\Component\HttpFoundation\File\UploadedFile;
class ImagePostControllerTest extends WebTestCase
{
public function testCreate()
{
$client = static::createClient();
$uploadedFile = new UploadedFile(
__DIR__.'/../fixtures/ryan-fabien.jpg',
'ryan-fabien.jpg'
);
$client->request('POST', '/api/images', [], [
... line 19
]);
... lines 21 - 22
}
}

If you look in ImagePostController, we're expecting the name of the uploaded file - that's normally the name attribute on the <input field - to literally be file. Not the most creative name ever... but sensible. Use that key in our test and set it to the $uploadedFile object.

... lines 1 - 5
use Symfony\Component\HttpFoundation\File\UploadedFile;
class ImagePostControllerTest extends WebTestCase
{
public function testCreate()
{
$client = static::createClient();
$uploadedFile = new UploadedFile(
__DIR__.'/../fixtures/ryan-fabien.jpg',
'ryan-fabien.jpg'
);
$client->request('POST', '/api/images', [], [
'file' => $uploadedFile
]);
dd($client->getResponse()->getContent());
}
}

And... that's it! To see if it worked, let's just dd($client->getResponse()->getContent()).

... lines 1 - 5
use Symfony\Component\HttpFoundation\File\UploadedFile;
class ImagePostControllerTest extends WebTestCase
{
public function testCreate()
{
$client = static::createClient();
$uploadedFile = new UploadedFile(
__DIR__.'/../fixtures/ryan-fabien.jpg',
'ryan-fabien.jpg'
);
$client->request('POST', '/api/images', [], [
'file' => $uploadedFile
]);
dd($client->getResponse()->getContent());
}
}

Testing time! Find your terminal, clear the screen, deep breath and...

php bin/phpunit

Got it! And we get a new id each time we run it. The ImagePost records are saving to our normal database because I haven't gone to the trouble of creating a separate database for my test environment. That is something I normally like to do.

Asserting Success

Remove the dd(): let's use a real assertion: $this->assertResponseIsSuccessful().

... lines 1 - 7
class ImagePostControllerTest extends WebTestCase
{
public function testCreate()
... lines 11 - 21
$this->assertResponseIsSuccessful();
}
}

This nice method was added in Symfony 4.3... and it's not the only one: this new WebTestAssertionsTrait has a ton of nice new methods for testing a whole bunch of stuff!

If we stopped now... this is a nice test and you might be perfectly happy with it. But... there's one part that's not ideal. Right now, when we run our test, the AddPonkaToImage message is actually being sent to our transport... or at least we think it is... we're not actually verifying that this happened... though we can check manually right now.

To make this test more useful, we can do one of two different things. First, we could override the transports to be synchronous in the test environment - just like we did with dev. Then, if handling the message failed, our test would fail.

Or, second, we could at least write some code here that proves that the message was at least sent to the transport. Right now, it's possible that the endpoint could return 200... but some bug in our code caused the message never to be dispatched.

Let's add that check next, by leveraging a special "in memory" transport.

Leave a comment!

  • 2020-04-03 Kakha Kashmadze

    I had same problem and I found that phpunit does not reads the .env files, instead it looks phpunit.xml in symfony's root dir. First copy
    phpunit.xml.dist to phpunit.xml. after that in section <php> .. </php> add these lines with yout values

    <php>
    ...
    <server name="KERNEL_CLASS" value="App\Kernel"/>
    <server name="APP_SECRET" value="somesecret"/>
    <server name="DATABASE_URL" value="mysql://root:password@127.0.0.1:3306/the_spacebar"/>
    <server name="MESSENGER_TRANSPORT_DSN" value="doctrine://default"/>
    </php>

    That works for me, I am using symfony 4.4.7

  • 2019-11-18 Victor Bocharsky

    Hey Dilyan,

    Thank you for this tip. Though, I'm not sure it works the exact way you described. Most probably the ".env.test" is created for you when you requires PHPUnit bridge with Composer (comes from the recipe), i.e. when executing "composer require phpunit".

    Cheers!

  • 2019-11-15 Дилян Траянов

    If you don't have .env.test. The first time you run "php bin/phpunit" it will be created. Remember to open that file and add all of you credentials and secrets, otherwise the program/app/website will not work.

  • 2019-08-30 Victor Bocharsky

    Hey Stéphane,

    First of all, you need to know the correct DB credentials. If those credentials are just defaults, like not secret ones - set them on DATABASE_URL env var in ".env" or ".env.test" respectively and commit them out. If your credentials are secret and you don't want to commit them - create ".env.local" or ".env.test.local" and set the them on DATABASE_URL there.

    Cheers!

  • 2019-08-26 weaverryan

    Hey Jérôme !

    Nice job debugging! And yea... that is very weird :/. For reference, here is the line that skips the .env.local file in the test environment: https://github.com/symfony/... (or rather, this is the section that LOADS .env.local in all environments except for the $testEnvs. And here are the lines that should ALWAYS load .env.test and .env.test.local https://github.com/symfony/... - AND, values from later files override values in earlier files.

    Anyways, if you want to nerd out and do some debugging to figure out what was going wrong there, awesome. If not, then I'm glad we at least got it working :D.

    Cheers!

  • 2019-08-22 Jérôme 

    I tried the solution you gave me, but no success. I added the env variable DATABASE_URL in .env.test, but it looks like it's not being overriden. *BUT*, I managed to make it work (no problem anymore) by using the (basic) .env, which I didn't really want to do. SO, when I try to use .env.local, .env.test or .env.test.local, nothing works. When I use .env, it's okay... It's weird that I can't use another .env file...

  • 2019-08-22 weaverryan

    Hey Jérôme !

    Brilliant debugging! I think I know the cause now... though I'm still *quite* confused about why you're exactly getting a "MySQL has gone away"... I would expect something more like "Unable to connect" in this situation.

    What's going on? By chance, I just talked about it on the API Platform security tutorial a few days ago: https://symfonycasts.com/sc...

    So, yes, you're exactly right: your .env is being read and your .env.local is being completely ignored. This is a little "feature" that I like and hate at the same time, and argued against originally when it was added (though it IS useful half the time).

    Let me know if this makes the difference :).

    Cheers!

  • 2019-08-20 Jérôme 

    Ok, I just did what you suggested. I replaced the return with the one you gave me, then deleted each line one by one. Finally, I think I know where the problem comes from... When I remove $entityManager->persist($imagePost); and $entityManager->flush();, the error disappears. When I put it back, the problem is here again. *SO*, there's a big chance it comes from the entityManager OR when I persist the $imagePost. But... Even though I know where the problem comes from, I don't know the origin... Why does it cause the request to fail?

    [EDIT] : After I dumped the $entityManager, I found out something interesting:


    -params: array:11 [
    "driver" => "pdo_mysql"
    "charset" => "utf8mb4"
    "url" => "mysql://root:@127.0.0.1:3306/messenger_tutorial"
    "host" => "127.0.0.1"
    "port" => "3306"
    "user" => "root"
    "password" => ""
    "driverOptions" => []
    "serverVersion" => "5.7"
    "defaultTableOptions" => array:2 [
    "charset" => "utf8mb4"
    "collate" => "utf8mb4_unicode_ci"
    ]
    "dbname" => "messenger_tutorial"
    ]

    The URL parameter is incorrect. So I just wanted to let you know that first of all, I created a .env.local file, and the original .env file does still have the default values. The URL param of the entity manager is equal to the default value in .env, not my .env.local, and I can't manage to change this URL... Any idea?

  • 2019-08-19 weaverryan

    Hey Jérôme !

    Thank you! I'm pretty sure that the database is "going away" inside the request itself - e.g. the POST request to the app_imagepost_create route. So, let's do some debugging :). With weird things like this, I usually first try to eliminate as many lines of code as possible to see if we can identify one line that might be causing the problem. So, for example, in your controller, I would delete one line of code then try the test. If you get the same result, delete another line of code. Do this until the database stops "going away" or until you have a completely blank controller (well, you'll *always* need to return a response - so you could put a dummy return $this->json(['success' => true]); at the bottom.

    Let me know what you find out!

    Cheers!

  • 2019-08-16 Jérôme 

    Ok, thank you Victor Bocharsky! Does this work: https://imgur.com/a/SrD8HJR There are 2 pictures. If you need more, just tell me.

  • 2019-08-16 Victor Bocharsky

    Hey Jerome,

    It's pretty easy! You can upload to any image hosting you want (like Imgur service for example) and then just put links to the images in a new comment.

    Cheers!

  • 2019-08-16 Jérôme 

    Hey, so I made the screenshots, but I don't know how I can give them to you. Please let me know! Thanks!

  • 2019-08-16 Stéphane

    Hey Ryan,

    Thank for this nice tutorial.

    I have error when I launch the test : FAILURES! Tests: 1, Assertions: 1, Failures: 1.

    Like Jérôme, I have a big message of HTML code into terminal. Access denied for user root. How can I change this default user ?

  • 2019-08-15 weaverryan

    Hey Jérôme !

    Can you take a screenshot of the error and stack trace in the terminal? Here's the part I'm not sure about yet: when you make a request, (A) does the error happen on that request... and then you print the error with dump() or (B) does the error NOT happen on the request, but when you dump the successful response, *that* causes something weird to happen and the database dies.

    I *think* it's probably the first, but I don't know for sure. If it *is* the first, I'd be interested in finding out which code during the request caused the issue to happen. Another way to "sort of" check all of this would be to comment out the $messageBus->dispatch() in the controller to see if that makes any difference.

    I hope you're not losing too much sleep over this ;). These issues are the worst!

    Cheers!

  • 2019-08-15 Jérôme 

    Already 3am here, I tried a lot of things, I set max_allowed_packet, added wait_timeout in my.cnf.. The last ten lines of the MySQL config on my Macbook Air are:


    connect-timeout 0
    max-allowed-packet 16777216
    net-buffer-length 16384
    select-limit 1000
    max-join-size 1000000
    show-warnings FALSE
    plugin-dir (No default value)
    default-auth (No default value)
    binary-mode FALSE
    connect-expired-password FALSE

    I don't know what to do anymore.....

  • 2019-08-14 Diego Aguiar

    Hmm, probably you need to tweak a bit your MySql config. I found this by googling your error
    > The MySQL server has gone away (error 2006) has two main causes and solutions: Server timed out and closed the connection. To fix, check that wait_timeout mysql variable in your my.cnf configuration file is large enough. ... set max_allowed_packet = 128M , then restart your MySQL server: sudo /etc/init.d/mysql restart.

    Could you give it a try to those possible fixes and let us know if it worked?

    Cheers!

  • 2019-08-14 Jérôme 

    Yes, the dd part causes the error. When I remove it, the problem disappears. There's a stack trace, but it's too big to show here. This stack trace includes the error message I mentioned earlier. Basically, it's a Symfony error page HTML that appears in the terminal.

  • 2019-08-14 weaverryan

    Hmm, yea, it's VERY odd. 3 seconds is not long enough for the database connection to "go away". Wait... let me think. Question:

    1) Does dd($client->getResponses()->getContent()) actually *cause* the error? If you removed this line, do you not see the error?

    2) Do you have any stack trace that goes with that error.

    Something is fishy... :)

  • 2019-08-14 Jérôme 

    Hey, thanks for the answer. It happens every time I run the test and takes approximatively 3 seconds (more or less). I think it's weird because I wrote exactly what you wrote.

  • 2019-08-14 weaverryan

    Hey Jérôme !

    Hmm. that is a wild error! That usually happens if, for example, you have some long-running connection to the database... and the database eventually times out your connection. But in a functional test... that's very odd. Does it happen *every* time you run the test? And how long does it take for this error to show up - just a few seconds?

    Cheers!

  • 2019-08-14 Jérôme 

    Hi, when I try dd($client->getResponses()->getContent()), I get an error:

    Doctrine\DBAL\Exception\DriverException:\n
    An exception occurred in driver: SQLSTATE[HY000] [2006] MySQL server has gone away\n

    I don't understand why as I'm just following the course and I didn't find a solution to fix the problem. Maybe I'm doing something wrong..? Can you help me please?