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!

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

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/api-pack": "^1.2", // v1.2.0
        "doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
        "nesbot/carbon": "^2.17", // 2.21.3
        "symfony/console": "4.3.*", // v4.3.2
        "symfony/dotenv": "4.3.*", // v4.3.2
        "symfony/flex": "^1.1", // v1.4.5
        "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/webpack-encore-bundle": "^1.6", // v1.6.2
        "symfony/yaml": "4.3.*" // v4.3.2
    },
    "require-dev": {
        "hautelook/alice-bundle": "^2.5", // v2.5.1
        "symfony/maker-bundle": "^1.11", // v1.12.0
        "symfony/profiler-pack": "^1.0", // v1.0.4
        "symfony/test-pack": "^1.0" // v1.0.6
    }
}