Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Base Test Class full of Goodies

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

I love using functional tests for my API. Even with the nice Swagger frontend, it's almost faster to write a test than it is to try things manually... over and over again. To make writing tests even nicer, I have a few ideas.

Inside src/, create a new directory called Test/. Then, add a new class: CustomApiTestCase. Make this extend the ApiTestCase that our test classes have been using so far. If you're using API platform 2.5, the namespace will start with the ApiPlatform\Core namespace.

... lines 1 - 2
namespace App\Test;
... line 4
use App\ApiPlatform\Test\ApiTestCase;
... lines 6 - 8
class CustomApiTestCase extends ApiTestCase
... lines 10 - 36

Base Class with createUser() and logIn()

We're creating a new base class that all our functional tests will extend. Why? Shortcut methods! There are a lot of tasks that we're going to do over and over again, like creating users in the database & logging in. To save time... and honestly, to make each test more readable, we can create reusable shortcut methods right here.

Start with protected function createUser(). We'll need to pass this the $email we want, the $password we want... and it will return the User object after it saves it to the database.

Go steal the logic for all of this from our test class: grab everything from $user = new User() to the flush call. Paste this and, at the bottom, return $user. Oh, and we need to make a couple of things dynamic: use the $email variable and the $password variable. This will temporarily still be the encoded password... but we're going to improve that in a minute. For the username, let's be clever! Grab the substring of the email from the zero position to wherever the @ symbol is located. Basically, use everything before the @ symbol.

... lines 1 - 8
class CustomApiTestCase extends ApiTestCase
{
protected function createUser(string $email, string $password): User
{
$user = new User();
$user->setEmail($email);
$user->setUsername(substr($email, 0, strpos($email, '@')));
$user->setPassword($password);
$em = self::$container->get('doctrine')->getManager();
$em->persist($user);
$em->flush();
return $user;
}
... lines 24 - 35
}

We're also going to need to log in from... basically every test. Add a protected function logIn(). To accomplish this, we'll need to make a request... which means we need the Client object. Add that as the first argument followed by the same string $email and string $password, except that this time $password will be the plain text password. We shouldn't need to return anything.

Let's sharpen our code-stealing skills once again by going back to our test, copying these last two lines, pasting them, and making $email and $password dynamic.

... lines 1 - 24
protected function logIn(Client $client, string $email, string $password)
{
$client->request('POST', '/login', [
'headers' => ['Content-Type' => 'application/json'],
'json' => [
'email' => $email,
'password' => $password
],
]);
$this->assertResponseStatusCodeSame(204);
}

Woo! Time to shorten our code! Change the test class to extend our shiny new CustomApiTestCase. Below, replace all the user stuff with $this->createUser('cheeseplease@example.com') and... aw... dang! I should have copied that long password. Through the power of PhpStorm... undo.... copy... redo... and paste as the second argument.

Replace the login stuff too with $this->login(), passing $client that same cheeseplease@example.com and the plain text password: foo.

... lines 1 - 5
use App\Test\CustomApiTestCase;
... lines 7 - 8
class CheeseListingResourceTest extends CustomApiTestCase
{
... lines 11 - 12
public function testCreateCheeseListing()
{
... lines 15 - 21
$this->createUser('cheeseplease@example.com', '$argon2id$v=19$m=65536,t=6,p=1$AIC3IESQ64NgHfpVQZqviw$1c7M56xyiaQFBjlUBc7T0s53/PzZCjV56lbHnhOUXx8');
$this->logIn($client, 'cheeseplease@example.com', 'foo');
}
}

Let's check things! Go tests go!

php bin/phpunit

If we ignore those deprecation warnings... it passed!

Encoding the User Password

Ok, this feels good. What else can we do? The weirdest thing now is probably that we're passing this long encoded password. It's not obvious that this is an encoded version of the password foo and... it's annoying! Heck, I had to undo 2 minutes of work earlier so I could copy it!

Let's do this properly. Replace that huge, encoded password string with just foo.

Now, inside the base test class, remove the $password variable and replace it with $encoded = and... hmm. We need to get the service out of the container that's responsible for encoding passwords. We can get that with self::$container->get('security.password_encoder'). We also could have used UserPasswordEncoderInterface::class as the service id - that's the type-hint we use normally for autowiring. Now say ->encodePassword() and pass the $user object and then the plain text password: $password. Finish this with $user->setPassword($encoded).

... lines 1 - 8
class CustomApiTestCase extends ApiTestCase
{
protected function createUser(string $email, string $password): User
{
... lines 13 - 16
$encoded = self::$container->get('security.password_encoder')
->encodePassword($user, $password);
$user->setPassword($encoded);
... lines 20 - 25
}
... lines 27 - 38
}

Beautiful!

And this point... I'm happy! Heck, I'm thrilled! But... I do have one more shortcut idea. It'll be pretty common for us to want to create a user and then log in immediately. Let's make that easy! Add a protected function createUserAndLogIn()... which needs the same three arguments as the function above: $client, $email and $password... and we'll return the User. Inside say $user = $this->createUser() with $email and $password, then $this->logIn() with $client, $email, $password. At the bottom, return $user.

... lines 1 - 39
protected function createUserAndLogIn(Client $client, string $email, string $password): User
{
$user = $this->createUser($email, $password);
$this->logIn($client, $email, $password);
return $user;
}

Nice! Now we can shorten things a little bit more: $this->createUserAndLogIn().

... lines 1 - 8
class CheeseListingResourceTest extends CustomApiTestCase
{
... lines 11 - 12
public function testCreateCheeseListing()
{
... lines 15 - 21
$this->createUserAndLogIn($client, 'cheeseplease@example.com', 'foo');
}
}

Let's try it! Run:

php bin/phpunit

All green!

Looking back at our test, the purpose of this test was really two things. First, to make sure that if an anonymous users tries to use this endpoint, they'll get a 401 status code. And second, that an authenticated user should have access. Let's add that second part!

Make the exact same request as before... except that this time we should have access. Assert that we get back a 400 status code. Wait, why 400 and not 200 or 201? Well 400 because we're not actually passing real data... and so this will fail validation: a 400 error. If you wanted to make this a bit more useful, you could pass real data here - like a title, description, etc - and test that we get back a 201 successful status code.

Tip

Starting in API Platform 2.6, validation errors will trigger a 422 status code instead of 400.

... lines 1 - 12
public function testCreateCheeseListing()
{
... lines 15 - 23
$client->request('POST', '/api/cheeses', [
'headers' => ['Content-Type' => 'application/json'],
'json' => [],
]);
$this->assertResponseStatusCodeSame(400);
}
... lines 30 - 31

Let's try this!

php bin/phpunit

It works! Oh, but one last, tiny bit of cleanup. See this headers key? We can remove that... and we have one more in CustomApiTestCase that we can also remove.

... lines 1 - 12
public function testCreateCheeseListing()
{
... line 15
$client->request('POST', '/api/cheeses', [
'json' => [],
]);
... lines 19 - 22
$client->request('POST', '/api/cheeses', [
'json' => [],
]);
... line 26
}
... lines 28 - 29

But wait... didn't we need this so that API Platform knows we're sending data in the right format? Absolutely. But... when you pass the json option, the Client automatically sets the Content-Type header for us. To prove it, run the tests one last time:

php bin/phpunit

Everything works perfectly!

Hey! This is a great setup! So let's get back to API Platform security stuff! Right now, to edit a cheese listing, you simply need to be logged in. We need to make that smarter: you should only be able to edit a cheese listing if you are the owner of that cheese listing... and maybe also admin users can edit any cheese listing.

Let's do that next and prove it works via a test.

Leave a comment!

27
Login or Register to join the conversation
André P. Avatar
André P. Avatar André P. | posted 1 year ago

Hey, everyone!

I am using Symfony 5.3 and I bumped into a situation that I would like to know your opinion to understand if this is the right way to deal with it.

In this version, the service security.password_encoder is deprecated in favour of the security.password_hasher, so when I run the tests I got the following error:

Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException: The "security.password_hasher" service or alias has been removed or inlined when the container was compiled. You should either make it public, or stop using the container directly and use dependency injection instead.

Since I think I cannot use dependency injection in this context (unit tests) I tried the other suggested solution: set it to public.

I ended up creating an alias in the services_test.yaml like this:

services:
test.security.password_hasher:
alias: 'Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface'
public: true

And then use that alias, like:

$hashedPassword = self::getContainer()->get('test.security.password_hasher')
->hashPassword($user, $password);

Is this the right way to deal with this situation?

Thank you in advance!

1 Reply
Dang Avatar

Hi, I have same error depreciation with Symfony 5.3 and I do the same thing as you did. I created file services_test.yaml in config and put any thing you write. But when I run the test, it says:

Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException: You have requested a non-existent service "test.security.password_hasher".
Its seems the services_test.yaml not works.
Did I miss the step to tell phpunit use that config in services_test.yaml? Any other suggests to do the encode password stuff in test?
Many thanks

Reply

HeyDang

That's odd. By default, PHPUnit should run in the test environment so your Kernel will load both config files services.yaml & services_test.yaml. Double check that the file lives inside config/ and the name is correct. You could also try deleting the cache manually rm -r var/cache

Cheers!

1 Reply
Ricardo M. Avatar
Ricardo M. Avatar Ricardo M. | MolloKhan | posted 11 months ago | edited

deleting the cache this way (instead of c:c) was key to solve the mistery here. thanks MolloKhan

1 Reply

Hey André,

I think in this situation it's fine what you did, and since you defined the service only for the test environment I don't see any potential problem to your approach.

Cheers!

Reply
André P. Avatar

Hey, Diego.

Nice! Thank you for the quick reply.
Keep up the great work and support!

Cheers!

Reply
Default user avatar
Default user avatar Alexander Sumkin | André P. | posted 1 year ago

Hi André,

Try something like this



use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory;

class SomeClasse extends ApiTestCase

public function someFunction()
{
$factory = new PasswordHasherFactory(['auto' => ['algorithm' => 'bcrypt'], 'memory-hard' => ['algorithm' => 'sodium'],]);
$passwordHasher = $factory->getPasswordHasher('auto');
$hashedPassword = $passwordHasher->hash($plainPasssord);

$user = new User();
$user->setEmail($email);
$user->setUsername(substr($email, 0, strpos($email, '@')));
$user->setPassword($hashedPassword);
}

}

I solved it so.

--
Regards,
Alexander Sumkin

1 Reply
Sakshi G. Avatar

Thanks!! it worked for me in symfony 5.4

Reply

Hey @Alexander

Thanks for your work around, that's also valid but personally, I prefer the other option because you don't have to worry about instatiation the PasswordHasher service. Making services public for the test environment it's not a bad thing actually

Cheers!

Reply
Roland W. Avatar
Roland W. Avatar Roland W. | posted 5 months ago

I have to call `disableReboot()` on the client. Otherwise I get this error on my second request (the first request is the login request): "Cannot set session ID after the session has started." I use Symfony 4.4 and API Platform 2.6.8. Any idea why this is necessary for me an not for you?

Reply

Hey Roland W.!

Hmm. That indeed looks weird. It "feels" to me as if PHP is starting a real session... but it should be using a "mock" session in the test environment. I would check into that. Specifically, you should have some config - probably in config/packages/test/framework.yaml that looks like this: https://github.com/symfony/... - I would check there first. But, I'm doing some guessing. You have, unfortunately, a pretty old Symfony version. So we can't rule out that there is some weird behavior due to that :/.

Cheers!

Reply
Ben G. Avatar

Hello everyone,

I am having an issue : My LogRessourceTest (aka CheeseListing) can't find my CustomApiTestCase.

docker-compose exec php bin/phpunit
PHP Fatal error: Uncaught Error: Class 'App\Test\CustomApiTestCase' not found in /srv/api/tests/Functional/LogResourceTest.php:8
Stack trace:

1) Why is that ?
2) Why do we move our CustomApiTestCase in src/Test ? That's make two tests folder, which can be confusing.
-> My test works if CustomApiTestCase is in Tests folder

PS : I'm on API Platform 2.5.9

Reply

Hey Ben G.!

Let's see what we can figure out :).

>1) Why is that ?

I'm not completely sure. As long as your CustomApiTestCase lives in a src/Test/CustomApiTestCase.php file and that file has the correct class name (CustomApiTestCase) and namespace (App\Test), Composer's autoloader should find it. These class not found things are usually something minor like that. The other possibility is that PHPUnit isn't loading your autoloader - that's normally handled thanks to your phpunit.xml.dist file - it has a bootstrap config that points to a file that initializes the autoloader - for example - https://github.com/symfony/...

> 2) Why do we move our CustomApiTestCase in src/Test ? That's make two tests folder, which can be confusing.
> -> My test works if CustomApiTestCase is in Tests folder

That's a good question. This is a matter of "taste" My perspective is that CustomApiTestCase is not itself a "test" - it's a helper class. So I put it in the src/ directory. But that doesn't mean I'm correct. You could argue that even though this isn't *itself* a test class, it exists for our tests, not our normal code, and so should live in tests/.

And, you ARE free to put things in either src/ or test/. A Symfony project, by default, it set up to autoload from both folders. The default "new project" composer.json skeleton - https://github.com/symfony/... - has this:


"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},

This means that you *can* put things into the tests/ directory, as long as the namespace starts with App\Tests.

So hopefully that explains how classes are loaded in both directories *and* the fact that you should totally feel ok putting tour CustomApiTestCase inside tests/ ;).

Cheers!

Reply
Ben G. Avatar

Thanks for your reactivity and you well explained anwser !

1) Ok, that's what I thought. When you start a new language/framework, is usually useful to ask dumb questions. I'll discuss what my team prefers.

2) Last time I did, I had no errors in PHPStorm. So I try again, and this time it works. I may have write my test folder in lowercase. Anyway, now it works and I totally got it, thanks again !

Reply
Kakha K. Avatar

In my case the problem was that I created folder name in lowercase
"test" and had same error. after renaming to Test this works fine!

Reply

The test directory name has to match to what you have in the autoload-dev.psr-4 key and in your phpunit.xml file

Cheers!

Reply
Vishal T. Avatar
Vishal T. Avatar Vishal T. | posted 1 year ago

1) App\Tests\Functional\CheeseListingResourceTest::testCreateCheeseListing
TypeError: Argument 1 passed to App\ApiPlatform\Test\ApiTestCase::getHttpClient() must be an instance of App\ApiPlatform\Test\Client or null, instance of ApiPlatform\Core\Bridge\Symfony\Bundle\Test\Client given, called in /var/www/html/api_platform/src/ApiPlatform/Test/ApiTestCase.php on line 66

/var/www/html/api_platform/src/ApiPlatform/Test/ApiTestAssertionsTrait.php:126
/var/www/html/api_platform/src/ApiPlatform/Test/ApiTestCase.php:66
/var/www/html/api_platform/tests/Functions/CheeseListingResourceTest.php:15

I am getting above error while creating the test case for user.

Reply

Hey Vishal T.!

Let's see if we can figure this out :).

First, are you using API Platform 2.5? If so, then you don't need to copy any of our test code from this chapter - https://symfonycasts.com/sc... - because API Platform 2.5 has all of these classes. I believe you *are* using API Platform 2.5, which is why you're having the issue. So, you can make your life simpler by not copying any of those files and extending the ApiTestCase directly from ApiPlatform :).

But, to your specific error, I think you did copy the test files, but I think you might be missing this services_test.yaml file - https://symfonycasts.com/sc... (but I could be wrong, that's just a guess). However, either way, if you're on API Platform 2.5, the best solution is to just use *their* test code and delete your src/ApiPlatform/Test/ directory entirely).

Cheers!

Reply
Sebastian K. Avatar
Sebastian K. Avatar Sebastian K. | posted 2 years ago

Maybe it is beyond the scope of this example, but I think it is not the best practice to place all tests in one test method. For my part (I am not following exactly), I created 3 methods: testCreateUser, the normal working path, testCreateUserNotLoggedIn, to ensure a 401 if there is no logged in user, and finally testCreateUserInsufficientRoles for a logged in user with insufficient roles.

Now, if one test fails, I can see immediately which part fails and I can focus exactly on the broke path. Also, the tests don't depend on each other.

Reply

Hey Sebastian,

Yes, I think I'd agree with you. Well, it depends... first of all, it's not a course about testing, so we didn't want to focus on testing strategy too much, mostly just the general idea of it, and for simplicity doing those things in one method was great as you can see, a little code, and users who does not care about testing (though we hope we don't have such users here :p ) may easily skip that part. Also, in defense of the current implementation I'd say that you can also easily add custom errors messages to PHPUnit's assert methods that would also give you a lot of context about what exactly test failed. But you're right, best practice would be to split those tests. And I think we're talking about this in our PHPUnit course here: https://symfonycasts.com/sc... - which has focus on testing.

Thank you for understanding!

Cheers!

Reply

Yep, I'd agree too :). I admit, I was taking some shortcuts on this one. A best practice would be to do exactly what you said. I will *also* admit that I'm not perfect on this - it's tempting to "re-use" your already-setup-and-ready-test to assert more things". But yea, ultimately, you "pay the price" later on when it's harder to debug :).

Cheers!

Reply
Amjed N. Avatar
Amjed N. Avatar Amjed N. | posted 2 years ago

Hi Ryan,

Thank you very much for your dynamism ;)

I have one question : Why did you create the CustomApiTestCase into the src/ directory. After all, it's a class that'll be used only for tests. From an organization point of view, don't you think that is better if we put it directly under the tests/ directory ?

I also have another note: I think that the input of the logIn function must be the email and the password only. The $client object that you passed, I think it should be something that the class provides. For me, I create something like this


namespace App\Tests;

use ApiPlatform\Core\Bridge\Symfony\Bundle\Test\ApiTestCase;
use App\Entity\User;
use ApiPlatform\Core\Bridge\Symfony\Bundle\Test\Client;

class CustomApiTestCase extends ApiTestCase
{

private static ?Client $httpClient = null;

/**
* @return Client
*/
protected static function getHttpClient(): Client
{
if (null === self::$httpClient) {
self::$httpClient = self::createClient();
}

return self::$httpClient;
}

protected function createUser(string $email, string $password): User
{
//...
}

protected function logIn(string $email, string $password)
{

self::getHttpClient()->request('POST', '/login', [
'headers' => ['Content-Type' => 'application/json'],
'json' => [
'email' => $email,
'password' => $password
],
]);
$this->assertResponseStatusCodeSame(204);
}

protected function createUserAndLogIn(string $email, string $password): User
{
$user = $this->createUser($email, $password);
$this->logIn($email, $password);
return $user;
}

Looking forward to hearing your feedback :)

Cheers :)

Reply

Hey Amjed N.

Good questions. I suppose holding CustomApiTestCase inside src/ is just to separate some reusable code from tests, and to keep tests/ directory clean only for executable tests.

And about your note sometimes it's better to not reuse existing $client object, and instantiate new one for each test. Because client can save some info about previous request and this can cause unexpected behavior.

Cheers :)

Reply
Philippe B. Avatar
Philippe B. Avatar Philippe B. | sadikoff | posted 2 years ago | edited

Hi sadikoff

I understand your point about having a clean tests/ directory but the thing is, my production build is loading my CustomApiTestCase and trying to load Alice's ReloadDatabaseTrait as well.

Of course, alice is dev dependency so it doesn't work.

I'm fairly new to php and autoloaders, do you have any tips to avoid loading the Test directory outside of testing env?
I could probably do as amjed said and move the Test directory in tests/ but then I can't get my imports to work.

I tried having a tests/Test directory with the namespace App\Test, then App\Test\Test. Nothing :(

Looking forward to your answer !

Reply

Hey Philippe B.

Try to exclude Test/ directory from autowiring. By default symfony excludes Tests/ directory you can find it inside config/services.yaml file look at App\: definition. Just add your Test directory or rename your directory to correspond default configuration.

Cheers!

Reply
Philippe B. Avatar
Philippe B. Avatar Philippe B. | sadikoff | posted 2 years ago | edited

Hi sadikoff

Perfect, it works like a charm!
Thank you so much for your quick reply

Cheers!

Reply
Amjed N. Avatar

Hey Vladimir,

Thank you for your respond.

Okay for the first point.

For the second one, you're right we must instanciate for each test a client. So I put the instansation of the $client in the setUp() method into the CustomApiTestCase.

Cheers :)

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial works great for Symfony 5 and API Platform 2.5/2.6.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3, <8.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.4.5
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // 1.13.2
        "doctrine/doctrine-bundle": "^1.6", // 1.11.2
        "doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
        "doctrine/orm": "^2.4.5", // v2.7.2
        "nelmio/cors-bundle": "^1.5", // 1.5.6
        "nesbot/carbon": "^2.17", // 2.21.3
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.1
        "symfony/asset": "4.3.*", // v4.3.2
        "symfony/console": "4.3.*", // v4.3.2
        "symfony/dotenv": "4.3.*", // v4.3.2
        "symfony/expression-language": "4.3.*", // v4.3.2
        "symfony/flex": "^1.1", // v1.18.7
        "symfony/framework-bundle": "4.3.*", // v4.3.2
        "symfony/http-client": "4.3.*", // v4.3.3
        "symfony/monolog-bundle": "^3.4", // v3.4.0
        "symfony/security-bundle": "4.3.*", // v4.3.2
        "symfony/twig-bundle": "4.3.*", // v4.3.2
        "symfony/validator": "4.3.*", // v4.3.2
        "symfony/webpack-encore-bundle": "^1.6", // v1.6.2
        "symfony/yaml": "4.3.*" // v4.3.2
    },
    "require-dev": {
        "hautelook/alice-bundle": "^2.5", // 2.7.3
        "symfony/browser-kit": "4.3.*", // v4.3.3
        "symfony/css-selector": "4.3.*", // v4.3.3
        "symfony/maker-bundle": "^1.11", // v1.12.0
        "symfony/phpunit-bridge": "^4.3", // v4.3.3
        "symfony/stopwatch": "4.3.*", // v4.3.2
        "symfony/web-profiler-bundle": "4.3.*" // v4.3.2
    }
}