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 Double | Replaces | Behavior |
|---|---|---|
ArraySession | NativeSession | Stores session data in a PHP array. No PHP session started. |
ArrayCache | FileCache | Stores cache entries in a PHP array with TTL support. |
NullLogger | FileLogger | Silently 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
| Layer | Test Strategy | Key Tools |
|---|---|---|
| Validation | Create DTO, call Validator->validate() | Validator, DTO classes |
| Services | Inject MockDbContext, call service methods | MockDbContext, Model::fromArray() |
| Controllers | Construct Request, call action methods | Request, setRequest() |
| Middleware | Call process() with request and mock handler | Request, anonymous handler classes |
| Pipelines | Chain middleware with Pipeline | Pipeline, Request |
| Sessions | Use ArraySession in place of NativeSession | ArraySession |
| Cache | Use ArrayCache in place of FileCache | ArrayCache |
| Logging | Use NullLogger to suppress output | NullLogger |
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.