Skip to content

Recipe: Testing Your OData Service ​

requires laravelui5/odata

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\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 beforeEach so 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