Unleash Symfony API Testing with PHPUnit

Unleash Symfony API Testing with PHPUnit

Summary:

Learn to test Symfony 7.2 APIs with PHPUnit, covering JWT auth, mocking, validation, and secure test setups.

June 24, 2025

Building robust APIs in Symfony 7.2 requires thorough testing to ensure reliability and security. In this guide, we’ll use PHPUnit to test the API endpoints from JWT authentication setup -/api/register, /api/login_check, /api/users/{id}, /api/users/me, and /api/profile. You’ll learn to set up PHPUnit, write unit and functional tests, mock dependencies, and verify authentication and validation.

Getting Started with Unit Testing and PHPUnit

Unit testing focuses on small and independent sections of code, such as a single method, to verify that it performs as intended. For example, we can test a password validation function to ensure that it blocks invalid passwords and requires a minimum of 8 characters – a small win that saves issues later. Functional testing examines the full application’s behaviour, like sending a request to ~/api/register and confirming that the response is correct.

PHPUnit is the most reliable PHP testing framework, simplifying the process of creating and running tests. It pairs well with Symfony, helping to automate checks, catch the issues early, and give confidence when modifying code. For APIs like JWT authentication or user registration logic, testing is essential to guarantee stability across multiple scenarios; a single oversight could open a security gap or break the functionality.

This guide is based on the Symfony 7.2 project, where initial test failures, like a persistent 404 on ~/api/login_check, taught me key lessons. It is structured to help you set up PHPUnit and build both unit and functional tests.

Technical Tips for Symfony Unit Testing

  • Use KernelTestCase: We can use the KernelTestCase class to boot the Symfony kernel and access services like the EntityManager, which lets us mock database operations for clean, isolated unit tests.
  • Mock Dependencies in Controller: When testing something like AuthController, use PHPUnit’s createMock or Mockery to simulate dependencies.
  • Set Up a .env.test File: Set up a separate .env.test file; it’s invaluable. It configures a test database so we can run Doctrine migrations safely without risking production data.
  • Validate Entities with ValidatorInterface: To catch edge cases, we can include the ValidatorInterface in tests, mocking errors to check for issues like invalid emails or missing fields-saved me during a recent debug session.
  • Mock HTTP Requests with Request Objects: We’ve started using Request objects to mimic HTTP calls, which helps me test request processing and response building without needing a live server setup.

Prerequisites

  • Symfony 7.2 project with JWT setup (using LexikJWTAuthenticationBundle)
  • PHP 8.4
  • MySQL 8.0
  • Composer
  • PHPUnit

Step 1: Set Up PHPUnit

Ensure PHPUnit is installed and configured in your Symfony project.

Install PHPUnit via Composer

composer require –dev phpunit/phpunit “^10.0” symfony/test-pack

Configure PHPUnit

Copy the default configuration and update phpunit.xml to include your test environment settings and test suites, pointing to your .env.test file:

<php>
    <env name="APP_ENV" value="test"/>
    <env name="DATABASE_URL" value="mysql://user:password@localhost:3306/symfony7_api_demo_test_test?serverVersion=8.0&charset=utf8mb4"/>
</php>
<testsuites>
    <testsuite name="Unit">
        <directory>tests/Unit</directory>
    </testsuite>
    <testsuite name="Functional">
        <directory>tests/Functional</directory>
    </testsuite>
</testsuites>

Note: Adjust the DATABASE_URL to match your test database. The section organizes tests into Unit and Functional suites for targeted execution.

Create a Test Database and Load the Schema

Run these commands manually to reset the database:

php bin/console doctrine:database:drop --env=test --force
php bin/console doctrine:database:create --env=test
php bin/console doctrine:migrations:migrate --env=test
php bin/console doctrine:fixtures:load --env=test --no-interaction

Step 2: Write Unit Tests for Authentication Logic

Let’s test the AuthController’s register method to ensure validation works.

Original Controller Code

Here’s the AuthController, which is going to be tested,

#create src/Controller/AuthController.php
<?php
// src/Controller/AuthController.php
namespace App\Controller;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class AuthController
{
    private $entityManager;
    private $passwordHasher;
    private $jwtManager;
    private $validator;
    public function __construct(
        EntityManagerInterface $entityManager,
        UserPasswordHasherInterface $passwordHasher,
        JWTTokenManagerInterface $jwtManager,
        ValidatorInterface $validator
    ) {
        $this->entityManager = $entityManager;
        $this->passwordHasher = $passwordHasher;
        $this->jwtManager = $jwtManager;
        $this->validator = $validator;
    }
    public function register(Request $request): JsonResponse
    {
        $data = json_decode($request->getContent(), true);
        $user = new User();
        $user->setEmail($data['email'] ?? '');
        $user->setName($data['name'] ?? '');
        $errors = $this->validator->validate($user);
        if (count($errors) > 0) {
            return new JsonResponse(['errors' => (string) $errors], 400);
        }
        if (strlen($data['password'] ?? '') < 8) {
            return new JsonResponse(['error' => 'Password must be at least 8 characters'], 400);
        }
        $hashedPassword = $this->passwordHasher->hashPassword($user, $data['password']);
        $user->setPassword($hashedPassword);
        $this->entityManager->persist($user);
        $this->entityManager->flush();
        $token = $this->jwtManager->create($user);
        return new JsonResponse([
            'message' => 'User registered successfully',
            'user' => ['id' => $user->getId(), 'email' => $user->getEmail(), 'name' => $user->getName()],
            'token' => $token
        ], 201);
    }
}

Note: This controller handles user registration, validates input, hashes the password, persists the user, and generates a JWT token.

Create a Test File for AuthController

#create tests/Unit/AuthControllerTest.php:
<?php
// tests/Unit/AuthControllerTest.php
namespace App\Tests\Unit;
use App\Controller\AuthController;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class AuthControllerTest extends TestCase
{
    private $entityManager;
    private $passwordHasher;
    private $jwtManager;
    private $validator;
    private $controller;
    protected function setUp(): void
    {
        $this->entityManager = $this->createMock(EntityManagerInterface::class);
        $this->passwordHasher = $this->createMock(UserPasswordHasherInterface::class);
        $this->jwtManager = $this->createMock(JWTTokenManagerInterface::class);
        $this->validator = $this->createMock(ValidatorInterface::class);
        $this->controller = new AuthController(
            $this->entityManager,
            $this->passwordHasher,
            $this->jwtManager,
            $this->validator
        );
    }
public function testRegisterWithInvalidPassword(): void
    {
        $violations = new ConstraintViolationList();
        $violations[] = new ConstraintViolation('Email is required', '', [], null, 'email', null);
        $this->validator->method('validate')->willReturn($violations);
        $request = new Request([], [], [], [], [], [], json_encode(['email' => '', 'name' => 'John', 'password' => 'short']));
        $response = $this->controller->register($request);
        $this->assertInstanceOf(JsonResponse::class, $response);
        $this->assertEquals(400, $response->getStatusCode());
        $data = json_decode($response->getContent(), true);
        $this->assertArrayHasKey('errors', $data);
        $this->assertArrayHasKey('email', $data['errors']);
    }
    public function testRegisterWithValidData(): void
    {
        $user = $this->createMock(User::class);
        $user->method('getId')->willReturn(1);
        $user->method('getEmail')->willReturn('test@example.com');
        $user->method('getName')->willReturn('John');
        $this->validator->method('validate')->willReturn(new ConstraintViolationList());
        $this->passwordHasher->method('hashPassword')->willReturn('hashedpassword');
        $this->entityManager->method('persist')->willReturnCallback(function ($entity) use ($user) {
            if ($entity === $user) return;
        });
        $this->entityManager->method('flush');
        $request = new Request([], [], [], [], [], [], json_encode(['email' => 'test@example.com', 'name' => 'John', 'password' => 'test12345']));
        $response = $this->controller->register($request);
        $this->assertInstanceOf(JsonResponse::class, $response);
        $this->assertEquals(201, $response->getStatusCode());
        $data = json_decode($response->getContent(), true);
        $this->assertArrayHasKey('message', $data);
        $this->assertArrayHasKey('user', $data);
        $this->assertEquals('test@example.com', $data['user']['email']);
    }

Step 3: Write Functional Tests for API Endpoints

Let’s test the API endpoints using Symfony’s WebTestCase to simulate HTTP requests.

Create a Test File

#create tests/Functional/ApiFunctionalTest.php:
<?php
// tests/Functional/ApiFunctionalTest.php
namespace App\Tests\Functional;

use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class ApiFunctionalTest extends WebTestCase
{
    private KernelBrowser $client;

    protected function setUp(): void
    {
        $this->client = static::createClient([], ['HTTP_HOST' => '127.0.0.1:8000']);
    }

    public function testRegisterEndpoint(): void
    {
        $email = 'newtest' . time() . '@example.com';
        $this->client->request('POST', '/api/register', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode([
            'email' => $email,
            'name' => 'New User',
            'password' => 'test12345'
        ]));

        $this->assertEquals(201, $this->client->getResponse()->getStatusCode());
    }

    public function testLoginCheckEndpoint(): void
    {
        $this->client->request('POST', '/api/login_check', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode([
            'email' => 'jane@example.com',
            'password' => 'password123'
        ]));

        $response = $this->client->getResponse();
        $content = $response->getContent();
        echo "Login Response: $content\n";
        $this->assertEquals(200, $response->getStatusCode(), 'Login failed: ' . $content);
        $this->assertJson($content);
        $data = json_decode($content, true);
        $this->assertArrayHasKey('token', $data, 'No token returned: ' . json_encode($data));
    }

    public function testProtectedEndpointWithoutToken(): void
    {
        $this->client->request('GET', '/api/users/1');
        $this->assertEquals(401, $this->client->getResponse()->getStatusCode());
    }

    public function testProtectedEndpointWithToken(): void
    {
        $this->client->request('POST', '/api/login_check', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode([
            'email' => 'jane@example.com',
            'password' => 'password123'
        ]));
        $data = json_decode($this->client->getResponse()->getContent(), true);
        $token = $data['token'] ?? null;

        if ($token) {
            $this->client->request('GET', '/api/users/1', [], [], ['HTTP_Authorization' => 'Bearer ' . $token]);
            $this->assertEquals(200, $this->client->getResponse()->getStatusCode());
            $this->assertJson($this->client->getResponse()->getContent());
        } else {
            $this->fail('Failed to obtain JWT token: ' . json_encode($data));
        }
    }

Step 4: Mock JWT Authentication

To test protected endpoints, we initially struggled with token generation but resolved it by relying on the bundle. The test now uses the real authentication flow.

Update ApiFunctionalTest.php

The testProtectedEndpointWithToken method above already incorporates this, using the token from /api/login_check. No additional mocking is needed since the bundle handles it.

Step 5: Run Unit Tests

Run your unit tests to verify the application’s logic. This step ensures your code works as expected.

Run Command PHPUnit
vendor/bin/phpunit –testsuite Unit

Sample Output:
Dropped database `symfony7_api_demo_test_test` for connection named default
Created database `symfony7_api_demo_test_test` for connection named default

Updating database schema…
1 query was executed

[OK] Database schema updated successfully!

> purging database
> loading App\DataFixtures\AppFixtures
PHPUnit 10.5.46 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.4.7
Configuration: /home/brainstream/workspace/symfony7apidemo/phpunit.xml

..                                                                  2 / 2 (100%)
Time: 00:00.026, Memory: 24.00 MB
OK (2 tests, 9 assertions)

Note: This output shows two unit tests passing, confirming the AuthControllerTest works. Run this after setting up the database to see your results. Use –testsuite Functional to run functional tests separately.

Why Test Your APIs?

  • Reliability: Catch bugs early with automated tests, as we did with the container and routing issues.
  • Security: Verify authentication and validation logic, critical for JWT setups.
  • Maintainability: Ensure changes don’t break existing functionality, a lesson from our iterative fixes.

Conclusion

You’ve set up PHPUnit to test your Symfony 7.2 API, covering unit and functional tests for JWT-authenticated endpoints. Our journey from test failures (container errors, 404 error) to success highlights the power of debugging and configuration tweaks. With this foundation, your API is more robust and ready for production.

Explore our complete Symfony development services to build and test high-performance APIs with confidence.

Anand Dattani

Author

Anand Dattani is a Senior Developer at BrainStream Technolabs. He is an experienced developer specializing in PHP and modern MVC frameworks, with expertise in backend architecture and building scalable web solutions.

Related Blog

Symfony

Securing Symfony APIs with JWT Authentication

This guide shows you how to secure a Symfony 7.2 REST API with JWT authentication using lexik/jwt-authentication-bundle. You’ll set up user registration with validation, a rate-limited login endpoint, and protect routes like /api/users/{id} and /api/profile.

Symfony

Migrating from FOSUserBundle to Symfony Security Bundle (with ResetPasswordBundle & VerifyEmailBundle)

If you're still using FOSUserBundle in your Symfony project, it's time to upgrade. FOSUserBundle was once the standard solution for user authentication and management in Symfony, but it is now deprecated and unsupported in Symfony 6 and beyond. The good...

Symfony

Build Symfony REST API with OpenAPI and Swagger UI Integration

This guide shows you how to build your first REST API endpoint (/api/users/{id}) in Symfony 7.2, document it with OpenAPI YAML, and integrate Swagger UI for interactive testing. Using nelmio/api-doc-bundle and a hybrid approach, we’ll keep code clean, generate openapi.yaml,...

newslatter_bg_image
newslatter_image

Keep up-to-date with our newsletter.

Sign up for our newsletter to receive weekly updates and news directly to your inbox.