Recipe: Testing Your OData Service
This recipe shows how to write tests for your OData service at different levels using Pest and Orchestra Testbench.
Test setup
The library uses Orchestra Testbench for testing with a real Laravel application instance and SQLite in-memory database.
Base test case
php
<?php
namespace Tests;
use App\OData\ServiceRegistry;
use LaravelUi5\OData\ODataServiceProvider;
use LaravelUi5\OData\Service\Contracts\ODataServiceRegistryInterface;
abstract class ODataTestCase extends \Orchestra\Testbench\TestCase
{
protected function defineDatabaseMigrations(): void
{
$this->loadMigrationsFrom(database_path('migrations'));
}
protected function getPackageProviders($app): array
{
return [ODataServiceProvider::class];
}
protected function setUp(): void
{
parent::setUp();
// Bind your service registry
$this->app->singleton(
ODataServiceRegistryInterface::class,
ServiceRegistry::class,
);
}
}Tier 1: Unit tests (Edm layer)
Test your entity type definitions without Laravel:
php
<?php
use App\OData\ProductService;
use LaravelUi5\OData\Edm\Contracts\Container\PrimitiveTypeEnum;
it('defines a Product entity type with id key', function () {
// Build the Edm manually to test structure
$service = new ProductService();
$schema = $service->schema();
$edmx = $schema->getEdmx();
$productType = $edmx->getSchemas()['Shop.Data']->getEntityType('Product');
expect($productType)->not->toBeNull()
->and($productType->getKey())->toHaveCount(1)
->and($productType->getKey()[0]->getName())->toBe('id');
});Tier 2: Resolver tests (SQLite in-memory)
Test that resolvers correctly translate query plans:
php
<?php
use LaravelUi5\OData\Driver\Sql\SqlEntitySetResolver;
use LaravelUi5\OData\Protocol\Planning\EntitySetQueryPlan;
// ... other imports
uses(Tests\ODataTestCase::class)
->beforeEach(function () {
// Seed test data
DB::table('products')->insert([
['name' => 'Widget', 'price' => 9.99, 'active' => true],
['name' => 'Gadget', 'price' => 24.50, 'active' => false],
]);
});
it('resolves all products from the table', function () {
$resolver = new SqlEntitySetResolver('products');
$plan = buildPlan(); // helper that creates an EntitySetQueryPlan
$rows = iterator_to_array($resolver->resolve($plan), false);
expect($rows)->toHaveCount(2);
});Tier 3: HTTP round-trip tests
Test the full request/response cycle:
php
<?php
uses(Tests\ODataTestCase::class)
->beforeEach(function () {
App\Models\Product::create(['name' => 'Widget', 'price' => 9.99]);
App\Models\Product::create(['name' => 'Gadget', 'price' => 24.50]);
});
it('returns all products as JSON', function () {
$response = $this->get('/odata/Products');
$response->assertStatus(200);
$json = $response->json();
expect($json['value'])->toHaveCount(2)
->and($json['@odata.context'])->toContain('$metadata#Products');
});
it('filters products by price', function () {
$response = $this->get('/odata/Products?$filter=price gt 10');
$response->assertStatus(200);
expect($response->json('value'))->toHaveCount(1)
->and($response->json('value.0.name'))->toBe('Gadget');
});
it('returns metadata as XML', function () {
$response = $this->get('/odata/$metadata');
$response->assertStatus(200);
$response->assertHeader('content-type', 'application/xml');
});
it('returns 404 for unknown entity', function () {
$response = $this->get('/odata/Products(999)');
$response->assertStatus(404);
expect($response->json('error.code'))->toBe('not_found');
});
it('returns service document', function () {
$response = $this->get('/odata');
$response->assertStatus(200);
$sets = collect($response->json('value'));
expect($sets->pluck('name'))->toContain('Products');
});
it('supports $select', function () {
$response = $this->get('/odata/Products?$select=name');
$response->assertStatus(200);
$first = $response->json('value.0');
expect($first)->toHaveKey('name')
->and($first)->not->toHaveKey('price');
});
it('supports $count', function () {
$response = $this->get('/odata/Products?$count=true&$top=1');
$response->assertStatus(200);
expect($response->json('@odata.count'))->toBe(2)
->and($response->json('value'))->toHaveCount(1);
});Tips
- Use
$this->withoutExceptionHandling()in setUp to get clear stack traces - Use SQLite in-memory (
:memory:) for fast, isolated tests - Seed data in
beforeEachso each test starts with a known state - Test edge cases: empty results, null values, special characters in $filter
- Test error responses: invalid filter syntax, unknown entity sets, missing keys