Skip to main content
OpenEyes uses a comprehensive testing approach combining PHPUnit for PHP tests and Cypress for end-to-end testing. This guide covers how to write and run tests.

Overview

OpenEyes tests fall into two main categories:
  1. Sample Data Tests - Use the sample database (recommended for new tests)
  2. Fixture-based Tests - Use fixtures (legacy approach)
All new tests should be written to work with the sample database, as this allows developers to run tests locally without interfering with their development environment.

PHP Testing

PHPUnit Configuration

OpenEyes uses PHPUnit 9.6.5 for PHP testing:
composer.json
{
  "require-dev": {
    "phpunit/phpunit": "9.6.5",
    "phpunit/php-invoker": "^3.0",
    "fakerphp/faker": "^1.13"
  }
}

Running Tests

Run tests tagged with the sample-data group:
oe-unit-tests --group=sample-data
Run specific test groups:
# Run feature tests
oe-unit-tests --group=feature

# Run profile tests
oe-unit-tests --group=profile

# Run specific test file
phpunit protected/tests/feature/ProfileTest.php

Writing PHPUnit Tests

Test Structure

Tests extend OEDbTestCase and use traits for functionality:
protected/tests/feature/ProfileTest.php
<?php

use OE\factories\ModelFactory;

/**
 * @group sample-data
 * @group feature
 * @group profile
 */
class ProfileTest extends OEDbTestCase
{
    use \WithTransactions;
    use \MocksSession;
    use \MakesApplicationRequests;

    /** @test */
    public function only_runtime_selectable_firms_are_available()
    {
        list($user, $institution) = $this->createUserWithInstitution();

        $expected_firm = ModelFactory::factoryFor(Firm::class)->create([
            'runtime_selectable' => 1,
            'name' => 'Expected Foo',
            'institution_id' => $institution->id
        ]);

        $response = $this->actingAs($user, $institution)
            ->get('/profile/firms')
            ->assertSuccessful()
            ->crawl();

        $this->assertCount(
            1,
            $response->filter('[data-test="unselected_firms"] select option[value="' . $expected_firm->id . '"]')
        );
    }
}

Required Annotations

All new tests should include the @group sample-data annotation:
/**
 * @group sample-data
 * @group feature
 * @group your-feature-name
 */
class YourTest extends OEDbTestCase
{
    // Test methods
}

Model Factories

Model factories provide a simple way to generate test data.

Using Factories

Create test instances with default values:
use OE\factories\ModelFactory;

// Create and save a patient
$patient = ModelFactory::for(Patient::class)->create();
$this->assertNotNull($patient->dob);

// Create with specific attributes
$patient = ModelFactory::for(Patient::class)->create(['dob' => '2005-03-05']);
$this->assertEquals(10, $patient->ageOn('2015-03-05'));

// Make without saving
$patient = ModelFactory::for(Patient::class)->make();
$this->assertNull($patient->id);

Factory Shorthand

Models with the HasFactory trait support shorthand syntax:
// Using the trait
use OE\factories\models\traits\HasFactory;

class Patient extends BaseActiveRecordVersioned
{
    use HasFactory;
}

// Shorthand usage
$patient = Patient::factory()->create();

Creating Factories

Define a factory for your model:
protected/factories/models/AddressFactory.php
<?php
namespace OE\factories\models;

use OE\factories\ModelFactory;
use Contact;
use Country;

class AddressFactory extends ModelFactory
{
    public function definition(): array
    {
        return [
            'address1' => $this->faker->streetAddress(),
            'city' => $this->faker->city(),
            'postcode' => $this->faker->postcode(),
            'country_id' => ModelFactory::factoryFor(Country::class)
                ->useExisting(['code' => 'GB']),
            'contact_id' => Contact::factory()
        ];
    }

    public function full(): self
    {
        return $this->state([
            'address1' => $this->faker->secondaryAddress(),
            'address2' => $this->faker->streetAddress(),
            'county' => $this->faker->county()
        ]);
    }
}

Factory Dependencies

Define dependent relations as factory calls:
class PatientFactory extends ModelFactory
{
    public function definition(): array
    {
        return [
            'first_name' => $this->faker->firstName(),
            'last_name' => $this->faker->lastName(),
            'contact_id' => ModelFactory::for(Contact::class)
        ];
    }
}

Factory States

Define multiple states for different scenarios:
class PatientFactory extends ModelFactory
{
    public function male()
    {
        return $this->state(function () {
            return [
                'gender' => 'M',
                'contact_id' => ModelFactory::factoryFor(Contact::class)->male()
            ];
        });
    }

    public function female()
    {
        return $this->state([
            'gender' => 'F',
            'contact_id' => ModelFactory::factoryFor(Contact::class)->female()
        ]);
    }
}

// Usage
$patient = ModelFactory::for(Patient::class)->male()->create();

// States can be chained (later states override earlier ones)
$patient = ModelFactory::for(Patient::class)->male()->female()->create();
// This patient will be female

Event Factories

Event factories create complete events with elements:
use OE\factories\EventFactory;

// Create a complete CVI event
$event = EventFactory::for('OphCoCvi')->create();
$this->assertTrue($event->patient instanceof Patient::class);
Event factories depend on element model factories being implemented.

Test Traits

WithTransactions

Wraps each test in a database transaction:
class MyTest extends OEDbTestCase
{
    use \WithTransactions;

    // Each test runs in a transaction and is rolled back
}

MocksSession

Provides session mocking utilities:
use \MocksSession;

$this->mockSession([
    'user_id' => $user->id,
]);

MakesApplicationRequests

Simplifies making HTTP requests:
use \MakesApplicationRequests;

$response = $this->actingAs($user, $institution)
    ->get('/profile/firms')
    ->assertSuccessful();

Cypress E2E Testing

Configuration

Cypress is configured for end-to-end testing:
cypress.config.js
const { defineConfig } = require("cypress");

module.exports = defineConfig({
  e2e: {
    baseUrl: 'http://localhost',
    viewportWidth: 1280,
    viewportHeight: 737,
    defaultCommandTimeout: 7000,
    retries: {
      runMode: 2,
      openMode: 0
    }
  },
});

Running Cypress Tests

# Open Cypress UI
npx cypress open

# Run tests headlessly
npx cypress run

# Run specific test file
npx cypress run --spec "cypress/e2e/admin/clinical-pathway-presets.cy.js"

Writing Cypress Tests

Cypress tests use a BDD-style syntax:
cypress/e2e/admin/clinical-pathway-presets.cy.js
describe('behaviour of the admin screen for clinical pathway presets', function () {
    beforeEach(() => {
        cy.createModels("PathwayType").as("pathway_type1");
        cy.createModels("PathwayType").as("pathway_type2");
        cy.login();
        cy.visit("/Admin/worklist/presetPathways");
    });

    it('toggles the activation status', function() {
        cy.getBySel("checkbox-" + this.pathway_type1.id).click();
        cy.getBySel("toggle-active-btn").click();
        cy.intercept("togglePathwayPresetsActivationStatus");
        cy.getBySel("is-active-" + this.pathway_type1.id).should("not.exist");
        cy.getBySel("is-active-" + this.pathway_type2.id).should("exist");
    });
});

Custom Cypress Commands

OpenEyes provides custom commands:
// Create models using factories
cy.createModels("Patient").as("patient");

// Login
cy.login();

// Select by data-test attribute
cy.getBySel("button-id").click();

Code Quality Tools

PHP CodeSniffer

Check code style compliance:
vendor/bin/phpcs --standard=phpcs.xml protected/modules/YourModule
OpenEyes follows PSR-12 with exceptions:
phpcs.xml
<rule ref="PSR12">
    <exclude name="Squiz.Classes.ValidClassName.NotCamelCaps" />
</rule>

<rule ref="Generic.Files.LineLength">
    <properties>
        <property name="lineLimit" value="150" />
    </properties>
</rule>

PHPStan

Run static analysis:
vendor/bin/phpstan analyse
Configuration:
phpstan.neon
parameters:
  level: 0
  phpVersion: 80000
  scanDirectories:
    - vendor/yiisoft/yii/framework
    - protected/modules

PHP CS Fixer

Automatically fix code style:
vendor/bin/php-cs-fixer fix protected/modules/YourModule

Test Organization

Directory Structure

protected/tests/
├── api/                    # API tests
├── feature/                # Feature tests
├── unit/                   # Unit tests
└── fixtures/              # Test fixtures (legacy)

protected/modules/OphCiExamination/tests/
├── unit/                   # Module unit tests
└── feature/                # Module feature tests

Test Naming

Follow these conventions:
  • Test files end with Test.php
  • Test methods start with test or use /** @test */
  • Use descriptive names: test_user_can_create_event()

Best Practices

  1. Tag all tests with @group sample-data and relevant groups
  2. Use transactions with WithTransactions trait for database tests
  3. Create factories for all models to support testing
  4. Test isolation - Each test should be independent
  5. Use factories instead of fixtures for new tests
  6. Mock external dependencies to keep tests fast
  7. Test edge cases and error conditions
  8. Keep tests simple - One assertion per test when possible

Continuous Integration

Tests run automatically in CI pipelines. Ensure:
  • All tests pass before committing
  • No skipped tests without good reason
  • Code style checks pass
  • PHPStan analysis passes

Debugging Tests

PHPUnit Debugging

# Run with verbose output
oe-unit-tests --group=sample-data --verbose

# Stop on first failure
oe-unit-tests --group=sample-data --stop-on-failure

# Run specific test method
oe-unit-tests --filter=test_user_can_create_event

Cypress Debugging

// Use cy.debug() to pause
cy.getBySel("button").debug().click();

// Use cy.pause() for interactive debugging
cy.pause();

// Screenshot on failure (automatic)

Next Steps

Contributing

Learn how to contribute your code

Module Development

Create custom modules

Build docs developers (and LLMs) love