PHPUnit habits that save time – not a full testing textbook. You already know why tests matter; here is how to write ones you will not delete in six months.
Setup
composer require --dev phpunit/phpunit
./vendor/bin/phpunit
Put tests in tests/, mirror your src/ namespace. One test class per production class is a fine default.
Test behaviour, not implementation
Good tests read like specifications: “given X, when Y, expect Z”. Bad tests assert that you called a private method three times – those break on every refactor.
public function test_empty_title_is_rejected(): void
{
$note = new Note(1, 5, 'Draft', 'Body');
$this->expectException(InvalidArgumentException::class);
$note->rename(' ');
}
Arrange, act, assert
Three clear blocks in every test. Blank line between them. Your future self will thank you.
public function test_admin_can_delete_any_note(): void
{
// Arrange
$auth = new Authorizer();
$note = new Note(1, 99, 'Title', 'Body');
// Act
$allowed = $auth->canDelete('admin', $note, 5);
// Assert
$this->assertTrue($allowed);
}
Use fakes for boundaries
Do not hit MySQL in unit tests. Pass an in-memory repository or a stub:
final class InMemoryNoteRepository implements NoteRepository
{
/** @var array<int, Note> */
private array $notes = [];
public function save(Note $note): void
{
$this->notes[$note->id] = $note;
}
public function findById(int $id): ?Note
{
return $this->notes[$id] ?? null;
}
}
Integration tests (real database, one or two) belong in a separate suite you run less often – CI nightly, or before release.
Data providers for tables of cases
/**
* @dataProvider roleProvider
*/
public function test_permissions(string $role, string $perm, bool $expected): void
{
$auth = new Authorizer();
$this->assertSame($expected, $auth->can($role, $perm));
}
public static function roleProvider(): array
{
return [
['admin', 'users.manage', true],
['user', 'users.manage', false],
['editor', 'notes.delete', true],
];
}
Habits that pay off
- Run tests before every commit – even if CI also runs them
- When you fix a bug, write the test that would have caught it first
- Keep tests fast; slow suites get skipped
- Name tests after the scenario, not the method:
test_guest_cannot_access_dashboard - Avoid testing the framework; test your rules
PHPUnit is not magic. It is a safety net for the code you actually care about – domain rules, authorisation, validation. Start there; expand when it hurts not to.

