Testing in OrangeHRM
SkillDatabases & dataReference 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).
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Testing in OrangeHRM skill
What this skill tells your AI
The instructions your AI receives, as published by orangehrm/orangehrm in .agents/skills/testing/SKILL.md and read by ahel’s review.
OrangeHRM tests fall into four buckets:
- PHPUnit unit / integration tests —
src/plugins/orangehrm{X}Plugin/test/(per-plugin) - Jest frontend unit tests —
src/client/src/**/__tests__/*.spec.ts(mostly util-function tests; component tests are rare) - Cypress E2E —
src/test/functional/cypress/(separate workspace, browser-driven) - Migration / installer / linting smoke tests — covered by CI workflows (see
compatibilityfor version/matrix policy anddev-environmentfor 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.
The test database
src/test/phpunit/Util/bootstrap.php is PHPUnit's bootstrap. It:
- Connects to the test DB (configured the same way as the main DB, but via the test env)
- Bails out with "Run
php devTools/core/console.php i:create-test-db ..." if the DB isn't ready - Uses
CoreFixtureService::isReady()to check if the test fixtures have been seeded - Otherwise, every test class is responsible for loading its own fixtures via
TestDataService::populate()
Creating the test DB (one-time setup)
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=0
What it does:
- Creates a fresh MySQL database (default name
ohrm_test) - Runs the full migration chain from V3_3_3 through PRODUCT_VERSION (see
migrationsskill) - Seeds OrangeHRM core fixtures (countries, currencies, predefined roles, etc.) via
CoreFixtureService - Dumps the populated DB to a SQL file in
src/test/phpunit/fixtures/for fast restore in subsequent tests
The --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.
CI matrix and dev-environment
The 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 configuration — 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 Core
To 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.
Per-plugin test layout
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 files
Tests under Tests\<Plugin>\ namespace (from the autoload-dev in src/composer.json).
Test base classes
All in OrangeHRM\Tests\Util\. Pick based on what you need.
TestCase — the default
Plain 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 viaTestDataService::populate($yamlPath)- Uses
Config::get(Config::PLUGINS_DIR)to resolve the fixture path portably - Tests instantiate the DAO with
new, no DI
KernelTestCase — full framework boot
When 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— registersDateTimeHelperService,NumberHelperService,TextHelperService, etc.OPTIONS_WITH_BASE_SERVICES— registers base infrastructure services
Use this when your code path involves traits like ConfigServiceTrait or DateTimeHelperTrait that fetch from the DI container.
EntityTestCase — entity validation only
For 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 mocking
OrangeHRM\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 tests
OrangeHRM\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.
YAML fixtures and TestDataService
Fixtures 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-deleted
TestDataService::populate($yamlPath):
- Parses the YAML
- For each entity type, truncates the corresponding table
- Inserts the rows
- Returns
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.
Core fixtures — pre-loaded
CoreFixtureService (run by instance:create-test-db) seeds:
- All countries, currencies, nationalities (the lookup tables)
- All user roles (Admin, ESS, Supervisor, etc.)
- All data groups + role permissions (see
authorizationskill — these come from the migrations) - All screens + role permissions
- All workflow state machine rows (see
workflowskill — also from migrations) - All i18n lang strings + groups
These are present in the test DB at boot and stay between tests. Your YAML fixtures add domain data on top.
Writing a DAO test (the most common kind)
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) — letsphpunit --group Daorun all DAO tests across pluginssetUp()always loads a fresh fixture- Constructor uses
newdirectly (no DI) - Real DB operations — no mocking the EM
Writing a Service test
Services are tested two ways:
1. Unit-style with DAO mocking
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.
2. Integration-style with real DB
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.
Writing an Endpoint test
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.
Writing an Entity test
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.
Frontend testing — Jest
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.ts
Run:
cd src/client
yarn test:unit # all
yarn test:unit path/to/file.spec.ts # one file
yarn test:unit --coverage # with coverage report
The 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');
});
});
End-to-end testing — Cypress
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 # ESLint
Cypress 13. Tests live in cypress/e2e/. Page objects in cypress/support/. Custom commands in cypress/support/commands.ts.
E2E tests require:
- A running OrangeHRM instance (locally served)
- The instance to be installed and seeded with known credentials
- The Cypress config pointing at the right base URL
Run E2E locally rarely — they're slow and fragile compared to PHPUnit tests. CI runs them on a schedule, not on every PR.
What's tested vs. not — observed convention
| 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.
Recipes
Recipe 1 — Set up the test environment (first time)
# 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 testGetJobTitleList
In 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"
Recipe 2 — Write a DAO test with fixtures
Create src/plugins/orangehrmXPlugin/test/fixtures/WidgetDao.yml:
Widget:
-
id: 1
name: 'Widget Alpha'
isActive: true
-
id: 2
name: 'Widget Beta'
isActive: false
Then 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.php
Recipe 3 — Test a validation rule
namespace 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.
Recipe 4 — Test an API endpoint method
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();
}
}
Recipe 5 — Mock a DAO inside a service test
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;
});
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 1k
- Forks
- 749
- Last commit
- Jun 2026
- Hacker News mentions
- 20
Advanced
- Catalog kind
- skill
- Gateway key
testing-orangehrm- Source
- github.com/orangehrm/orangehrm