
Oro Functional Testing
- 2 installs
- 2 repo stars
- Updated July 22, 2026
- netresearch/orocommerce-skill
Writes PHPUnit functional tests for OroCommerce 6.1 with WebTestCase: controllers, REST API, commands, datagrids, ACL flows, fixtures, and transactional isolation annotations.
About
Covers OroCommerce 6.1 functional testing on top of WebTestCase, including initClient, fixture loading, isolation annotations, and HTML/JSON/grid assertions against a real database. A developer uses it when testing controllers, APIs, commands, or ACL rules in an Oro bundle.
- Canonical WebTestCase with initClient, loadFixtures, and dbIsolationPerTest
- Basic vs API auth headers and datagrid/JSON assertion helpers
Oro Functional Testing by the numbers
- 2 all-time installs (skills.sh)
- +1 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,683 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/netresearch/orocommerce-skill --skill oro-functional-testingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 2 |
| Last updated | July 22, 2026 |
| Repository | netresearch/orocommerce-skill ↗ |
What it does
Writes PHPUnit functional tests for OroCommerce 6.1 with WebTestCase: controllers, REST API, commands, datagrids, ACL flows, fixtures, and transactional isolation annotations.
Files
OroCommerce v6.1 Functional Testing (PHPUnit)
Functional tests exercise real controllers, services, commands, APIs, and ACL rules against a real database. Oro's WebTestCase boots the kernel, manages transactional isolation, loads fixtures deterministically, and layers HTTP/grid/JSON helpers on top of Symfony's BrowserKit client.
Canonical WebTestCase
This is the reference pattern combining initClient, fixtures, isolation annotations, and HTML assertions:
<?php
namespace Acme\Bundle\DemoBundle\Tests\Functional\Controller;
use Acme\Bundle\DemoBundle\Entity\Document;
use Acme\Bundle\DemoBundle\Tests\Functional\DataFixtures\LoadDocumentData;
use Oro\Bundle\TestFrameworkBundle\Test\WebTestCase;
/**
* @dbIsolationPerTest
* @outputBuffering enabled
*/
class DocumentControllerTest extends WebTestCase
{
protected function setUp(): void
{
$this->initClient([], $this->generateBasicAuthHeader());
$this->loadFixtures([LoadDocumentData::class]);
}
public function testIndex(): void
{
$crawler = $this->client->request('GET', $this->getUrl('acme_demo_document_index'));
$result = $this->client->getResponse();
$this->assertHtmlResponseStatusCodeEquals($result, 200);
$this->assertStringContainsString('Documents', $crawler->html());
}
public function testView(): void
{
/** @var Document $document */
$document = $this->getReference('document.first');
$this->client->request('GET', $this->getUrl('acme_demo_document_view', ['id' => $document->getId()]));
$this->assertHtmlResponseStatusCodeEquals($this->client->getResponse(), 200);
}
}initClient(array $kernelOptions = [], array $serverOptions = []) boots the kernel; pass generateBasicAuthHeader($user, $pass) to authenticate (defaults: admin@example.com / admin). loadFixtures(array $fixtures, bool $force = false) loads once per test case; dependencies resolve transitively via DependentFixtureInterface. getReference('name') retrieves entities registered from fixtures. See fixtures.md for entity manager clearing behavior and InitialFixtureInterface.
WebTestCase Helpers
WebTestCase provides these instance helpers; memorize the signatures to avoid guessing:
initClient(array $kernelOptions = [], array $serverOptions = [])— boot kernel, optionally authenticategenerateBasicAuthHeader(string $username = 'admin@example.com', string $password = 'admin')— HTML-form basic auth headergenerateApiAuthHeader(string $username)— REST API auth header (no password needed; test framework issues a token)loadFixtures(array $fixtures, bool $force = false)— load PHP or YAML fixtures (@BundleName/path/to/file.yml)getReference(string $name)— fetch an entity registered by a fixturegetContainer()— DI container for pulling servicesrunCommand(string $commandName, array $params)— execute a console command, returns output stringrequestGrid(string $gridName, array $gridParams)— request a datagrid JSON response (called on$this->client)getJsonResponseContent(Response $response, int $expectedCode)— assert status + decode JSONassertHtmlResponseStatusCodeEquals(Response $response, int $expectedCode)— asserts status, dumps body on failureassertJsonResponseStatusCodeEquals(Response $response, int $expectedCode)— same for JSON responsesgetUrl(string $routeName, array $routeParams = [])— resolve a Symfony route to a URL
Isolation Annotations
@dbIsolationPerTest— wraps each test method in a transaction that rolls back on completion. Without it, every method in the class shares one transaction and state bleeds between tests.@outputBuffering enabled— required on WebTestCase subclasses so controller output is captured correctly. PHPUnit strict output mode otherwise fails on any echo inside request handling.@depends testMethodName— inherits both fixtures and state from the dependent test. Use sparingly for sequential scenarios (create -> update -> delete) where full isolation would defeat the point of the test.
Controller, API, Command, Grid
The kernel is reused across all test types; what differs is the helper and the auth header:
// HTML controller
$this->initClient([], $this->generateBasicAuthHeader());
$this->client->request('GET', $this->getUrl('route_name', ['id' => 1]));
$this->assertHtmlResponseStatusCodeEquals($this->client->getResponse(), 200);
// REST API (back-office)
$this->initClient([], $this->generateApiAuthHeader('admin@example.com'));
$this->client->request('GET', $this->getUrl('oro_api_get_users'));
$result = $this->getJsonResponseContent($this->client->getResponse(), 200);
// Console command — no auth header, still needs initClient()
$output = $this->runCommand('oro:search:reindex', ['--class' => 'OroUserBundle:User']);
$this->assertStringContainsString('Reindex finished', $output);
// Datagrid
$response = $this->client->requestGrid('users-grid', ['users-grid[_filter][username][value]' => 'admin']);
$result = $this->getJsonResponseContent($response, 200);See grid-testing.md for filter bracket notation and acl-patterns.md for 403 assertion flows.
Test Environment Installation
Install the test env via php bin/console oro:install --env=test. CLI flags like `--user-email=...` are silently ignored in test env — functional tests rely on exact values, so install parameters must come from oro_test_framework.install_options in config/config.yml. See install-options.md for every field.
Test-specific environment variables live in .env-app.test.local:
ORO_DB_DSN=postgresql://root@127.0.0.1/crm_test
ORO_MAILER_DSN=smtp://127.0.0.1Running Tests
# Functional suite only
docker compose run --rm toolbox bin/phpunit -c ./ --testsuite=functional
# Unit suite only
docker compose run --rm toolbox bin/phpunit -c ./ --testsuite=unitNever run both suites in one PHPUnit invocation. Mock objects registered by unit tests interfere with kernel boot and service resolution in functional tests. This is Oro's canonical warning; it typically surfaces as baffling service-not-found or class-not-found errors deep inside seemingly unrelated tests.
Key Pitfalls
1. Running unit + functional suites together — mock objects from unit tests interfere with functional test execution. Always separate runs (--testsuite=unit and --testsuite=functional). 2. Forgetting `@dbIsolationPerTest` — all test methods in the class share one transaction, state bleeds between tests, and failures become order-dependent and hard to reproduce. 3. Passing `--user-email=...` CLI flags to `oro:install --env=test` — silently ignored. Test env rejects install command options; the only supported path is oro_test_framework.install_options YAML. 4. Assuming fixtures share entity manager state — the entity manager is cleared after each fixture by default. Entities referenced during fixture A are detached by the time fixture B runs. Implement InitialFixtureInterface on fixtures whose entities must survive. 5. Using bundle aliases for entity FQCNs in tests ('OroProductBundle:Product') — deprecated in Doctrine; use Product::class everywhere.
See Also
- install-options.md — Full
oro_test_framework.install_optionsblock, every field, and the CLI-flags-ignored gotcha - fixtures.md — PHP/YAML fixtures,
DependentFixtureInterface,InitialFixtureInterface, EM clearing, built-in Load* fixtures, references - acl-patterns.md — HTML 403 vs JSON 403,
generateBasicAuthHeadervsgenerateApiAuthHeader, limited-user setup, grid ACL - grid-testing.md —
requestGridsignature, filter bracket notation,getJsonResponseContent, row extraction - v6.1.md — Symfony 6.4 base, env file naming, unit-vs-functional separation warning
- v7.0.md — v7.0 deltas (placeholder)
Functional Test — ACL Patterns
ACL tests verify that a user without a permission receives a 403 instead of the resource. Oro uses different assertion helpers for HTML and JSON responses; pick the one that matches the endpoint's content type or the failure message will be misleading.
HTML 403 Pattern
public function testIndexForbiddenForLimitedUser(): void
{
$this->client->request(
'GET',
$this->getUrl('oro_user_index'),
[],
[],
$this->generateBasicAuthHeader('limited_user@example.com', 'limited_user_password')
);
$this->assertHtmlResponseStatusCodeEquals($this->client->getResponse(), 403);
}assertHtmlResponseStatusCodeEquals checks the status code and, on failure, dumps the response body into the failure message. That is the entire reason to prefer it over plain assertSame(403, $response->getStatusCode()) on HTML endpoints — when an ACL change unexpectedly returns 200, you want to see the page Oro rendered, not a bare status mismatch.
JSON 403 Pattern
public function testApiForbidden(): void
{
$this->client->request(
'GET',
$this->getUrl('oro_api_get_users'),
['limit' => 100],
[],
$this->generateApiAuthHeader('limited_user@example.com')
);
$this->assertJsonResponseStatusCodeEquals($this->client->getResponse(), 403);
}assertJsonResponseStatusCodeEquals asserts both the status and that Content-Type is application/json. Use it for every REST API endpoint — it catches the common failure mode where ACL middleware returns a 500 HTML error instead of the JSON error envelope a client expects.
Auth Header Helpers
generateBasicAuthHeader(string $username = 'admin@example.com', string $password = 'admin')— buildsPHP_AUTH_USER+PHP_AUTH_PWfor Symfony's HTTP basic firewall. Used for HTML controller tests.generateApiAuthHeader(string $username)— builds theX-WSSEheader the Oro REST API firewall expects. No password needed: the test framework looks up the user's API key and signs the header. This is the reason ACL API tests don't need credentials for each limited user, only the username.
Limited-User Test Setup
Don't use raw admin headers for ACL tests — create limited users via a fixture and reference them by name:
use Oro\Bundle\UserBundle\Entity\User;
class LoadLimitedUser extends AbstractFixture implements DependentFixtureInterface
{
public function getDependencies(): array
{
return [LoadOrganization::class, LoadBusinessUnit::class];
}
public function load(ObjectManager $manager): void
{
$user = new User();
$user->setEmail('limited_user@example.com');
$user->setPlainPassword('limited_user_password');
$user->setFirstName('Limited');
$user->setLastName('User');
$user->setOrganization($this->getReference('organization'));
$user->addBusinessUnit($this->getReference('business_unit'));
// ...attach role with no access to the endpoint under test
$manager->persist($user);
$manager->flush();
$this->addReference('limited_user', $user);
}
}Then in the test, pull the user via reference and pass its email to generateBasicAuthHeader/generateApiAuthHeader.
Grid ACL
Datagrids go through the same ACL pipeline as controllers. Forbidden access returns a 403 JSON response:
$response = $this->client->requestGrid('users-grid', [], false, $this->generateApiAuthHeader('limited_user'));
self::assertSame(403, $response->getStatusCode());When permissions are partially granted (read but not edit), the grid returns 200 with restricted rows — assert on the row count in getJsonResponseContent($response, 200)['data'] instead of the status code.
Functional Test — Data Fixtures
Oro functional fixtures populate the database with deterministic data before each test case. They come in two flavors (PHP class and YAML) and plug into a dependency-resolving loader that tracks references across fixtures.
PHP Fixture
<?php
namespace Acme\Bundle\DemoBundle\Tests\Functional\DataFixtures;
use Acme\Bundle\DemoBundle\Entity\Document;
use Doctrine\Common\DataFixtures\AbstractFixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
use Oro\Bundle\TestFrameworkBundle\Tests\Functional\DataFixtures\LoadOrganization;
use Oro\Bundle\TestFrameworkBundle\Tests\Functional\DataFixtures\LoadUser;
class LoadDocumentData extends AbstractFixture implements DependentFixtureInterface
{
public function getDependencies(): array
{
return [LoadOrganization::class, LoadUser::class];
}
public function load(ObjectManager $manager): void
{
$document = new Document();
$document->setTitle('First document');
$document->setOwner($this->getReference('user'));
$document->setOrganization($this->getReference('organization'));
$manager->persist($document);
$manager->flush();
$this->addReference('document.first', $document);
}
}Dependencies resolve transitively: declaring LoadOrganization::class pulls in any fixture it depends on, so the common LoadUser + LoadOrganization base is always available.
YAML Fixture
# Tests/Functional/DataFixtures/documents.yml
Acme\Bundle\DemoBundle\Entity\Document:
document.alpha:
title: Alpha
owner: '@user'
organization: '@organization'
document.beta:
title: Beta
owner: '@user'
organization: '@organization'Load it alongside PHP fixtures by passing a bundle-relative path:
$this->loadFixtures([
LoadOrganization::class,
'@AcmeDemoBundle/Tests/Functional/DataFixtures/documents.yml',
]);Built-in Load* Fixtures
Three built-in fixtures seed the minimum identity graph Oro expects:
Oro\Bundle\TestFrameworkBundle\Tests\Functional\DataFixtures\LoadOrganizationOro\Bundle\TestFrameworkBundle\Tests\Functional\DataFixtures\LoadBusinessUnitOro\Bundle\TestFrameworkBundle\Tests\Functional\DataFixtures\LoadUser
Any fixture that creates an entity with USER or BUSINESS_UNIT ownership should declare these as dependencies — otherwise getReference('user')/getReference('organization') return nothing and persistence fails on NOT NULL columns.
Entity Manager Clearing (the important nuance)
By default, the entity manager is cleared after loading EACH fixture — not after the batch. That means any entity your fixture persisted becomes detached the moment the next fixture starts. Rationale: identity map reset between fixtures avoids stale references and lets each fixture start from a clean slate.
Consequences:
- Inside a single fixture,
persist()+flush()+addReference()work as expected. - Across fixtures, references survive (they are tracked by name), but the underlying entity object may need to be re-fetched. Use
getReference()to get a managed copy in the current EM. - Bulk data fixtures that rely on the entity remaining managed across fixture boundaries must implement
Oro\Bundle\TestFrameworkBundle\Test\DataFixtures\InitialFixtureInterface— this tells the loader "don't clear the EM after me."
use Oro\Bundle\TestFrameworkBundle\Test\DataFixtures\InitialFixtureInterface;
class LoadCoreReferenceData extends AbstractFixture implements InitialFixtureInterface
{
public function load(ObjectManager $manager): void
{
// Entities persisted here stay managed for all subsequent fixtures.
}
}Use InitialFixtureInterface sparingly. It is for built-in/reference data that every other fixture assumes is present and managed (enum options, currencies, localization); for regular per-test data, the default clearing is what you want.
Accessing References In Tests
protected function setUp(): void
{
$this->initClient([], $this->generateBasicAuthHeader());
$this->loadFixtures([LoadDocumentData::class]);
}
public function testSomething(): void
{
/** @var Document $document */
$document = $this->getReference('document.first');
// $document is a managed entity in the current EM
}The $force Parameter
loadFixtures(array $fixtures, bool $force = false) — fixtures load once per test case by default. The loader tracks which fixtures have been loaded and short-circuits on repeat calls. Pass $force = true only when a single test method must reload fixtures mid-run (e.g. after destructive operations in a @depends chain). Abuse of $force slows the suite dramatically; prefer @dbIsolationPerTest to rollback changes instead of reloading.
Functional Test — Grid Testing
Datagrids in Oro are rendered server-side as JSON and hydrated client-side. The test framework exposes requestGrid() on the browser client so tests can hit grids by name, apply filters, and assert on rows without parsing HTML.
Method Signature
$response = $this->client->requestGrid(
string $gridName,
array $gridParams = [],
bool $isRealRequest = false,
array $serverOptions = []
);$gridName— the grid's identifier as declared indatagrids.yml(e.g.users-grid,acme-demo-documents-grid).$gridParams— flat key/value array with bracketed filter paths (see below).$isRealRequest—falsedispatches through the kernel,truehits the actual HTTP layer. Leave default unless testing middleware.$serverOptions— optional auth header for ACL tests.
Filter Bracket Notation
Grid filter parameters use bracketed paths that mirror the HTML form field names. The format is:
{gridName}[_filter][{fieldName}][value]
{gridName}[_filter][{fieldName}][type]Example — filter users-grid by username equal to admin:
$response = $this->client->requestGrid('users-grid', [
'users-grid[_filter][username][value]' => 'admin',
]);Example — range filter on a price column:
$response = $this->client->requestGrid('acme-demo-documents-grid', [
'acme-demo-documents-grid[_filter][price][value][start]' => 100,
'acme-demo-documents-grid[_filter][price][value][end]' => 500,
'acme-demo-documents-grid[_filter][price][type]' => 7, // between
]);The filter type integer values come from the filter type registered in the grid — check FilterUtility::TYPE_* constants or the grid definition when uncertain.
Extracting Results
$response = $this->client->requestGrid('users-grid', [
'users-grid[_filter][username][value]' => 'admin',
]);
$result = $this->getJsonResponseContent($response, 200);
self::assertCount(1, $result['data']);
$firstRow = reset($result['data']);
self::assertSame('admin@example.com', $firstRow['email']);getJsonResponseContent($response, 200) asserts the status and returns the decoded body. The grid response shape is:
{
"data": [ { column: value, ... }, ... ],
"options": { "totalRecords": N, ... },
"metadata": { ... }
}Use $result['options']['totalRecords'] to assert pagination totals; use $result['data'] for row-level assertions. The row keys are the column name values from datagrids.yml, not the underlying DB columns.
Sorting
$response = $this->client->requestGrid('users-grid', [
'users-grid[_sort_by][createdAt]' => 'DESC',
]);Pagination
$response = $this->client->requestGrid('users-grid', [
'users-grid[_pager][_page]' => 2,
'users-grid[_pager][_per_page]' => 10,
]);Functional Test — Install Options
The Silent Footgun
Oro docs state plainly: "As functional tests rely on exact values, test environments do not support install command options." This means CLI flags passed to oro:install --env=test — like --user-email=..., --user-password=..., --application-url=... — are silently ignored. The command runs, reports success, and installs with defaults that your tests then fail to match.
The only supported path to customize the test install is the oro_test_framework.install_options block in config/config.yml.
Full Install Options Block
# config/config.yml
oro_test_framework:
install_options:
user_name: admin
user_email: admin@example.com
user_firstname: John
user_lastname: Doe
user_password: admin
sample_data: false
organization_name: OroInc
application_url: http://localhost/
skip_translations: true
timeout: 600
language: en
formatting_code: en_US
no_interaction: trueField Reference
user_name— Admin username created by install.user_email— Admin email. Must match whatgenerateBasicAuthHeader()defaults to (admin@example.com) unless every test passes an override.user_firstname— Admin first name; appears in seeded user records that fixtures may reference.user_lastname— Admin last name.user_password— Admin password, i.e. the default test user password used bygenerateBasicAuthHeader().sample_data— Keepfalsefor functional tests. Sample data adds nondeterministic rows that break fixture-based assertions.organization_name— Name of the default organization record. Most ownership tests reference this seeded org.application_url— Used to build absolute URLs in the test kernel; match the scheme/host your tests expect.skip_translations— Keeptrue. Skipping translation dumps speeds the install and avoids locale-dependent assertions.timeout— Install command timeout in seconds; raise on slow CI runners.language— UI language code for the seeded user (e.g.en).formatting_code— Locale code for number/date formatting (e.g.en_US). Controls how fixture-loaded dates and prices render in HTML assertions.no_interaction— Run install non-interactively (true) so CI/test bootstrap never blocks on prompts.
Running the Install
docker compose run --rm toolbox bin/console oro:install --env=testNo CLI flags. Everything the test env needs comes from the YAML block above. The install command reads oro_test_framework.install_options and applies each field during bootstrap.
Why CLI Flags Are Ignored
The test framework intercepts install in the test env and overrides parameters with the values from install_options so that generateBasicAuthHeader(), fixture-referenced org/user records, and URL generators all see the same fixed values across every test run and every developer machine. Allowing CLI flags would let per-invocation drift sneak into fixture references and break the isolation guarantees the test suite depends on.
When Tests Fail With Auth Errors
If generateBasicAuthHeader() returns 401 after a fresh test install, the mismatch is almost always between the hardcoded defaults (admin@example.com / admin) and a modified install_options block. Either revert the YAML or pass the custom credentials to every generateBasicAuthHeader($email, $password) call — there is no middle ground, because the helper's defaults are compiled into test base classes throughout Oro.
Functional Testing — v6.1 Notes
Key Environment
- Symfony 6.4 LTS as the underlying framework;
BrowserKitand test kernel bootstrapping follow Symfony 6.4 conventions. - PHPUnit 9.x is the supported major version; Oro's
WebTestCaseextends the SymfonyKernelTestCaseplus framework-specific traits. - PHP 8.1+ required; 8.2 recommended for functional test runs (faster kernel boot).
- PostgreSQL primary; MySQL supported in legacy mode. Test DB connections via
ORO_DB_DSN.
Test Environment File
Functional tests read their environment overrides from .env-app.test.local:
ORO_DB_DSN=postgresql://root@127.0.0.1/crm_test
ORO_MAILER_DSN=smtp://127.0.0.1Note: the Oro docs themselves are internally inconsistent — the e2e docs reference .app-env.local while the functional docs say .env-app.test.local. For functional tests specifically, use `.env-app.test.local`. The file is loaded by the Symfony Dotenv component early in kernel boot, and values win over committed .env files.
Unit vs Functional Suite Separation
Oro's canonical warning: "Never run unit and functional suites in the same PHPUnit invocation." Mock objects registered during unit test boot persist in the autoloader/container in ways that collide with real service resolution during functional tests. The symptoms are confusing — service-not-found, class-not-found, or "cannot redeclare class" errors deep inside tests that have nothing to do with the offending mock.
# Do:
docker compose run --rm toolbox bin/phpunit -c ./ --testsuite=unit
docker compose run --rm toolbox bin/phpunit -c ./ --testsuite=functional
# Don't:
docker compose run --rm toolbox bin/phpunit -c ./ # runs everything in one processChanges from v6.0
- Annotation-based test configuration (
@dbIsolationPerTest,@outputBuffering,@depends) unchanged. generateApiAuthHeaderremains WSSE-based; token-based API auth is a v7.x topic.InitialFixtureInterfacesemantics unchanged from v6.0.- No changes to
requestGridsignature or filter bracket notation.
Common v6.1 Failures
1. Running bin/phpunit with no --testsuite flag — picks up both suites, mocks leak, mysterious failures follow. 2. Forgetting .env-app.test.local on a fresh clone — tests silently use default DSNs and fail on connection. 3. Leaving sample_data: true in oro_test_framework.install_options — fixture counts drift every install. 4. Old tests using 'OroProductBundle:Product' bundle aliases — Doctrine 2.13+ rejects them.
Functional Testing — v7.0 Notes
v7.0 (7.1-dev) is not yet released. The Oro master docs for the functional testing page show no material changes from v6.1 — the page is largely a placeholder. This file will be updated when v7.0 stabilizes.
Expected Changes
- TBD — no confirmed deltas to
WebTestCase, fixtures, or isolation annotations. - Watch for updates to
generateApiAuthHeaderif WSSE is replaced by token-based auth in v7.x.