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.

... 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!

  • 2020-06-03 Philippe bouttereux

    Hi Vladimir Sadicov

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

    Cheers!

  • 2020-06-03 Vladimir Sadicov

    Hey Philippe bouttereux

    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!

  • 2020-06-03 Philippe bouttereux

    Hi Vladimir Sadicov

    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 !

  • 2020-01-27 Amjed Nouira

    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 :)

  • 2020-01-27 Vladimir Sadicov

    Hey Amjed Nouira

    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 :)

  • 2020-01-26 Amjed Nouira

    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 :)