Testing Your Application

In this tutorial you will learn how to unit test a Melodic application using PHPUnit and the framework's built-in test doubles. You will test validation rules, services, controllers, and middleware — all without starting a web server or touching a database.

What you will build: A comprehensive test suite that covers every layer of the Melodic architecture, using ArraySession, ArrayCache, NullLogger, and mock implementations to isolate each component.

Step 1: PHPUnit Setup

Install PHPUnit as a dev dependency:

composer require --dev phpunit/phpunit

Create phpunit.xml in your project root:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true">
    <testsuites>
        <testsuite name="App">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
</phpunit>

Create the test directory:

mkdir -p tests

Run the test suite:

vendor/bin/phpunit

Step 2: Built-in Test Doubles

Melodic provides several classes designed for use in tests. They implement the same interfaces as their production counterparts but operate entirely in memory.

Test DoubleReplacesBehavior
ArraySessionNativeSessionStores session data in a PHP array. No PHP session started.
ArrayCacheFileCacheStores cache entries in a PHP array with TTL support.
NullLoggerFileLoggerSilently discards all log messages.

These are real classes in the framework, not mocks. They implement the correct interfaces (SessionInterface, CacheInterface, LoggerInterface) and behave predictably:

<?php

use Melodic\Cache\ArrayCache;
use Melodic\Log\NullLogger;
use Melodic\Session\ArraySession;

// ArraySession — works like NativeSession but in memory
$session = new ArraySession();
$session->set('user_id', 42);
$session->get('user_id');       // 42
$session->has('user_id');       // true
$session->remove('user_id');
$session->has('user_id');       // false

// ArrayCache — works like FileCache but in memory
$cache = new ArrayCache();
$cache->set('key', 'value', ttl: 300);
$cache->get('key');             // 'value'
$cache->has('key');             // true
$cache->delete('key');
$cache->clear();                // wipes everything

// NullLogger — accepts all log calls, writes nothing
$logger = new NullLogger();
$logger->info('This goes nowhere');
$logger->error('This also goes nowhere');

Step 3: Testing Validation Rules

Melodic's validation uses PHP attributes on DTO properties. The Validator reads these attributes and runs their validate() methods.

First, create a DTO with validation rules. Create src/DTOs/CreateTaskDTO.php:

<?php

declare(strict_types=1);

namespace App\DTOs;

use Melodic\Validation\Rules\MinLength;
use Melodic\Validation\Rules\Required;

class CreateTaskDTO
{
    #[Required]
    #[MinLength(3)]
    public string $title;

    public string $description = '';
}

Now test the validation. Create tests/Validation/CreateTaskDTOTest.php:

<?php

declare(strict_types=1);

namespace Tests\Validation;

use App\DTOs\CreateTaskDTO;
use Melodic\Validation\Validator;
use PHPUnit\Framework\TestCase;

class CreateTaskDTOTest extends TestCase
{
    private Validator $validator;

    protected function setUp(): void
    {
        $this->validator = new Validator();
    }

    public function testValidDataPasses(): void
    {
        $dto = new CreateTaskDTO();
        $dto->title = 'Buy groceries';
        $dto->description = 'Milk, eggs, bread';

        $result = $this->validator->validate($dto);

        $this->assertTrue($result->isValid);
        $this->assertEmpty($result->errors);
    }

    public function testEmptyTitleFails(): void
    {
        $dto = new CreateTaskDTO();
        $dto->title = '';

        $result = $this->validator->validate($dto);

        $this->assertFalse($result->isValid);
        $this->assertArrayHasKey('title', $result->errors);
    }

    public function testShortTitleFails(): void
    {
        $dto = new CreateTaskDTO();
        $dto->title = 'Hi';

        $result = $this->validator->validate($dto);

        $this->assertFalse($result->isValid);
        $this->assertArrayHasKey('title', $result->errors);

        // Check that the MinLength error message is present
        $titleErrors = $result->errors['title'];
        $this->assertNotEmpty(array_filter($titleErrors, fn($e) => str_contains($e, 'at least 3')));
    }

    public function testValidateArrayMethod(): void
    {
        $data = ['title' => 'Valid title', 'description' => 'Some text'];

        $result = $this->validator->validateArray($data, CreateTaskDTO::class);

        $this->assertTrue($result->isValid);
    }

    public function testValidateArrayWithMissingRequiredField(): void
    {
        $data = ['description' => 'No title provided'];

        $result = $this->validator->validateArray($data, CreateTaskDTO::class);

        $this->assertFalse($result->isValid);
        $this->assertArrayHasKey('title', $result->errors);
    }
}

Tip: The Validator also provides validateArray() which validates raw associative arrays against a DTO class. This is useful when you want to validate request data before constructing the DTO.

Step 4: Testing Services with a Mock DbContext

Services depend on DbContextInterface. Create a mock implementation that returns predictable data without any database.

Create tests/Mocks/MockDbContext.php:

<?php

declare(strict_types=1);

namespace Tests\Mocks;

use Melodic\Data\DbContextInterface;

class MockDbContext implements DbContextInterface
{
    private array $queryResults = [];
    private array $queryFirstResults = [];
    private int $commandResult = 0;
    private mixed $scalarResult = null;
    private int $lastInsertId = 0;

    /** @var array<array{sql: string, params: array}> */
    public array $executedCommands = [];

    /** @var array<array{sql: string, params: array}> */
    public array $executedQueries = [];

    public function setQueryResult(array $results): void
    {
        $this->queryResults = $results;
    }

    public function setQueryFirstResult(?object $result): void
    {
        $this->queryFirstResults[] = $result;
    }

    public function setCommandResult(int $result): void
    {
        $this->commandResult = $result;
    }

    public function setLastInsertId(int $id): void
    {
        $this->lastInsertId = $id;
    }

    public function query(string $class, string $sql, array $params = []): array
    {
        $this->executedQueries[] = ['sql' => $sql, 'params' => $params];

        return $this->queryResults;
    }

    public function queryFirst(string $class, string $sql, array $params = []): ?object
    {
        $this->executedQueries[] = ['sql' => $sql, 'params' => $params];

        return array_shift($this->queryFirstResults);
    }

    public function command(string $sql, array $params = []): int
    {
        $this->executedCommands[] = ['sql' => $sql, 'params' => $params];

        return $this->commandResult;
    }

    public function scalar(string $sql, array $params = []): mixed
    {
        return $this->scalarResult;
    }

    public function transaction(callable $callback): mixed
    {
        return $callback($this);
    }

    public function lastInsertId(): int
    {
        return $this->lastInsertId;
    }
}

Now test a service. Create tests/Services/TaskServiceTest.php:

<?php

declare(strict_types=1);

namespace Tests\Services;

use App\Models\Task;
use App\Services\TaskService;
use PHPUnit\Framework\TestCase;
use Tests\Mocks\MockDbContext;

class TaskServiceTest extends TestCase
{
    private MockDbContext $dbContext;
    private TaskService $service;

    protected function setUp(): void
    {
        $this->dbContext = new MockDbContext();
        $this->service = new TaskService($this->dbContext);
    }

    public function testGetAllReturnsTasks(): void
    {
        $task1 = Task::fromArray(['id' => 1, 'title' => 'Task 1', 'description' => '', 'completed' => false, 'created_at' => '2026-01-01']);
        $task2 = Task::fromArray(['id' => 2, 'title' => 'Task 2', 'description' => '', 'completed' => true, 'created_at' => '2026-01-02']);

        $this->dbContext->setQueryResult([$task1, $task2]);

        $tasks = $this->service->getAll();

        $this->assertCount(2, $tasks);
        $this->assertSame('Task 1', $tasks[0]->title);
        $this->assertSame('Task 2', $tasks[1]->title);
    }

    public function testGetByIdReturnsTask(): void
    {
        $task = Task::fromArray(['id' => 1, 'title' => 'Found', 'description' => 'desc', 'completed' => false, 'created_at' => '2026-01-01']);

        $this->dbContext->setQueryFirstResult($task);

        $result = $this->service->getById(1);

        $this->assertNotNull($result);
        $this->assertSame('Found', $result->title);
    }

    public function testGetByIdReturnsNullWhenNotFound(): void
    {
        $this->dbContext->setQueryFirstResult(null);

        $result = $this->service->getById(999);

        $this->assertNull($result);
    }

    public function testCreateExecutesInsertCommand(): void
    {
        $this->dbContext->setCommandResult(1);
        $this->dbContext->setLastInsertId(42);

        $id = $this->service->create('New task', 'Some description');

        $this->assertSame(42, $id);
        $this->assertCount(1, $this->dbContext->executedCommands);
        $this->assertStringContainsString('INSERT', $this->dbContext->executedCommands[0]['sql']);
    }

    public function testDeleteExecutesDeleteCommand(): void
    {
        $this->dbContext->setCommandResult(1);

        $affected = $this->service->delete(1);

        $this->assertSame(1, $affected);
        $this->assertCount(1, $this->dbContext->executedCommands);
        $this->assertStringContainsString('DELETE', $this->dbContext->executedCommands[0]['sql']);
    }
}

No database needed: The MockDbContext lets you test service logic in complete isolation. You control exactly what data it returns and can inspect what SQL was executed.

Step 5: Testing Controllers

Controllers can be tested by constructing a Request object, calling setRequest(), and invoking the action method directly.

Create tests/Controllers/TaskApiControllerTest.php:

<?php

declare(strict_types=1);

namespace Tests\Controllers;

use App\Controllers\TaskApiController;
use App\Models\Task;
use App\Services\TaskService;
use Melodic\Http\Request;
use PHPUnit\Framework\TestCase;
use Tests\Mocks\MockDbContext;

class TaskApiControllerTest extends TestCase
{
    private MockDbContext $dbContext;
    private TaskService $service;
    private TaskApiController $controller;

    protected function setUp(): void
    {
        $this->dbContext = new MockDbContext();
        $this->service = new TaskService($this->dbContext);
        $this->controller = new TaskApiController($this->service);
    }

    public function testIndexReturnsJsonArray(): void
    {
        $task = Task::fromArray([
            'id' => 1,
            'title' => 'Test Task',
            'description' => 'Description',
            'completed' => false,
            'created_at' => '2026-01-01 12:00:00',
        ]);

        $this->dbContext->setQueryResult([$task]);

        $request = new Request(
            server: ['REQUEST_METHOD' => 'GET', 'REQUEST_URI' => '/api/tasks'],
        );
        $this->controller->setRequest($request);

        $response = $this->controller->index();

        $this->assertSame(200, $response->getStatusCode());

        $body = json_decode($response->getBody(), true);
        $this->assertCount(1, $body);
        $this->assertSame('Test Task', $body[0]['title']);
    }

    public function testShowReturnsTask(): void
    {
        $task = Task::fromArray([
            'id' => 5,
            'title' => 'Specific Task',
            'description' => 'Details',
            'completed' => true,
            'created_at' => '2026-02-01 09:00:00',
        ]);

        $this->dbContext->setQueryFirstResult($task);

        $request = new Request(
            server: ['REQUEST_METHOD' => 'GET', 'REQUEST_URI' => '/api/tasks/5'],
        );
        $this->controller->setRequest($request);

        $response = $this->controller->show(5);

        $this->assertSame(200, $response->getStatusCode());

        $body = json_decode($response->getBody(), true);
        $this->assertSame('Specific Task', $body['title']);
    }

    public function testShowReturns404WhenNotFound(): void
    {
        $this->dbContext->setQueryFirstResult(null);

        $request = new Request(
            server: ['REQUEST_METHOD' => 'GET', 'REQUEST_URI' => '/api/tasks/999'],
        );
        $this->controller->setRequest($request);

        $response = $this->controller->show(999);

        $this->assertSame(404, $response->getStatusCode());
    }

    public function testStoreCreatesTask(): void
    {
        $this->dbContext->setCommandResult(1);
        $this->dbContext->setLastInsertId(10);

        $createdTask = Task::fromArray([
            'id' => 10,
            'title' => 'New Task',
            'description' => 'Created via test',
            'completed' => false,
            'created_at' => '2026-02-17 12:00:00',
        ]);
        $this->dbContext->setQueryFirstResult($createdTask);

        $request = new Request(
            server: ['REQUEST_METHOD' => 'POST', 'REQUEST_URI' => '/api/tasks'],
            body: ['title' => 'New Task', 'description' => 'Created via test'],
        );
        $this->controller->setRequest($request);

        $response = $this->controller->store();

        $this->assertSame(201, $response->getStatusCode());
    }

    public function testStoreReturnsBadRequestWithoutTitle(): void
    {
        $request = new Request(
            server: ['REQUEST_METHOD' => 'POST', 'REQUEST_URI' => '/api/tasks'],
            body: ['description' => 'No title'],
        );
        $this->controller->setRequest($request);

        $response = $this->controller->store();

        $this->assertSame(400, $response->getStatusCode());
    }
}

Key pattern: Construct a Request with the required server, body, and headers parameters. Call setRequest() on the controller, then invoke the action method directly and assert on the returned Response.

Step 6: Testing Middleware with Mini-Pipelines

Test middleware by building small pipelines or calling process() directly. You control the request and the next handler.

Testing a single middleware

<?php

declare(strict_types=1);

namespace Tests\Middleware;

use Melodic\Http\Middleware\JsonBodyParserMiddleware;
use Melodic\Http\Middleware\RequestHandlerInterface;
use Melodic\Http\Request;
use Melodic\Http\Response;
use PHPUnit\Framework\TestCase;

class JsonBodyParserMiddlewareTest extends TestCase
{
    public function testParsesJsonBody(): void
    {
        $middleware = new JsonBodyParserMiddleware();

        $jsonBody = json_encode(['name' => 'Alice', 'age' => 30]);

        $request = new Request(
            server: ['REQUEST_METHOD' => 'POST', 'REQUEST_URI' => '/api/users'],
            headers: ['Content-Type' => 'application/json'],
            rawBody: $jsonBody,
        );

        // The handler captures the modified request
        $capturedRequest = null;

        $handler = new class($capturedRequest) implements RequestHandlerInterface {
            public function __construct(private ?Request &$captured) {}

            public function handle(Request $request): Response
            {
                $this->captured = $request;

                return new Response(statusCode: 200, body: 'OK');
            }
        };

        $response = $middleware->process($request, $handler);

        $this->assertSame(200, $response->getStatusCode());
        $this->assertNotNull($capturedRequest);
        $this->assertSame('Alice', $capturedRequest->body('name'));
    }
}

Testing a multi-middleware pipeline

Use the Pipeline class to chain multiple middleware together, just like the framework does:

<?php

declare(strict_types=1);

namespace Tests\Middleware;

use App\Middleware\RateLimitMiddleware;
use Melodic\Http\Middleware\JsonBodyParserMiddleware;
use Melodic\Http\Middleware\Pipeline;
use Melodic\Http\Middleware\RequestHandlerInterface;
use Melodic\Http\Request;
use Melodic\Http\Response;
use PHPUnit\Framework\TestCase;

class PipelineTest extends TestCase
{
    public function testMiddlewareExecutesInOrder(): void
    {
        $log = [];

        // Create a final handler
        $finalHandler = new class($log) implements RequestHandlerInterface {
            public function __construct(private array &$log) {}

            public function handle(Request $request): Response
            {
                $this->log[] = 'handler';

                return new Response(statusCode: 200, body: 'Done');
            }
        };

        // Build the pipeline
        $pipeline = new Pipeline($finalHandler);
        $pipeline->pipe(new JsonBodyParserMiddleware());
        $pipeline->pipe(new RateLimitMiddleware(maxRequests: 100, windowSeconds: 60));

        $request = new Request(
            server: ['REQUEST_METHOD' => 'GET', 'REQUEST_URI' => '/', 'REMOTE_ADDR' => '10.0.0.1'],
        );

        $response = $pipeline->handle($request);

        $this->assertSame(200, $response->getStatusCode());
    }

    public function testPipelineShortCircuitsOnRateLimit(): void
    {
        $finalHandler = new class implements RequestHandlerInterface {
            public int $callCount = 0;

            public function handle(Request $request): Response
            {
                $this->callCount++;

                return new Response(statusCode: 200, body: 'OK');
            }
        };

        $pipeline = new Pipeline($finalHandler);
        $pipeline->pipe(new RateLimitMiddleware(maxRequests: 2, windowSeconds: 60));

        $request = new Request(
            server: ['REQUEST_METHOD' => 'GET', 'REQUEST_URI' => '/', 'REMOTE_ADDR' => '10.0.0.2'],
        );

        // First 2 requests pass through
        $pipeline->handle($request);
        $pipeline->handle($request);

        // 3rd request is blocked by rate limiter
        $response = $pipeline->handle($request);

        $this->assertSame(429, $response->getStatusCode());
        // The final handler was only called twice
        $this->assertSame(2, $finalHandler->callCount);
    }
}

Testing Patterns Summary

LayerTest StrategyKey Tools
ValidationCreate DTO, call Validator->validate()Validator, DTO classes
ServicesInject MockDbContext, call service methodsMockDbContext, Model::fromArray()
ControllersConstruct Request, call action methodsRequest, setRequest()
MiddlewareCall process() with request and mock handlerRequest, anonymous handler classes
PipelinesChain middleware with PipelinePipeline, Request
SessionsUse ArraySession in place of NativeSessionArraySession
CacheUse ArrayCache in place of FileCacheArrayCache
LoggingUse NullLogger to suppress outputNullLogger

Philosophy: Melodic's architecture — constructor injection, explicit interfaces, and no global state — makes every component testable in isolation. You never need to boot the full application to test a single service or controller.

Project Structure

After completing this tutorial, your test directory should look like this:

tests/
├── Mocks/
│   └── MockDbContext.php
├── Validation/
│   └── CreateTaskDTOTest.php
├── Services/
│   └── TaskServiceTest.php
├── Controllers/
│   └── TaskApiControllerTest.php
└── Middleware/
    ├── JsonBodyParserMiddlewareTest.php
    └── PipelineTest.php

Run all tests:

vendor/bin/phpunit

# Or run a specific test class:
vendor/bin/phpunit tests/Services/TaskServiceTest.php

# Or a specific test method:
vendor/bin/phpunit --filter testGetAllReturnsTasks

Next steps: Add test coverage reporting with --coverage-html, integrate tests into your CI pipeline, or explore the Validation documentation for more attribute rules to test.