Reference for OrangeHRM's test layers — PHPUnit per-plugin testsuites declared in `phpunit.xml`, the test-DB lifecycle (`instance:create-test-db` builds a populated MySQL DB plus a `CoreFixtureService` dump that bootstrap restores per test), test base classes (`TestCase` for plain unit tests, `KernelTestCase` for tests that need the full framework + DI container, `EntityTestCase` for entity-only tests, `EndpointTestCase` and `EndpointIntegrationTestCase` for API endpoint tests with request mocking + exception expectations), the YAML fixture pattern (per-plugin `test/fixtures/<DaoName>.yml` + `TestDataService::populate($yamlPath)` in `setUp()`), Jest configuration for frontend unit tests (`@vue/cli-plugin-unit-jest/presets/typescript-and-babel`, `__tests__/` siblings), and Cypress for E2E (separate workspace under `src/test/functional/`). Use whenever the user is writing a test, deciding which base class to extend, debugging fixture loading, setting up the test DB, running a single test class, or trying to figure out why a test that worked locally fails in CI. Companion to `dev-environment` (`instance:create-test-db` setup), `migrations` (the test DB is a migrated fresh DB), `daos` (DAO tests are the most common kind), `rest-endpoints` (endpoint tests).
OrangeHRM tests fall into four buckets:
src/plugins/orangehrm{X}Plugin/test/ (per-plugin)src/client/src/**/__tests__/*.spec.ts (mostly util-function tests; component tests are rare)src/test/functional/cypress/ (separate workspace, browser-driven)compatibility for version/matrix policy and dev-environment for local container setup)The strongest testing convention is integration-style DAO tests with YAML fixtures hitting a real test database. Pure unit tests with mocks are rarer — the codebase deliberately doesn't mock the database. This skill covers all four buckets but focuses on the PHPUnit patterns since that's where most code lives.
src/test/phpunit/Util/bootstrap.php is PHPUnit's bootstrap. It:
php devTools/core/console.php i:create-test-db ..." if the DB isn't readyCoreFixtureService::isReady() to check if the test fixtures have been seededTestDataService::populate()php devTools/core/console.php instance:create-test-db -p root --dump-options=--ssl=0
# or the shorthand alias:
php devTools/core/console.php i:create-test-db -p root --dump-options=--ssl=0What it does:
ohrm_test)migrations skill)CoreFixtureServicesrc/test/phpunit/fixtures/ for fast restore in subsequent testsThe --dump-options=--ssl=0 flag is for mysqldump compatibility — see dev-environment skill.
Run this once before running tests for the first time, and whenever migrations change the schema. CI runs it on every test build.
dev-environmentThe current CI test matrix is defined in .github/workflows/test.yml. Tests pass in CI but fail locally? Often a DB-version-specific issue — inspect the workflow matrix, then run locally against the matching DB/PHP version via the Docker dev environment.
phpunit.xml at the repo root<testsuites>
<testsuite name="Admin"><directory>src/plugins/orangehrmAdminPlugin/test</directory></testsuite>
<testsuite name="Pim"><directory>src/plugins/orangehrmPimPlugin/test</directory></testsuite>
<testsuite name="Leave"><directory>src/plugins/orangehrmLeavePlugin/test/Dao</directory><directory>src/plugins/orangehrmLeavePlugin/test</directory></testsuite>
<!-- … one per plugin -->
</testsuites>Each plugin gets its own testsuite. To run one:
./src/vendor/bin/phpunit --testsuite Admin
./src/vendor/bin/phpunit --testsuite Pim
./src/vendor/bin/phpunit --testsuite CoreTo run a single file or method:
./src/vendor/bin/phpunit src/plugins/orangehrmAdminPlugin/test/Dao/JobTitleDaoTest.php
./src/vendor/bin/phpunit --filter testGetJobTitleList
./src/vendor/bin/phpunit --filter 'JobTitleDaoTest::testGetJobTitleList'Bootstrap is src/test/phpunit/Util/bootstrap.php — explicitly set in phpunit.xml. PHPUnit's convertErrorsToExceptions, convertNoticesToExceptions, convertWarningsToExceptions are all enabled — a stray PHP warning fails a test. This is intentional.
Mirror of the plugin's source layout:
src/plugins/orangehrm{X}Plugin/test/
Api/ ← API endpoint tests (extend EndpointTestCase)
Model/ ← Model normalization tests
Authorization/ ← Permission tests
Controller/ ← Page controller tests (rare)
Dao/ ← DAO tests (the most common kind) (extend TestCase)
Entity/ ← Entity tests (extend EntityTestCase)
Service/ ← Service tests
fixtures/ ← YAML fixture files (one per test class, typically)
<DaoName>.yml
testCases/ ← Data provider filesTests under Tests\<Plugin>\ namespace (from the autoload-dev in src/composer.json).
All in OrangeHRM\Tests\Util\. Pick based on what you need.
TestCase — the defaultPlain PHPUnit TestCase extension. Use for simple unit tests that don't need the DI container or framework boot. Most DAO tests use this — they instantiate the DAO directly and let it talk to the test DB via the same EntityManager singleton.
namespace OrangeHRM\Tests\Admin\Dao;
use OrangeHRM\Admin\Dao\JobTitleDao;
use OrangeHRM\Config\Config;
use OrangeHRM\Tests\Util\TestCase;
use OrangeHRM\Tests\Util\TestDataService;
class JobTitleDaoTest extends TestCase
{
private $jobTitleDao;
protected $fixture;
protected function setUp(): void
{
$this->jobTitleDao = new JobTitleDao();
$this->fixture = Config::get(Config::PLUGINS_DIR)
. '/orangehrmAdminPlugin/test/fixtures/JobTitleDao.yml';
TestDataService::populate($this->fixture);
}
public function testGetJobTitleList(): void
{
$jobTitles = $this->jobTitleDao->getJobTitleList();
$this->assertCount(3, $jobTitles);
// …
}
}Key patterns visible here:
setUp() loads a YAML fixture via TestDataService::populate($yamlPath)Config::get(Config::PLUGINS_DIR) to resolve the fixture path portablynew, no DIKernelTestCase — full framework bootWhen the test needs the framework to be running — services that depend on Services::DOCTRINE access via the DI container, subscribers, anything that calls ServiceContainer::getContainer()->get(...).
abstract class KernelTestCase extends TestCase
{
use ServiceContainerTrait;
public const OPTIONS_WITH_HELPER_SERVICES = 'withHelperServices';
public const OPTIONS_WITH_BASE_SERVICES = 'withBaseServices';
protected function tearDown(): void
{
$this->getEntityManager()->clear();
$this->createKernel(); // ← re-create kernel between tests
}
protected function createKernel(): Framework { /* … */ }
protected function getHttpRequest(/* … */): Request { /* … */ }
}createKernel() boots a full Framework instance (the HttpKernel subclass — see doctrine-bootstrap) with the DI container, all plugins initialized, all subscribers registered. The container is fresh per test (via tearDown).
Two options on the kernel test:
OPTIONS_WITH_HELPER_SERVICES — registers DateTimeHelperService, NumberHelperService, TextHelperService, etc.OPTIONS_WITH_BASE_SERVICES — registers base infrastructure servicesUse this when your code path involves traits like ConfigServiceTrait or DateTimeHelperTrait that fetch from the DI container.
EntityTestCase — entity validation onlyFor tests that verify entity getters/setters, validation, and relations without needing the framework. Lighter than KernelTestCase. Used in */test/Entity/ directories.
EndpointTestCase — REST endpoint tests with request mockingOrangeHRM\Tests\Util\EndpointTestCase extends KernelTestCase and adds API-test conveniences:
abstract class EndpointTestCase extends KernelTestCase
{
use ValidatorTrait;
protected function getRequest(array $query = [], array $request = [], array $attributes = []): Request
{
// builds a Core\Api\V2\Request with the given params
}
protected function getApiEndpointMockBuilder(string $apiClassName, array $requestParams = []): MockBuilder
{
// builds a mock of the endpoint with a real request
}
protected function expectNotImplementedException(): void { /* … */ }
protected function expectRecordNotFoundException(): void { /* … */ }
protected function expectBadRequestException(): void { /* … */ }
protected function expectForbiddenException(): void { /* … */ }
protected function expectInvalidParamException(): void { /* … */ }
}Use for tests of API endpoint classes. The expectXxxException() helpers wrap PHPUnit's expectException for the common API exception types (see rest-endpoints skill for the full list).
EndpointIntegrationTestCase — full request-cycle endpoint testsOrangeHRM\Tests\Util\EndpointIntegrationTestCase. The heaviest — runs the request through the full HTTP kernel, including all subscribers (auth, authorization, exception handling). Used when you need to test the integration as a whole, not just the endpoint method.
Tests using this are slower but verify the auth + authorization + serialization layers together. Pair with the Integration/TestCaseParams.php data-provider helper.
TestDataServiceFixtures are YAML files representing rows of data:
# src/plugins/orangehrmAdminPlugin/test/fixtures/JobTitleDao.yml
JobTitle:
-
id: 1
jobTitleName: 'Software Engineer'
jobDescription: 'Develops software'
isDeleted: false
-
id: 2
jobTitleName: 'Project Manager'
isDeleted: false
-
id: 3
jobTitleName: 'Old Title'
isDeleted: true # soft-deletedTestDataService::populate($yamlPath):
The truncate is full — calling populate() wipes other test data of the same entity type. Each test class typically populates exactly what it needs in setUp().
Fixture per test class is the convention. Don't share one fixture file across multiple tests unless they really do need the same data and you've thought through the truncate semantics.
CoreFixtureService (run by instance:create-test-db) seeds:
authorization skill — these come from the migrations)workflow skill — also from migrations)These are present in the test DB at boot and stay between tests. Your YAML fixtures add domain data on top.
namespace OrangeHRM\Tests\X\Dao;
use OrangeHRM\Config\Config;
use OrangeHRM\X\Dao\WidgetDao;
use OrangeHRM\X\Dto\WidgetSearchFilterParams;
use OrangeHRM\Tests\Util\TestCase;
use OrangeHRM\Tests\Util\TestDataService;
/**
* @group X
* @group Dao
*/
class WidgetDaoTest extends TestCase
{
private WidgetDao $dao;
private string $fixture;
protected function setUp(): void
{
$this->dao = new WidgetDao();
$this->fixture = Config::get(Config::PLUGINS_DIR)
. '/orangehrmXPlugin/test/fixtures/WidgetDao.yml';
TestDataService::populate($this->fixture);
}
public function testGetWidgetById(): void
{
$widget = $this->dao->getWidgetById(1);
$this->assertNotNull($widget);
$this->assertEquals('Test widget', $widget->getName());
}
public function testGetWidgetListFiltered(): void
{
$params = new WidgetSearchFilterParams();
$params->setName('Test');
$widgets = $this->dao->getWidgetList($params);
$this->assertCount(2, $widgets);
}
public function testSaveWidget(): void
{
$widget = new Widget();
$widget->setName('New');
$saved = $this->dao->saveWidget($widget);
$this->assertNotNull($saved->getId());
$retrieved = $this->dao->getWidgetById($saved->getId());
$this->assertEquals('New', $retrieved->getName());
}
}Conventions:
@group <Plugin> + @group <Layer> (Dao / Service / Api / Entity) — lets phpunit --group Dao run all DAO tests across pluginssetUp() always loads a fresh fixturenew directly (no DI)Services are tested two ways:
class WidgetServiceTest extends TestCase
{
public function testSaveWidgetDispatchesEvent(): void
{
$mockDao = $this->createMock(WidgetDao::class);
$mockDao->expects($this->once())->method('saveWidget')->willReturn(new Widget());
$service = new WidgetService();
$service->setWidgetDao($mockDao); // ← test-injection setter
// … assert event was dispatched, etc.
}
}The setXxxDao() setter on every service (see services skill) exists exactly for this — inject a mock to isolate the service from the DAO.
When the service composes several DAOs or fires events that have to be observed, the integration-style test is cleaner:
class WidgetServiceIntegrationTest extends KernelTestCase
{
public function testSaveTriggersEvent(): void
{
$this->createKernel();
TestDataService::populate(/* … */);
$captured = null;
$this->getEventDispatcher()->addListener(WidgetEvents::WIDGET_SAVED, function ($event) use (&$captured) {
$captured = $event;
});
$service = new WidgetService();
$service->saveWidget(new Widget(/* … */));
$this->assertInstanceOf(WidgetSavedEvent::class, $captured);
}
}KernelTestCase gives you a real event dispatcher to subscribe to.
class WidgetAPITest extends EndpointTestCase
{
public function testGetOneReturnsWidget(): void
{
TestDataService::populate(/* … */);
$endpoint = new WidgetAPI($this->getRequest(
[], // query
[], // body
[CommonParams::PARAMETER_ID => 1], // attributes
));
$result = $endpoint->getOne();
$data = $result->normalize();
$this->assertEquals(1, $data['data']['id']);
}
public function testGetOneNotFoundThrows(): void
{
$endpoint = new WidgetAPI($this->getRequest(
[], [], [CommonParams::PARAMETER_ID => 99999]
));
$this->expectRecordNotFoundException();
$endpoint->getOne();
}
public function testValidationRuleForCreate(): void
{
$rules = (new WidgetAPI($this->getRequest()))->getValidationRuleForCreate();
$this->expectInvalidParamException();
$this->validate(['name' => ''], $rules); // empty name → fail
}
}The validate() from ValidatorTrait runs the same validator the REST framework runs (see rest-validation skill). Use it to test that validation rule collections produce the expected pass/fail behavior.
class WidgetTest extends EntityTestCase
{
public function testSetGetName(): void
{
$widget = new Widget();
$widget->setName('Test');
$this->assertEquals('Test', $widget->getName());
}
public function testCollectionInitialized(): void
{
$widget = new Widget();
$this->assertInstanceOf(ArrayCollection::class, $widget->getTags());
}
}Used for entity-level invariants — getter/setter symmetry, constructor initialization, computed properties on entities. Doesn't need DB.
jest.config.js:
module.exports = {
preset: '@vue/cli-plugin-unit-jest/presets/typescript-and-babel',
transform: {
'^.+\\.vue$': '@vue/vue3-jest',
},
coverageReporters: ['html'],
};Tests live in __tests__/ siblings to the file being tested:
src/client/src/core/util/
helper/
datefns.ts
__tests__/
datefns.spec.ts
validation/
rules.ts
__tests__/
rules.spec.tsRun:
cd src/client
yarn test:unit # all
yarn test:unit path/to/file.spec.ts # one file
yarn test:unit --coverage # with coverage reportThe frontend testing surface is light. Most tests cover util functions (validation rules, date helpers, file size, URL builders, year-range). Vue component tests exist but are rare. Don't propose adding component tests unless asked — the precedent in the codebase is to extract testable logic into util functions and unit-test those.
Sample util test:
import {required, shouldNotExceedCharLength} from '../rules';
describe('validation rules', () => {
test('required passes for non-empty string', () => {
expect(required('hello')).toBe(true);
});
test('required fails for empty string', () => {
expect(typeof required('')).toBe('string'); // returns error message string
});
test('shouldNotExceedCharLength', () => {
expect(shouldNotExceedCharLength(5)('hello')).toBe(true);
expect(typeof shouldNotExceedCharLength(5)('too long')).toBe('string');
});
});src/test/functional/ is a separate yarn workspace with its own package.json:
cd src/test/functional
yarn install # one-time
yarn test # headless run
yarn open # interactive Cypress UI
yarn lint # ESLintCypress 13. Tests live in cypress/e2e/. Page objects in cypress/support/. Custom commands in cypress/support/commands.ts.
E2E tests require:
Run E2E locally rarely — they're slow and fragile compared to PHPUnit tests. CI runs them on a schedule, not on every PR.
| Layer | Test density | Style |
|---|---|---|
| DAOs | Heavy | Integration, with YAML fixtures, real DB |
| Services | Medium | Mix of mocked DAOs and integration |
| API endpoints | Medium | EndpointTestCase, request param mocking |
| Validators (custom rules) | Heavy | Direct rule instantiation + value-based assertions |
| Entities | Light | Mostly getter/setter symmetry |
| Decorators | Light | Mostly happy-path |
| Migrations | None | Verified by running them in CI; no direct test |
| Event subscribers | Light | Mostly via service integration tests |
| Page controllers | Very light | Mostly indirectly via E2E |
| Vue components | Very light | Util functions, not components |
| Cron/scheduled tasks | None | Verified by running the underlying command's tests |
When adding code, follow the precedent — if you're adding a DAO method, write a DAO test with a YAML fixture. If you're adding a service method that orchestrates events, write an integration test with KernelTestCase + real dispatcher.
# 1. Make sure the test DB is created (one-time per OS or after migrations change)
php devTools/core/console.php instance:create-test-db -p root --dump-options=--ssl=0
# 2. Run all tests
./src/vendor/bin/phpunit
# 3. Run just one plugin's tests
./src/vendor/bin/phpunit --testsuite Admin
# 4. Run just one test class
./src/vendor/bin/phpunit src/plugins/orangehrmAdminPlugin/test/Dao/JobTitleDaoTest.php
# 5. Run just one method
./src/vendor/bin/phpunit --filter testGetJobTitleListIn Docker dev environment (see dev-environment skill), run all of these inside the PHP container:
docker exec -it os_dev_php83 bash -c "cd /var/www/<ohrm-checkout> && php devTools/core/console.php i:create-test-db -p root"
docker exec -it os_dev_php83 bash -c "cd /var/www/<ohrm-checkout> && ./src/vendor/bin/phpunit --testsuite Admin"Create src/plugins/orangehrmXPlugin/test/fixtures/WidgetDao.yml:
Widget:
-
id: 1
name: 'Widget Alpha'
isActive: true
-
id: 2
name: 'Widget Beta'
isActive: falseThen the test:
namespace OrangeHRM\Tests\X\Dao;
use OrangeHRM\Config\Config;
use OrangeHRM\X\Dao\WidgetDao;
use OrangeHRM\Tests\Util\TestCase;
use OrangeHRM\Tests\Util\TestDataService;
/**
* @group X
* @group Dao
*/
class WidgetDaoTest extends TestCase
{
private WidgetDao $dao;
private string $fixture;
protected function setUp(): void
{
$this->dao = new WidgetDao();
$this->fixture = Config::get(Config::PLUGINS_DIR) . '/orangehrmXPlugin/test/fixtures/WidgetDao.yml';
TestDataService::populate($this->fixture);
}
public function testGetByIdReturnsActiveWidget(): void
{
$w = $this->dao->getWidgetById(1);
$this->assertNotNull($w);
$this->assertEquals('Widget Alpha', $w->getName());
}
}Run:
./src/vendor/bin/phpunit src/plugins/orangehrmXPlugin/test/Dao/WidgetDaoTest.phpnamespace OrangeHRM\Tests\X\Api;
use OrangeHRM\Core\Api\V2\Validator\Rule;
use OrangeHRM\Core\Api\V2\Validator\Rules;
use OrangeHRM\Tests\Util\TestCase;
class WidgetValidationTest extends TestCase
{
public function testEmailRulePasses(): void
{
$rule = new Rule(Rules::EMAIL);
$validator = new ($rule->getClass())(...$rule->getConstructorArgs());
$this->assertTrue($validator->validate('test@example.com'));
}
public function testEmailRuleFailsForInvalid(): void
{
$rule = new Rule(Rules::EMAIL);
$validator = new ($rule->getClass())(...$rule->getConstructorArgs());
$this->assertFalse($validator->validate('not-an-email'));
}
}Each rule class has a validate($input): bool method (see rest-validation skill). Test directly without needing the full validator pipeline.
namespace OrangeHRM\Tests\X\Api;
use OrangeHRM\Core\Api\CommonParams;
use OrangeHRM\Tests\Util\EndpointTestCase;
use OrangeHRM\Tests\Util\TestDataService;
use OrangeHRM\X\Api\WidgetAPI;
class WidgetAPITest extends EndpointTestCase
{
protected function setUp(): void
{
parent::setUp();
TestDataService::populate(/* path to fixture */);
}
public function testGetOneReturnsWidgetById(): void
{
$endpoint = new WidgetAPI($this->getRequest(
[], // query
[], // body
[CommonParams::PARAMETER_ID => 1], // attributes (path params)
));
$result = $endpoint->getOne();
$normalized = $result->normalize();
$this->assertEquals(1, $normalized['data']['id']);
}
public function testGetOneThrowsForMissing(): void
{
$endpoint = new WidgetAPI($this->getRequest([], [], [
CommonParams::PARAMETER_ID => 999999,
]));
$this->expectRecordNotFoundException();
$endpoint->getOne();
}
}class WidgetServiceTest extends TestCase
{
public function testSaveCallsDaoAndDispatches(): void
{
$widget = new Widget();
$widget->setName('Test');
$mockDao = $this->createMock(WidgetDao::class);
$mockDao->expects($this->once())
->method('saveWidget')
->with($this->isInstanceOf(Widget::class))
->willReturnCallback(function (Widget $w) {
$w->setId(42);
return $w;
});
$service = new WidgetService();
$service->setWidgetDao($mockDao); // ← key — every service has setDao()
$result = $service->saveWidget($widget);
$this->assertEquals(42, $result->getId());
}
}The setXxxDao() pattern is one of the reasons services exist as plain classes with lazy-getter setters (see services skill) — it makes unit tests cheap.
instance:create-test-db (see dev-environment skill)./src/vendor/bin/phpunit --testsuite Core without errors before writing new testssrc/plugins/orangehrm{X}Plugin/test/Dao/<Name>DaoTest.php under namespace OrangeHRM\Tests\<Plugin>\DaoOrangeHRM\Tests\Util\TestCase (not the framework KernelTestCase — DAO tests don't need the DI container)@group <Plugin> + @group Dao annotationssetUp() instantiates the DAO with new and populates a YAML fixturetest/fixtures/<DaoName>.ymlOrangeHRM\Tests\Util\EndpointTestCase$this->getRequest($query, $body, $attributes) to build a RequestexpectRecordNotFoundException() / expectBadRequestException() / expectInvalidParamException() for error cases$this->validate($values, $rules) from ValidatorTrait to test validation rule collectionssetXxxDao()) or integration-style (KernelTestCase + real DB)addListener on the test dispatcher in setUp to capture themsetXxxService() setter to inject a mockmariadb103 or mysql57 container — see dev-environment skill)phpunit.xml — strict error/notice/warning conversion is on; a notice in your code = failpopulate its own fixture in setUpDateTime without a TZ; CI runs in UTC, you might not. Use DateTimeHelperService::TIMEZONE_UTC explicitly.TestDataService::populate() truncates the table before inserting. If your test depends on data from a previous test, it's gone. Always re-populate in setUp().CoreFixtureService data (countries, roles, permissions) is shared. Your fixtures must not insert rows that conflict with the core data (e.g. inserting a UserRole with id=1 will collide with the Admin role).convertErrorsToExceptions, convertNoticesToExceptions, convertWarningsToExceptions. A Notice: Undefined index in production code makes tests fail.instance:create-test-db having been called. The error message is explicit; if you ignore it, every test fails to load.@group annotations are aspirational — they let you filter (phpunit --group Dao) but don't change test ordering or isolation.KernelTestCase::tearDown clears the EM and re-creates the kernel. That makes each test start clean but adds overhead. For DAO tests that don't need the kernel, use plain TestCase — it's faster..github/workflows/test.yml. SQL or PHP that works in only one matrix entry can still fail CI. Most issues stem from charset / collation differences between MySQL and MariaDB.Doctrine\ORM\EntityManager — it has too many methods and the mocks fall out of sync with reality. Use the test DB.56e23b3
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.