13 KiB
Testing Patterns
Analysis Date: 2026-02-04
Reference: This document captures WinterCMS testing patterns (PHP and JavaScript) that should inform the Scala rewrite's test structure and conventions.
Test Framework
PHP Testing:
Runner:
- PHPUnit 9.5.8+
- Config:
phpunit.xmlat root with module-specific configs inmodules/*/phpunit.xml - Bootstrap:
modules/system/tests/bootstrap/app.php - Base classes in
modules/system/tests/bootstrap/
JavaScript Testing:
Runner:
- Jest
- Config:
modules/system/tests/js/jest.config.js - Uses jsdom environment (default behavior, Node.js runtime not specified)
Run Commands:
composer test # Run all PHPUnit tests (stop on first failure)
composer lint # Check PHP syntax
composer sniff # Code standards check (PSR-1, PSR-2, PSR-4)
npm test # Run Jest tests (in js directory)
npm test -- --watch # Watch mode for JavaScript
Test File Organization
PHP Tests:
Location:
- Tests co-located with source code in
modules/*/tests/subdirectories - Test subdirectories mirror source structure (e.g.,
modules/backend/tests/widgets/,modules/backend/tests/models/,modules/backend/tests/classes/) - Module-level phpunit.xml files in each module
Naming:
- Test files end with
Test.php(e.g.,FormTest.php,AuthManagerTest.php,ImportModelTest.php) - Test class names: Source class name +
Testsuffix (e.g.,Form→FormTest) - Test methods start with
testprefix (e.g.,testRestrictedFieldWithUserWithNoPermissions())
Structure:
modules/
├── backend/
│ ├── tests/
│ │ ├── widgets/
│ │ │ ├── FormTest.php
│ │ │ ├── ListsTest.php
│ │ │ └── FilterWidgetTest.php
│ │ ├── models/
│ │ │ ├── ImportModelTest.php
│ │ │ └── ExportModelTest.php
│ │ ├── classes/
│ │ │ ├── AuthManagerTest.php
│ │ │ └── WidgetManagerTest.php
│ │ ├── formwidgets/
│ │ │ ├── CheckboxTest.php
│ │ │ └── ColorPickerTest.php
│ │ ├── fixtures/
│ │ │ └── models/
│ │ │ └── UserFixture.php
│ │ └── traits/
│ │ └── WidgetMakerTest.php
│ └── phpunit.xml
JavaScript Tests:
Location:
- Tests in
modules/system/tests/js/cases/directory - Structure mirrors feature organization
- Tests for Snowboard framework (AJAX library), form framework, and utilities
Naming:
- Test files:
*.test.jssuffix (e.g.,DataAttribute.test.js,Snowboard.test.js) - Suite names: Descriptive string (e.g., "Data Attribute Request AJAX library")
- Test cases: Descriptive string starting with "can" or "should" (e.g., "can parse request data")
Structure:
modules/system/tests/js/
├── jest.config.js
└── cases/
├── snowboard/
│ ├── ajax/
│ │ ├── DataAttribute.test.js
│ │ └── Request.test.js
│ ├── main/
│ │ ├── Snowboard.test.js
│ │ └── PluginLoader.test.js
│ └── extras/
│ └── DataConfig.test.js
└── framework/
└── FormParent.test.js
Test Structure
PHP Suite Organization:
Base Test Case:
namespace System\Tests\Bootstrap;
class TestCase extends \Illuminate\Foundation\Testing\TestCase
{
public function createApplication()
{
// Creates Laravel application with testing configuration
}
}
Plugin Test Case:
- Extends base
TestCase - Auto-detects plugin being tested
- Automatically loads plugin dependencies
- Uses
InteractsWithAuthenticationtrait - Runs all migrations for test context
Example Test Structure (modules/backend/tests/classes/AuthManagerTest.php lines 1-76):
class AuthManagerTest extends TestCase
{
protected AuthManager $instance;
protected $existingPermissions = [];
public function setUp(): void
{
$this->createApplication();
$this->instance = AuthManager::instance();
$this->existingPermissions = $this->instance->listPermissions();
$this->instance->registerPermissions('Winter.TestCase', [
'test.permission_one' => [
'label' => 'Test Permission 1',
'tab' => 'Test',
'order' => 200
],
]);
}
protected function tearDown(): void
{
AuthManager::forgetInstance();
}
public function testListPermissions()
{
$permissions = $this->listNewPermissions();
$this->assertCount(2, $permissions);
$this->assertEquals([
'test.permission_one',
'test.permission_two'
], $permissions);
}
}
Patterns Observed:
Setup/Teardown:
setUp(): voidmethod runs before each test (creates fresh application state)tearDown(): voidmethod cleans up singleton instances- Database runs in-memory (SQLite
:memory:) for test speed - Fresh encryption key generated per test run
- All migrations run in setUp via
Artisan::call('winter:up')
Assertion Patterns:
- PHPUnit 9 assertions:
$this->assertCount(),$this->assertEquals(),$this->assertNull(),$this->assertNotNull() - Compatibility shims for PHPUnit 8/9 differences in
TestCasebase class (e.g.,assertFileNotExists(),assertRegExp())
Model Testing Pattern (modules/backend/tests/widgets/FormTest.php lines 33-66):
public function testRestrictedFieldWithUserWithNoPermissions()
{
$user = new UserFixture;
$this->actingAs($user);
$form = $this->restrictedFormFixture();
$form->render();
$this->assertNull($form->getField('testRestricted'));
}
JavaScript Test Structure
Suite and Test Organization:
describe('Data Attribute Request AJAX library', function () {
it('can parse request data', function (done) {
FakeDom
.new()
.addScript([
'modules/system/assets/js/build/manifest.js',
'modules/system/assets/js/snowboard/build/snowboard.vendor.js',
])
.render()
.then(
(dom) => {
const DataAttributeSingleton = dom.window.Snowboard.attributeRequest();
expect(
DataAttributeSingleton.parseData('{foo: "bar"}')
).toEqual({ foo: 'bar' });
done();
}
);
});
});
Patterns:
- Uses FakeDom helper for DOM testing
- Scripts loaded explicitly (no auto-import)
- Async tests use
done()callback (callback-style async) - Expectations:
expect().toEqual()for value comparison - Jest global functions:
describe(),it(),jest.setTimeout()
Mocking
PHP Mocking:
Framework: Mockery (mockery/mockery 1.4.4+)
Patterns:
- Extends/uses reflection helpers in base
TestCase:protected static function callProtectedMethod($object, $name, $params = []) protected static function getProtectedProperty($object, $name) protected static function setProtectedProperty($object, $name, $value) - Trait
InteractsWithAuthenticationprovides auth mocking for controller tests - Database transactions rolled back after each test (not explicitly visible but default Laravel behavior)
Authentication Mocking:
use Backend\Tests\Concerns\InteractsWithAuthentication;
$user = new UserFixture;
$this->actingAs($user->withPermission('test.access_field', true));
What to Mock:
- Database queries (use fixtures instead)
- External services (mail, events)
- Singleton instances (reset in tearDown)
What NOT to Mock:
- Models and their relations (use fixtures)
- Framework components (use real instances)
- Business logic methods (test actual behavior)
JavaScript Mocking:
Framework: Jest built-in mocking
Patterns:
clearMocks: truein jest.config.js clears mock state before each test- Mock creation via
jest.mock() - Used for module imports, timers, API calls
Fixtures and Factories
PHP Test Fixtures:
Location:
modules/backend/tests/fixtures/models/UserFixture.php- Located in
fixtures/subdirectory parallel to test files
Test Data Pattern:
class UserFixture extends User
{
public function withPermission($permission, $value = true)
{
$this->permissions[$permission] = $value;
return $this;
}
}
Usage:
- Fixtures are model subclasses that don't persist
- Can build chains of factory methods
- Used for controller/widget tests requiring user context
- No factory library (plain class extension)
Database Fixtures:
- Run migrations in test setup to create schema
- Use model factories for initial test data where needed
Coverage
Requirements:
- Coverage tracking configured in
phpunit.xmlbut not enforced - Coverage collected from
./modules/directory - Excludes routes, migrations, test fixtures
View Coverage:
# Generate coverage report (requires xdebug)
phpunit --coverage-html=coverage/
Report Configuration (phpunit.xml lines 14-26):
<coverage>
<include>
<directory suffix=".php">./modules/</directory>
</include>
<exclude>
<file>./modules/backend/routes.php</file>
<file>./modules/cms/routes.php</file>
<file>./modules/system/routes.php</file>
<directory suffix=".php">./modules/backend/database</directory>
<directory suffix=".php">./modules/cms/database</directory>
<directory suffix=".php">./modules/system/database</directory>
</exclude>
</coverage>
Test Types
Unit Tests:
- Scope: Single class/method in isolation
- Examples:
AuthManagerTest,ImportModelTest - Uses mocking for dependencies
- Fast execution (milliseconds)
- Tests business logic and edge cases
Integration Tests:
- Scope: Multiple components working together
- Examples:
FormTest,FilterWidgetTest(form rendering with widgets) - Uses real database (in-memory SQLite)
- Tests workflows and component interaction
- Slower execution (seconds per suite)
E2E Tests:
- Framework: Not observed in WinterCMS core (JavaScript e2e exists but minimal coverage)
- Would require: Full browser automation (Selenium/Cypress)
- Not standard in this codebase
Fixture-Based Tests:
- Use model fixtures for state setup
- Examples:
testRestrictedFieldWithUserWithRightPermissions()- user fixture with permission - Reduce mock overhead
Common Patterns
Async Testing (PHP):
Event-Based Async:
// No explicit async patterns in WinterCMS PHP tests
// Uses synchronous execution with database transactions
Database Isolation:
- Transactions rolled back after each test (Laravel default)
- Fresh database state per test via setUp()
Async Testing (JavaScript):
Promise-Based:
FakeDom.new()
.addScript([...])
.render()
.then((dom) => {
// Assertions here
done();
});
Callback-Based:
it('test name', function (done) {
asyncOperation(() => {
expect(...).toEqual(...);
done();
});
});
Error Testing:
PHP Exception Testing:
public function testThrowsException()
{
$this->expectException(SystemException::class);
$this->expectExceptionMessage('Expected message');
// Code that throws
}
Validation Error Testing:
public function testValidationRules()
{
$user = new User;
$user->email = 'invalid';
if (!$user->validate()) {
$this->assertCount(1, $user->errors());
}
}
JavaScript Error Testing:
it('throws on invalid input', () => {
expect(() => {
parser.parse(null);
}).toThrow();
});
Plugin Testing
Plugin Test Case (modules/system/tests/bootstrap/PluginTestCase.php):
- Extends base
TestCase - Auto-detects plugin under test via namespace
- Loads all plugin dependencies
- Runs migrations in test-specific configuration
- Resets singleton instances between tests
Plugin Test Setup:
abstract class PluginTestCase extends TestCase
{
use InteractsWithAuthentication;
public function setUp(): void
{
PluginManager::forgetInstance();
UpdateManager::forgetInstance();
parent::setUp();
Artisan::call('winter:up'); // Run all migrations
$pluginCode = $this->guessPluginCode();
if (!is_null($pluginCode)) {
$this->instantiatePlugin($pluginCode, false);
}
}
}
Plugin Test Isolation:
- Each plugin can run its own test suite
- Test database in-memory and transaction-isolated
- Dependencies automatically resolved and loaded
Testing analysis: 2026-02-04 Reference: WinterCMS test patterns (PHP PHPUnit + JavaScript Jest) for Scala rewrite guidance