testing-standards

PHP Testing Standards

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "testing-standards" with this command: npx skills add masanao-ohba/claude-manifests/masanao-ohba-claude-manifests-testing-standards

PHP Testing Standards

Language-level testing standards using PHPUnit, applicable to any PHP project.

PHPUnit Basics

Test Class Structure

<?php declare(strict_types=1);

namespace App\Test\TestCase\Service;

use PHPUnit\Framework\TestCase; use App\Service\UserService;

/**

  • UserService Test

  • Tests user service functionality

  • @covers \App\Service\UserService */ class UserServiceTest extends TestCase { private UserService $service;

    protected function setUp(): void { parent::setUp(); $this->service = new UserService(); }

    protected function tearDown(): void { unset($this->service); parent::tearDown(); }

    public function testSomething(): void { // Test implementation } }

Test Method Naming

// Pattern: test + MethodName + Scenario public function testValidateUserReturnsTrue

WhenDataIsValid(): void {} public function testValidateUserReturnsFalseWhenEmailInvalid(): void {} public function testValidateUserThrowsExceptionWhenAgeNegative(): void {}

// Alternative: use @test annotation /**

  • @test */ public function it_validates_user_with_valid_data(): void {}

Assertions

Common Assertions

// Equality $this->assertEquals($expected, $actual); $this->assertSame($expected, $actual); // Strict comparison $this->assertNotEquals($expected, $actual);

// Boolean $this->assertTrue($condition); $this->assertFalse($condition);

// Null $this->assertNull($value); $this->assertNotNull($value);

// Empty/Count $this->assertEmpty($array); $this->assertNotEmpty($array); $this->assertCount(3, $array);

// String $this->assertStringContainsString('needle', $haystack); $this->assertStringStartsWith('prefix', $string); $this->assertStringEndsWith('suffix', $string); $this->assertMatchesRegularExpression('/pattern/', $string);

// Array $this->assertContains('value', $array); $this->assertArrayHasKey('key', $array); $this->assertArraySubset($subset, $array);

// Object $this->assertInstanceOf(User::class, $object); $this->assertObjectHasAttribute('property', $object);

// Exception $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Invalid data');

Custom Assertions

// Add descriptive messages $this->assertEquals( $expected, $actual, 'User name should match expected value' );

// Use assertThat for complex conditions $this->assertThat( $value, $this->logicalAnd( $this->greaterThan(0), $this->lessThan(100) ) );

Test Patterns

AAA Pattern (Arrange-Act-Assert)

public function testUserCreation(): void { // Arrange - Set up test data $userData = [ 'name' => 'John Doe', 'email' => 'john@example.com', ];

// Act - Execute the code under test
$user = $this->service->createUser($userData);

// Assert - Verify the outcome
$this->assertInstanceOf(User::class, $user);
$this->assertEquals('John Doe', $user->getName());
$this->assertEquals('john@example.com', $user->getEmail());

}

Given-When-Then Pattern

public function testUserLogin(): void { // Given - Initial context $user = $this->createAuthenticatedUser(); $credentials = ['email' => 'test@example.com', 'password' => 'secret'];

// When - Event occurs
$result = $this->service->login($credentials);

// Then - Expected outcome
$this->assertTrue($result->isSuccess());
$this->assertEquals($user->getId(), $result->getUserId());

}

Data Providers

Basic Data Provider

/**

  • @dataProvider validEmailProvider */ public function testValidateEmailWithValidData(string $email): void { $result = $this->validator->validateEmail($email); $this->assertTrue($result); }

public function validEmailProvider(): array { return [ 'standard email' => ['user@example.com'], 'subdomain' => ['user@mail.example.com'], 'plus addressing' => ['user+tag@example.com'], ]; }

Multiple Parameters

/**

  • @dataProvider userDataProvider */ public function testUserValidation(string $name, int $age, bool $expectedResult): void { $result = $this->validator->validate($name, $age); $this->assertEquals($expectedResult, $result); }

public function userDataProvider(): array { return [ 'valid user' => ['John Doe', 25, true], 'empty name' => ['', 25, false], 'negative age' => ['John Doe', -1, false], 'too young' => ['John Doe', 15, false], ]; }

Test Doubles

Mocks

public function testUserServiceCallsRepository(): void { // Create mock $repository = $this->createMock(UserRepository::class);

// Set expectations
$repository->expects($this->once())
    ->method('findById')
    ->with(1)
    ->willReturn(new User(['id' => 1, 'name' => 'John']));

// Inject mock
$service = new UserService($repository);

// Execute and assert
$user = $service->getUser(1);
$this->assertEquals('John', $user->getName());

}

Stubs

public function testUserServiceWithStub(): void { // Create stub (no expectations) $repository = $this->createStub(UserRepository::class);

// Configure return values
$repository->method('findAll')
    ->willReturn([
        new User(['id' => 1, 'name' => 'John']),
        new User(['id' => 2, 'name' => 'Jane']),
    ]);

$service = new UserService($repository);
$users = $service->getAllUsers();

$this->assertCount(2, $users);

}

Spy

public function testEventWasTriggered(): void { // Create spy $eventDispatcher = $this->createMock(EventDispatcher::class);

// Verify method was called
$eventDispatcher->expects($this->once())
    ->method('dispatch')
    ->with($this->isInstanceOf(UserCreatedEvent::class));

$service = new UserService($eventDispatcher);
$service->createUser(['name' => 'John']);

}

Test Documentation

PHPDoc for Tests

/**

  • Test user validation with invalid email
  • Verifies that validation fails when email format is invalid
  • @return void
  • @throws \Exception */ public function testValidateUserWithInvalidEmail(): void { $this->expectException(\InvalidArgumentException::class); $this->validator->validate('invalid-email'); }

Test Class Documentation

/**

  • User Service Test
  • Comprehensive tests for UserService class including:
    • User creation and validation
    • Email verification
    • Password hashing
    • User authentication
  • @covers \App\Service\UserService
  • @uses \App\Repository\UserRepository
  • @uses \App\Entity\User */ class UserServiceTest extends TestCase { // Test methods }

Test Organization

Test File Structure

tests/ ├── Unit/ # Unit tests (isolated, no dependencies) │ ├── Service/ │ │ └── UserServiceTest.php │ └── Entity/ │ └── UserTest.php ├── Integration/ # Integration tests (multiple components) │ └── Repository/ │ └── UserRepositoryTest.php └── Functional/ # Functional tests (end-to-end) └── Api/ └── UserApiTest.php

Test Categories

/**

  • @group unit
  • @group user */ class UserServiceTest extends TestCase {}

/**

  • @group integration
  • @group database */ class UserRepositoryTest extends TestCase {}

Coverage

Code Coverage Requirements

/**

  • @covers \App\Service\UserService::createUser
  • @covers \App\Service\UserService::validateUser */ class UserServiceTest extends TestCase {}

Ignore from Coverage

/**

  • @codeCoverageIgnore */ class DeprecatedClass {}

/**

  • @codeCoverageIgnore */ public function legacyMethod(): void {}

Best Practices

DO

// ✅ Test one thing per test public function testUserNameValidation(): void {} public function testUserEmailValidation(): void {}

// ✅ Use descriptive test names public function testValidateUserThrowsExceptionWhenEmailIsEmpty(): void {}

// ✅ Use data providers for similar test cases /**

  • @dataProvider invalidEmailProvider */ public function testInvalidEmails(string $email): void {}

// ✅ Clean up in tearDown protected function tearDown(): void { $this->cleanupTestData(); parent::tearDown(); }

DON'T

// ❌ Test multiple things in one test public function testEverything(): void { $this->testValidation(); $this->testCreation(); $this->testDeletion(); }

// ❌ Use generic test names public function testMethod1(): void {}

// ❌ Leave test data public function testWithoutCleanup(): void { // Creates test data but never cleans up }

// ❌ Skip tests without reason public function testSomething(): void { $this->markTestSkipped(); // Why? }

Skipped Test Policy

STRICT RULE: @skip or markTestSkipped() are ONLY allowed for features planned for future implementation.

Prohibited Patterns

// ❌ PROHIBITED: Skipping due to complexity /**

  • @skip Feature is too complex to test */ public function testComplexFeature(): void { $this->markTestSkipped('Too complex'); }

// ❌ PROHIBITED: Skipping due to incomplete fixtures /**

  • @skip Fixture data incomplete */ public function testWithIncompleteFixture(): void { $this->markTestSkipped('Fixture incomplete'); }

// ❌ PROHIBITED: Skipping due to missing dependencies public function testExternalApiIntegration(): void { $this->markTestSkipped('API not available in test env'); }

// ❌ PROHIBITED: Skipping due to intermittent failures public function testFlaky(): void { $this->markTestSkipped('Test is flaky'); }

Allowed Pattern (ONLY for confirmed future features)

// ✅ ALLOWED: Future feature with version/milestone reference /**

  • @skip File upload feature will be implemented in v2.0 (TICKET-123) */ public function testFileUploadValidation(): void { $this->markTestSkipped('File upload feature planned for v2.0 - see TICKET-123'); }

Why This Matters

  • Skipped tests hide real coverage gaps: They create false sense of completeness

  • "Temporary" skips become permanent debt: Most skipped tests are never fixed

  • Tests must validate actual production behavior NOW: If production code exists, test MUST execute it

  • Coverage metrics become meaningless: Skipped tests inflate reported coverage

What To Do Instead

If test fails:

  • Fix the test: Update assertions, add Fixture data, mock external dependencies correctly

  • Fix production code: If behavior is wrong, fix the implementation

  • Remove the test: If testing non-existent feature, delete the test entirely

If test is difficult:

  • Break down the test: Split complex test into smaller, focused tests

  • Improve test infrastructure: Add helpers, factories, or fixtures

  • Mock external dependencies: Email, API calls, file I/O should be mocked

  • Ask for help: Don't skip - seek assistance to write proper test

If feature doesn't exist:

  • Don't write the test: Only test actual production code

  • Future features: Only add @skip with ticket/version reference

  • Delete misaligned tests: If test references non-existent code, remove it

Enforcement

NEVER skip to make test suite pass. Skipping is NOT a valid solution for:

  • Incomplete fixtures → Add fixture data

  • Complex logic → Break down into smaller tests

  • Flaky tests → Fix the race condition or timing issue

  • Missing mocks → Properly mock external dependencies

  • Schema mismatches → Fix migration files and clear cache

Only valid reason to skip: Documented future feature with:

  • Ticket/issue number

  • Target version or milestone

  • Explicit approval from team lead

Test Execution Environment

Docker-Based Test Execution (Recommended)

ALWAYS use Docker containers for PHP tests to ensure consistent PHP version and environment:

✅ CORRECT: Docker-based execution

docker compose -f docker-compose.test.yml run --rm web

With specific test file

TEST_ONLY="./tests/TestCase/Service/UserServiceTest.php"
docker compose -f docker-compose.test.yml run --rm web

With specific test method

TEST_ONLY="--filter testMethodName ./tests/TestCase/Service/UserServiceTest.php"
docker compose -f docker-compose.test.yml run --rm web

Why Docker execution is critical:

  • Ensures consistent PHP version across all environments

  • Avoids version mismatch between local and CI/CD

  • Guarantees same database configuration

  • Prevents environment-specific test failures

Prohibited Execution Methods

❌ WRONG: Direct localhost execution

vendor/bin/phpunit

❌ WRONG: Composer shortcut (unless explicitly configured)

composer test

❌ WRONG: Direct PHPUnit without container

php vendor/bin/phpunit

Pre-Execution Checklist

Before running tests, verify:

  • docker-compose.test.yml exists in project root

  • Dockerfile or Dockerfile.test specifies correct PHP version

  • Check tests/README.md for project-specific instructions

  • Verify test database configuration

Environment Configuration

docker-compose.test.yml example

version: '3.8' services: web: build: context: . dockerfile: Dockerfile.test environment: - DB_HOST=db - DB_DATABASE=test_database - PHP_VERSION=8.2 # Match project requirements volumes: - ./:/app depends_on: - db db: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: test_database

Framework-Agnostic

These standards apply to:

  • CakePHP with PHPUnit

  • Laravel with PHPUnit

  • Symfony with PHPUnit

  • Any PHP project using PHPUnit

Framework-specific testing patterns (Fixtures, TestCase extensions, etc.) should be defined in framework-level skills (e.g., php-cakephp/testing-conventions ).

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

requirement-analyzer

No summary provided by upstream source.

Repository SourceNeeds Review
General

test-case-designer

No summary provided by upstream source.

Repository SourceNeeds Review
General

test-validator

No summary provided by upstream source.

Repository SourceNeeds Review
General

functional-designer

No summary provided by upstream source.

Repository SourceNeeds Review