name: phpunit-mock-test
description: Writes a PHPUnit 10 unit test under tests/Unit/{Module}/{Class}Test.php extending PHPUnit\Framework\TestCase, following the exact patterns in tests/Unit/Auth/JwtHandlerTest.php and tests/Unit/Media/Library/ItemRepositoryTest.php — uses $this->createMock(Connection::class) for Workerman\MySQL\Connection with ->method('query')->willReturn([['col' => 'val']]) for reads and ->expects($this->once())->method('query')->with($this->stringContains('SQL'), $this->callback(fn)) for writes. Use when the user says 'write a test', 'add unit test', 'TDD this', 'test this class', or adds files under tests/Unit/. Covers PSR-4 namespacing (Phlix\Tests\Unit\{Module}), constructor-injection mocking, return-value stubs, expectation-based assertions, and running with vendor/bin/phpunit --testsuite Unit. Do NOT use for JS tests (project has no JS test runner), integration/E2E tests that need a real DB (no integration testsuite is configured), or non-tests/Unit/ paths.
PHPUnit Mock Test (phlix)
Critical
- PHPUnit version is 10 (
phpunit/phpunit: ^10.0incomposer.json). Do NOT use@dataProviderannotations only — attributes (#[DataProvider]) are preferred under PHPUnit 10, but annotations still work. Do NOT usesetExpectedException(removed); use$this->expectException(...). - All tests live under
tests/Unit/{Module}/{Class}Test.php. Thephpunit.xmltestsuiteUnitscanstests/Unitwith suffixTest.php. Files placed elsewhere will NOT run. - Namespace MUST be
Phlix\Tests\Unit\{Module}matching the directory undertests/Unit/. Autoload is PSR-4 (Phlix\Tests\→tests/) percomposer.json. - Database type is
Workerman\MySQL\Connection— neverPDO, nevermysqli. Always mock it with$this->createMock(Connection::class). - The phpunit config is strict:
failOnRisky="true",failOnWarning="true",beStrictAboutOutputDuringTests="true". A test that produces output (echo, var_dump, error_log to stdout) will FAIL. Every test must contain at least one assertion or$mock->expects(...). - No integration testsuite is wired up — only
Unitexists inphpunit.xml. Do not add tests that require a live DB connection; mock theConnectioninstead.
Instructions
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 undertests/Unit/MUST mirror the directory undersrc/. 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.phpandtests/Unit/Media/Library/ItemRepositoryTest.php):<?php namespace Phlix\Tests\Unit\{Module}; use PHPUnit\Framework\TestCase; use Phlix\{Module}\{Class}; // Only add this if the class takes a DB connection: 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 — seetests/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
Connectiondependency, build the mock inside each test method (NOT insetUp), since each test needs different stubbed data. Pattern fromtests/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 anarrayof associative-array rows. For "not found", return[].- For aggregate queries (
COUNT(*)), return[['count' => 5]]and assert the unwrapped value (seeItemRepositoryTest.php:241-250).
For methods that perform writes (INSERT/UPDATE/DELETE), assert the call shape with
expects()— not the return value. Pattern fromItemRepositoryTest.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)(seeItemRepositoryTest.php:322-346):$db->expects($this->exactly(2)) ->method('query') ->with($this->stringContains('INSERT INTO {table}'));Test naming convention — method names must start with
testand read as a sentence describing behavior:- GOOD:
testFindByIdReturnsNullWhenNotFound,testCreateGeneratesUuidAndInsertsItem,testExpiredTokenReturnsNull - BAD:
testFindById1,test_find_by_id,findByIdTest
All methods MUST declare
: voidreturn type.- GOOD:
Run the test in isolation, then the full suite. From the project root:
vendor/bin/phpunit --filter {Class}Test vendor/bin/phpunit --testsuite UnitVerify both pass before marking the task done. Because
failOnRiskyandfailOnWarningaretrue, a test that produces no assertions OR triggers any deprecation will fail the run. Do not declare success on partial green.Do NOT add
@coversannotations — none of the existing tests use them, and PHPUnit 10 withfailOnRisky="true"does not require them sincerequireCoverageMetadatais unset.
Examples
Example 1: New class with constructor-injected dependencies
User says: "Add a unit test for Phlix\Session\SessionManager — it takes a Workerman\MySQL\Connection and has findById(string): ?array and create(array): string."
Actions taken:
- Create
tests/Unit/Session/SessionManagerTest.php. - Write header with
namespace Phlix\Tests\Unit\Session;,use PHPUnit\Framework\TestCase;,use Phlix\Session\SessionManager;,use Workerman\MySQL\Connection;. - Add
testFindByIdReturnsNullWhenNotFoundstubbing$db->method('query')->willReturn([]). - Add
testFindByIdReturnsSessionWhenFoundstubbingwillReturn([['id' => 'sess-1', ...]]). - Add
testCreateInsertsSessionusing$db->expects($this->once())->method('query')->with($this->stringContains('INSERT INTO sessions'), $this->callback(...)). - Run
vendor/bin/phpunit --filter SessionManagerTest.
Result: File mirrors tests/Unit/Media/Library/ItemRepositoryTest.php structure exactly; all tests pass under strict mode.
Example 2: Pure class with no dependencies
User says: "TDD a PasswordHasher class in src/Auth/."
Actions taken:
- Create
tests/Unit/Auth/PasswordHasherTest.php. - Mirror the
JwtHandlerTestshape: private property$hasher,setUp()constructs the real instance, no mocks. - Add tests like
testHashProducesNonEmptyString,testVerifyAcceptsCorrectPassword,testVerifyRejectsWrongPassword. - Run
vendor/bin/phpunit --filter PasswordHasherTest— failing → implement → green.
Result: TDD cycle complete; test file structure identical to JwtHandlerTest.php.
Common Issues
Class "Phlix\...\FooTest" not foundwhen running phpunit: The namespace in the test file does not match its directory.tests/Unit/Media/Library/FooTest.phpMUST declarenamespace Phlix\Tests\Unit\Media\Library;. Fix the namespace, then re-runcomposer dump-autoloadif needed.This test did not perform any assertionscauses failure:failOnRisky="true"is set inphpunit.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 realWorkerman\MySQL\Connection::querysignature. If you simply forgot a stub, use$db->method('query')->willReturn([]).Test code or tested code printed unexpected output:beStrictAboutOutputDuringTests="true"is set. Remove anyecho,print,var_dump, orerror_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. Runcomposer install(orcomposer dump-autoloadif vendor exists). Verifyvendor/workerman/mysql/exists.SQLSTATE[HY000] [2002] Connection refusedduring a unit test: You instantiated a realConnectioninstead of mocking it. Replacenew 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')— seeItemRepositoryTest.php:165. Whitespace/formatting in the production query will otherwise break the matcher.Test triggered deprecationcausing red:displayDetailsOnTestsThatTriggerDeprecations="true"plusfailOnWarning="true"makes any deprecation visible. Update the test (e.g. remove@expectedExceptionannotations, switch to#[DataProvider]attributes) rather than suppressing.