-
Locate the class under test. Identify its namespace (Phlix\{Module}\{Class}) and constructor signature. Note every dependency the constructor accepts — these become the mocks in your test.
Verify: grep -rn "class {ClassName}" src/{Module}/ returns exactly one file. If not, ask the user which one.
-
Create the test file at the mirrored path tests/Unit/{Module}/{Class}Test.php. The directory under tests/Unit/ MUST mirror the directory under src/. Example: src/Media/Library/ItemRepository.php → tests/Unit/Media/Library/ItemRepositoryTest.php.
Verify the directory exists: ls tests/Unit/{Module}/ — create it if missing.
-
Write the file header exactly in this shape (from tests/Unit/Auth/JwtHandlerTest.php and tests/Unit/Media/Library/ItemRepositoryTest.php):
<?php
namespace Phlix\Tests\Unit\{Module};
use PHPUnit\Framework\TestCase;
use Phlix\{Module}\{Class};
use Workerman\MySQL\Connection;
class {Class}Test extends TestCase
{
}
Verify the namespace exactly matches the directory path under tests/Unit/.
-
For classes without dependencies, instantiate directly in setUp() and assert against real behavior — see tests/Unit/Auth/JwtHandlerTest.php:12-15:
private JwtHandler $jwtHandler;
protected function setUp(): void
{
$this->jwtHandler = new JwtHandler('test-secret-key-12345', 'HS256', 3600, 604800);
}
Do NOT introduce a setUp() for classes that need per-test mock variations — build the mock inside each test method instead (see step 5).
-
For classes with a Connection dependency, build the mock inside each test method (NOT in setUp), since each test needs different stubbed data. Pattern from tests/Unit/Media/Library/ItemRepositoryTest.php:
public function testFindByIdReturnsItemWhenFound(): void
{
$db = $this->createMock(Connection::class);
$db->method('query')->willReturn([
[
'id' => 'test-id',
'name' => 'Test Movie',
// ... full row matching the SELECT columns
]
]);
$repo = new {Class}($db);
$result = $repo->findById('test-id');
$this->assertIsArray($result);
$this->assertEquals('test-id', $result['id']);
}
query() returns an array of associative-array rows. For "not found", return [].
- For aggregate queries (
COUNT(*)), return [['count' => 5]] and assert the unwrapped value (see ItemRepositoryTest.php:241-250).
-
For methods that perform writes (INSERT/UPDATE/DELETE), assert the call shape with expects() — not the return value. Pattern from ItemRepositoryTest.php:159-188:
$db = $this->createMock(Connection::class);
$db->expects($this->once())
->method('query')
->with(
$this->stringContains('INSERT INTO {table}'),
$this->callback(function ($params) {
return count($params) === 7
&& $params[1] === 'expected-value'
&& $params[3] === 'another-value';
})
);
$repo = new {Class}($db);
$id = $repo->create([...]);
$this->assertNotEmpty($id);
Use $this->stringContains('SQL fragment') rather than full SQL — it tolerates whitespace/formatting drift. Use $this->callback(...) to verify positional parameter binding.
-
For batch / multi-call methods, use $this->exactly(N) (see ItemRepositoryTest.php:322-346):
$db->expects($this->exactly(2))
->method('query')
->with($this->stringContains('INSERT INTO {table}'));
-
Test naming convention — method names must start with test and read as a sentence describing behavior:
- GOOD:
testFindByIdReturnsNullWhenNotFound, testCreateGeneratesUuidAndInsertsItem, testExpiredTokenReturnsNull
- BAD:
testFindById1, test_find_by_id, findByIdTest
All methods MUST declare : void return type.
-
Run the test in isolation, then the full suite. From the project root:
vendor/bin/phpunit --filter {Class}Test
vendor/bin/phpunit --testsuite Unit
Verify both pass before marking the task done. Because failOnRisky and failOnWarning are true, a test that produces no assertions OR triggers any deprecation will fail the run. Do not declare success on partial green.
-
Do NOT add @covers annotations — none of the existing tests use them, and PHPUnit 10 with failOnRisky="true" does not require them since requireCoverageMetadata is unset.
-
Class "Phlix\...\FooTest" not found when running phpunit: The namespace in the test file does not match its directory. tests/Unit/Media/Library/FooTest.php MUST declare namespace Phlix\Tests\Unit\Media\Library;. Fix the namespace, then re-run composer dump-autoload if needed.
-
This test did not perform any assertions causes failure: failOnRisky="true" is set in phpunit.xml:8. Either add an $this->assert* call or use $mock->expects($this->once()) (expectations count as assertions). Do NOT change phpunit.xml to silence this.
-
PHPUnit\Framework\MockObject\...\BadMethodCallException: Method query may not be called more than 0 times: You used $mock->expects($this->never()) somewhere, or you stubbed a different method name. Verify the method name matches the real Workerman\MySQL\Connection::query signature. If you simply forgot a stub, use $db->method('query')->willReturn([]).
-
Test code or tested code printed unexpected output: beStrictAboutOutputDuringTests="true" is set. Remove any echo, print, var_dump, or error_log(..., 4) from the class under test or the test itself. If output is intentional, capture it with $this->expectOutputString(...).
-
Error: Class "Workerman\MySQL\Connection" not found: Composer autoload not built. Run composer install (or composer dump-autoload if vendor exists). Verify vendor/workerman/mysql/ exists.
-
SQLSTATE[HY000] [2002] Connection refused during a unit test: You instantiated a real Connection instead of mocking it. Replace new Connection(...) with $this->createMock(Connection::class). Unit tests must never connect to MySQL — only the (currently non-existent) integration suite would.
-
with() matcher mismatch failures on SQL strings: Do NOT pass full SQL to ->with(). Use $this->stringContains('INSERT INTO media_items') — see ItemRepositoryTest.php:165. Whitespace/formatting in the production query will otherwise break the matcher.
-
Test triggered deprecation causing red: displayDetailsOnTestsThatTriggerDeprecations="true" plus failOnWarning="true" makes any deprecation visible. Update the test (e.g. remove @expectedException annotations, switch to #[DataProvider] attributes) rather than suppressing.