# 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.xml` at root with module-specific configs in `modules/*/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:** ```bash 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 + `Test` suffix (e.g., `Form` → `FormTest`) - Test methods start with `test` prefix (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.js` suffix (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:** ```php 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 `InteractsWithAuthentication` trait - Runs all migrations for test context **Example Test Structure** (`modules/backend/tests/classes/AuthManagerTest.php` lines 1-76): ```php 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(): void` method runs before each test (creates fresh application state) - `tearDown(): void` method 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 `TestCase` base class (e.g., `assertFileNotExists()`, `assertRegExp()`) **Model Testing Pattern** (`modules/backend/tests/widgets/FormTest.php` lines 33-66): ```php 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:** ```javascript 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`: ```php protected static function callProtectedMethod($object, $name, $params = []) protected static function getProtectedProperty($object, $name) protected static function setProtectedProperty($object, $name, $value) ``` - Trait `InteractsWithAuthentication` provides auth mocking for controller tests - Database transactions rolled back after each test (not explicitly visible but default Laravel behavior) **Authentication Mocking:** ```php 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: true` in 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:** ```php 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.xml` but not enforced - Coverage collected from `./modules/` directory - Excludes routes, migrations, test fixtures **View Coverage:** ```bash # Generate coverage report (requires xdebug) phpunit --coverage-html=coverage/ ``` **Report Configuration** (`phpunit.xml` lines 14-26): ```xml ./modules/ ./modules/backend/routes.php ./modules/cms/routes.php ./modules/system/routes.php ./modules/backend/database ./modules/cms/database ./modules/system/database ``` ## 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:** ```php // 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:** ```javascript FakeDom.new() .addScript([...]) .render() .then((dom) => { // Assertions here done(); }); ``` **Callback-Based:** ```javascript it('test name', function (done) { asyncOperation(() => { expect(...).toEqual(...); done(); }); }); ``` **Error Testing:** **PHP Exception Testing:** ```php public function testThrowsException() { $this->expectException(SystemException::class); $this->expectExceptionMessage('Expected message'); // Code that throws } ``` **Validation Error Testing:** ```php public function testValidationRules() { $user = new User; $user->email = 'invalid'; if (!$user->validate()) { $this->assertCount(1, $user->errors()); } } ``` **JavaScript Error Testing:** ```javascript 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:** ```php 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*