Recipe: Testing Your OData Service
requires laravelui5/odataThis 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\EdmPrimitiveType;
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 Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use LaravelUi5\OData\Driver\Sql\SqlEntitySetResolver;
use LaravelUi5\OData\Http\CustomQueryOptions;
use LaravelUi5\OData\Protocol\Planning\EntitySetQueryPlan;
use LaravelUi5\OData\Service\Contracts\EntitySetSourceInterface;
// ... 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(new class implements EntitySetSourceInterface {
public function query(CustomQueryOptions $options): Builder
{
return DB::table('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;charset=utf-8');
});
it('returns 404 for unknown entity', function () {
$response = $this->get('/odata/Products(999)');
$response->assertStatus(404);
expect($response->json('error.code'))->toBe('entity_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