Testing Guide
This guide covers all aspects of testing your REST API, from unit tests to integration tests, using FakeApiRequester for in-process API testing.
Overview
The reference architecture provides a complete testing framework that allows you to test your API without running a web server.
Testing Approach
- Integration Tests: Test complete API endpoints using
FakeApiRequester - Unit Tests: Test services and business logic in isolation
- Schema Validation: Automatically validate responses against OpenAPI schema
- In-Process: No web server required, tests run directly in PHPUnit
Key Components
| Component | Purpose | Location |
|---|---|---|
FakeApiRequester | In-process API testing | src/Util/FakeApiRequester.php |
BaseApiTestCase | Base class for API tests | tests/Rest/BaseApiTestCase.php |
Credentials | Test user credentials | tests/Rest/Credentials.php |
Test Structure
Directory Layout
tests/
└── Rest/
├── BaseApiTestCase.php # Base test case with schema + DB reset
├── Credentials.php # Helper for authenticating test users
├── DummyTest.php # Repository pattern example CRUD tests
├── DummyHexTest.php # Hex/UUID example
├── LoginTest.php # Authentication flow
└── ... (add your own files here)
Add additional directories (e.g., tests/Service) as needed—PHPUnit's configuration already looks at the whole tests/ tree.
Running Tests
# Create or reset the testing database
APP_ENV=test composer migrate -- reset --yes
# Run all tests using the composer script
APP_ENV=test composer run test
# Run a specific test file
APP_ENV=test ./vendor/bin/phpunit tests/Rest/DummyTest.php
# Run a single test method
APP_ENV=test ./vendor/bin/phpunit --filter testFullCrud tests/Rest/DummyTest.php
# Generate coverage (optional)
APP_ENV=test ./vendor/bin/phpunit --coverage-html coverage/
tests/Rest/BaseApiTestCase.php already calls Migration::reset() the first time a test runs, but pre-resetting with the command above avoids surprises if you run the suite outside PHPUnit (e.g., invoking migrations manually).
FakeApiRequester
The FakeApiRequester class enables in-process API testing without a web server.
Location: src/Util/FakeApiRequester.php
BaseApiTestCase extends PHPUnit\Framework\TestCase and mixes in the OpenApiValidation trait, so every call to sendRequest() validates the result against public/docs/openapi.json. All routing happens in-memory via FakeApiRequester, so you don't need a running web server—only a configured database.
How It Works
- Creates PSR-7 Request: Builds HTTP request object
- Routes to Controller: Uses OpenAPI routing
- Executes Middleware: Applies JWT authentication, validation
- Returns PSR-7 Response: Returns HTTP response object
- Validates Schema: Checks response against OpenAPI schema
Basic Usage
use RestReferenceArchitecture\Util\FakeApiRequester;
$request = (new FakeApiRequester())
->withPsr7Request($this->getPsr7Request())
->withMethod('GET')
->withPath('/dummy/1')
->withRequestHeader(['Authorization' => 'Bearer ' . $token])
->expectStatus(200);
$response = $this->sendRequest($request);
$data = json_decode($response->getBody()->getContents(), true);
FakeApiRequester Methods
// HTTP Method & Path
->withMethod('GET') // GET, POST, PUT, DELETE, PATCH
->withPath('/api/products') // API endpoint path
// Request Body
->withRequestBody(json_encode(['name' => 'Product']))
// Headers
->withRequestHeader(['Authorization' => 'Bearer token'])
->withRequestHeader(['Content-Type' => 'application/json'])
// Query Parameters
->withQuery(['page' => 2, 'size' => 50])
// Expected Response
->expectStatus(200) // Assert HTTP status code
->expectJsonContains(['name' => 'Product'])
Sending Body Data
public function testPingWithBody()
{
$request = (new FakeApiRequester())
->withPsr7Request($this->getPsr7Request())
->withMethod('POST')
->withPath('/sample/ping')
->withRequestBody(json_encode([
'name' => 'John Doe'
]));
$this->sendRequest($request);
}
Sending Query Parameters
public function testPingWithQuery()
{
$request = (new FakeApiRequester())
->withPsr7Request($this->getPsr7Request())
->withMethod('GET')
->withPath('/sample/ping')
->withQuery(['name' => 'John Doe']);
$this->sendRequest($request);
}
Expecting a Specific Status Code
public function testPingNotFound()
{
$request = (new FakeApiRequester())
->withPsr7Request($this->getPsr7Request())
->withMethod('GET')
->withPath('/sample/ping')
->expectStatus(404);
$this->sendRequest($request);
}
Expecting a Specific Response Body
public function testPingResponse()
{
$request = (new FakeApiRequester())
->withPsr7Request($this->getPsr7Request())
->withMethod('GET')
->withPath('/sample/ping')
->expectJsonContains(['result' => 'pong']);
$this->sendRequest($request);
}
Writing API Tests
BaseApiTestCase
All API tests should extend BaseApiTestCase:
Location: tests/Rest/BaseApiTestCase.php
namespace Test\Rest;
use ByJG\ApiTools\Base\Schema;
use ByJG\ApiTools\OpenApiValidation;
use ByJG\Config\Config;
use ByJG\DbMigration\Database\MySqlDatabase;
use ByJG\DbMigration\Migration;
use ByJG\Util\Uri;
use ByJG\WebRequest\Psr7\Request;
use Exception;
use PHPUnit\Framework\TestCase;
class BaseApiTestCase extends TestCase
{
use OpenApiValidation;
protected static bool $databaseReset = false;
protected string $filePath = __DIR__ . '/../../public/docs/openapi.json';
protected function setUp(): void
{
$this->setSchema(Schema::getInstance(file_get_contents($this->filePath)));
$this->resetDb();
}
protected function tearDown(): void
{
$this->setSchema(null);
}
public function getPsr7Request(): Request
{
$uri = Uri::getInstanceFromString()
->withScheme(Config::get('API_SCHEMA'))
->withHost(Config::get('API_SERVER'));
return Request::getInstance($uri);
}
protected function resetDb(): void
{
if (!self::$databaseReset) {
if (Config::definition()->getCurrentEnvironment() !== 'test') {
throw new Exception('This test can only be executed in test environment');
}
Migration::registerDatabase(MySqlDatabase::class);
$migration = new Migration(new Uri(Config::get('DBDRIVER_CONNECTION')), __DIR__ . '/../../db');
$migration->prepareEnvironment();
$migration->reset();
self::$databaseReset = true;
}
}
}
What BaseApiTestCase Provides
// PSR-7 Request Factory
$psr7Request = $this->getPsr7Request();
// Send a FakeApiRequester and validate against OpenAPI automatically
$response = $this->sendRequest($request);
// Reset the database once per test process
$this->resetDb();
Basic Test Example
<?php
namespace Test\Rest;
use RestReferenceArchitecture\Util\FakeApiRequester;
class SampleTest extends BaseApiTestCase
{
public function testPing(): void
{
$request = (new FakeApiRequester())
->withPsr7Request($this->getPsr7Request())
->withMethod('GET')
->withPath('/sample/ping')
->expectStatus(200);
$this->sendRequest($request);
}
}
Testing Authentication
Test User Credentials
Location: tests/Rest/Credentials.php
use Test\Rest\Credentials;
// Admin user
$adminCreds = Credentials::getAdminUser();
// Returns: ['username' => 'admin', 'password' => 'admin']
// Regular user
$userCreds = Credentials::getRegularUser();
// Returns: ['username' => 'user', 'password' => 'user']
// Login request
$loginRequest = Credentials::requestLogin(Credentials::getAdminUser());
$response = $this->sendRequest($loginRequest);
$data = json_decode($response->getBody()->getContents(), true);
$token = $data['token'];
You can override the default credentials via environment variables:
export [email protected]
export TEST_ADMIN_PASSWORD='!P4ssw0rdstr!'
export [email protected]
export TEST_REGULAR_PASSWORD='!P4ssw0rdstr!'
Testing Unauthorized Access
public function testGetUnauthorized()
{
$this->expectException(Error401Exception::class);
$this->expectExceptionMessage('Absent authorization token');
$request = new FakeApiRequester();
$request
->withPsr7Request($this->getPsr7Request())
->withMethod('GET')
->withPath('/dummy/1')
->assertResponseCode(401);
$this->sendRequest($request);
}
Testing Invalid Credentials
public function testLoginInvalidCredentials()
{
$this->expectException(Error401Exception::class);
$this->expectExceptionMessage('Username or password is invalid');
$this->sendRequest(Credentials::requestLogin([
'username' => 'invalid',
'password' => 'wrong'
]));
}
Testing Token Expiration
public function testExpiredToken()
{
$expiredToken = JwtWrapper::createToken([
'userid' => 1,
'name' => 'Test User',
'role' => 'user'
], -3600); // Expired 1 hour ago
$this->expectException(Error401Exception::class);
$request = new FakeApiRequester();
$request
->withPsr7Request($this->getPsr7Request())
->withMethod('GET')
->withPath('/products')
->withRequestHeader(['Authorization' => "Bearer {$expiredToken}"])
->assertResponseCode(401);
$this->sendRequest($request);
}
Testing Authorization
Testing Role Requirements
public function testInsufficientPrivileges()
{
$this->expectException(Error403Exception::class);
$this->expectExceptionMessage('Insufficient privileges');
$loginResponse = $this->sendRequest(
Credentials::requestLogin(Credentials::getRegularUser())
);
$data = json_decode($loginResponse->getBody()->getContents(), true);
$token = $data['token'];
$request = new FakeApiRequester();
$request
->withPsr7Request($this->getPsr7Request())
->withMethod('DELETE')
->withPath('/products/1')
->withRequestHeader(['Authorization' => "Bearer {$token}"])
->assertResponseCode(403);
$this->sendRequest($request);
}
Testing CRUD Operations
Complete CRUD Test
Location: tests/Rest/DummyTest.php:142
public function testFullCrud()
{
// Login
$loginResponse = $this->sendRequest(
Credentials::requestLogin(Credentials::getAdminUser())
);
$loginData = json_decode($loginResponse->getBody()->getContents(), true);
$token = $loginData['token'];
// CREATE
$createRequest = new FakeApiRequester();
$createRequest
->withPsr7Request($this->getPsr7Request())
->withMethod('POST')
->withPath('/dummy')
->withRequestBody(json_encode(['field' => 'test value']))
->withRequestHeader(['Authorization' => "Bearer {$token}"])
->assertResponseCode(200);
$createResponse = $this->sendRequest($createRequest);
$created = json_decode($createResponse->getBody()->getContents(), true);
$id = $created['id'];
// READ
$getRequest = new FakeApiRequester();
$getRequest
->withPsr7Request($this->getPsr7Request())
->withMethod('GET')
->withPath("/dummy/{$id}")
->withRequestHeader(['Authorization' => "Bearer {$token}"])
->assertResponseCode(200);
$getResponse = $this->sendRequest($getRequest);
$retrieved = json_decode($getResponse->getBody()->getContents(), true);
$this->assertEquals($id, $retrieved['id']);
$this->assertEquals('test value', $retrieved['field']);
// UPDATE
$retrieved['field'] = 'updated value';
$updateRequest = new FakeApiRequester();
$updateRequest
->withPsr7Request($this->getPsr7Request())
->withMethod('PUT')
->withPath('/dummy')
->withRequestBody(json_encode($retrieved))
->withRequestHeader(['Authorization' => "Bearer {$token}"])
->assertResponseCode(200);
$this->sendRequest($updateRequest);
// Verify update
$verifyRequest = new FakeApiRequester();
$verifyRequest
->withPsr7Request($this->getPsr7Request())
->withMethod('GET')
->withPath("/dummy/{$id}")
->withRequestHeader(['Authorization' => "Bearer {$token}"])
->assertResponseCode(200);
$verifyResponse = $this->sendRequest($verifyRequest);
$verified = json_decode($verifyResponse->getBody()->getContents(), true);
$this->assertEquals('updated value', $verified['field']);
}
Testing List & Pagination
public function testList()
{
$loginResponse = $this->sendRequest(
Credentials::requestLogin(Credentials::getRegularUser())
);
$data = json_decode($loginResponse->getBody()->getContents(), true);
$request = new FakeApiRequester();
$request
->withPsr7Request($this->getPsr7Request())
->withMethod('GET')
->withPath('/dummy?page=0&size=20')
->withRequestHeader(['Authorization' => "Bearer {$data['token']}"])
->assertResponseCode(200);
$response = $this->sendRequest($request);
$list = json_decode($response->getBody()->getContents(), true);
$this->assertIsArray($list);
if (count($list) > 0) {
$this->assertArrayHasKey('id', $list[0]);
}
}
Unit Testing Services
Service Test Example
<?php
namespace Test\Unit\Service;
use PHPUnit\Framework\TestCase;
use RestReferenceArchitecture\Service\ProductService;
use RestReferenceArchitecture\Repository\ProductRepository;
use RestReferenceArchitecture\Model\Product;
class ProductServiceTest extends TestCase
{
protected ProductService $service;
protected ProductRepository $repository;
protected function setUp(): void
{
$this->repository = $this->createMock(ProductRepository::class);
$this->service = new ProductService($this->repository);
}
public function testGetOrFail()
{
$product = new Product();
$product->setId(1);
$product->setName('Test Product');
$this->repository
->expects($this->once())
->method('get')
->with(1)
->willReturn($product);
$result = $this->service->getOrFail(1);
$this->assertEquals(1, $result->getId());
$this->assertEquals('Test Product', $result->getName());
}
public function testGetOrFailThrowsException()
{
$this->expectException(Error404Exception::class);
$this->repository
->expects($this->once())
->method('get')
->with(999)
->willReturn(null);
$this->service->getOrFail(999);
}
}
Test Data Management
Using Helper Methods
class ProductTest extends BaseApiTestCase
{
protected function getSampleData(bool $array = false)
{
$sample = [
'name' => 'Test Product',
'price' => 99.99,
'stock' => 100
];
if ($array) {
return $sample;
}
ObjectCopy::copy($sample, $model = new Product());
return $model;
}
}
Seeding Test Data
protected function seedTestData()
{
$productService = Config::get(ProductService::class);
for ($i = 1; $i <= 10; $i++) {
$productService->create([
'name' => "Product {$i}",
'price' => $i * 10.00,
'stock' => $i * 5
]);
}
}
Best Practices
1. Test One Thing Per Test
// Good - Single responsibility
public function testCreateProduct() { /* ... */ }
public function testGetProduct() { /* ... */ }
public function testUpdateProduct() { /* ... */ }
2. Use Descriptive Test Names
// Good - Clear intent
public function testCreateProductWithNegativePriceThrowsException() { }
public function testUserCannotDeleteOtherUsersProducts() { }
3. Arrange-Act-Assert Pattern
public function testCreateProduct()
{
// ARRANGE
$loginResponse = $this->sendRequest(
Credentials::requestLogin(Credentials::getAdminUser())
);
$token = json_decode($loginResponse->getBody()->getContents(), true)['token'];
// ACT
$request = new FakeApiRequester();
$request
->withPsr7Request($this->getPsr7Request())
->withMethod('POST')
->withPath('/products')
->withRequestBody(json_encode($this->getSampleData(true)))
->withRequestHeader(['Authorization' => "Bearer {$token}"])
->assertResponseCode(200);
$response = $this->sendRequest($request);
// ASSERT
$data = json_decode($response->getBody()->getContents(), true);
$this->assertArrayHasKey('id', $data);
$this->assertGreaterThan(0, $data['id']);
}
4. Use Data Providers for Similar Tests
/**
* @dataProvider invalidProductDataProvider
*/
public function testCreateWithInvalidData($payload, $expectedMessage)
{
$this->expectException(Error400Exception::class);
$this->expectExceptionMessage($expectedMessage);
$productService = Config::get(ProductService::class);
$productService->create($payload);
}
public function invalidProductDataProvider(): array
{
return [
'missing name' => [
['price' => 99.99],
'Product name is required'
],
'negative price' => [
['name' => 'Product', 'price' => -10],
'Price cannot be negative'
],
];
}
5. Test Both Success and Failure Cases
public function testCreateProductSuccess() { /* ... */ }
public function testCreateProductWithoutName() { /* ... */ }
public function testCreateProductWithInvalidPrice() { /* ... */ }
public function testCreateProductWithoutAuthentication() { /* ... */ }
public function testCreateProductWithoutAuthorization() { /* ... */ }
Related Documentation
- REST Controllers
- Attributes System - Testing validation attributes
- Error Handling - Testing error responses
- Service Layer - Unit testing services
- JWT Authentication Advanced - Testing authentication