1. PHPUnit on Plain PHP
Pest (Week 15) is built on PHPUnit — its expressive test('...', function () {})
syntax compiles down to PHPUnit test classes underneath. Outside Laravel, PHPUnit's
own class-based syntax is the standard, and it works on any PHP code, framework or
not — including Module 1's raw ProductRepository from Week 7:
composer require --dev phpunit/phpunit
<?php
declare(strict_types=1);
namespace Tests;
use App\Shop\ProductRepository;
use PHPUnit\Framework\TestCase;
class ProductRepositoryTest extends TestCase
{
private \PDO $pdo;
private ProductRepository $repository;
protected function setUp(): void
{
// An in-memory SQLite database -- fast, disposable, no real MySQL needed for a unit test
$this->pdo = new \PDO('sqlite::memory:');
$this->pdo->exec('CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price REAL, stock_count INTEGER)');
$this->repository = new ProductRepository($this->pdo);
}
public function test_it_creates_and_finds_a_product(): void
{
$id = $this->repository->create('Keyboard', 89.99, 10);
$product = $this->repository->find($id);
$this->assertNotNull($product);
$this->assertSame('Keyboard', $product['name']);
$this->assertEquals(89.99, $product['price']);
}
public function test_find_returns_null_for_a_missing_product(): void
{
$this->assertNull($this->repository->find(999));
}
}
./vendor/bin/phpunit tests/
setUp() runs before every single test method, giving each one a
completely fresh, isolated database — no test can accidentally leak state into
another, which matters far more as a test suite grows past a handful of tests.
2. Mocks & Test Doubles
A unit test should test one piece of logic in isolation. When a class depends on something slow, external, or hard to control in a test — an email service, an HTTP API — a mock stands in for it:
<?php
namespace App\Shop;
interface NotificationSender
{
public function send(string $to, string $message): bool;
}
class OrderService
{
public function __construct(private NotificationSender $sender) {}
public function confirmOrder(string $customerEmail, int $orderId): bool
{
return $this->sender->send($customerEmail, "Order #{$orderId} confirmed!");
}
}
<?php
use App\Shop\NotificationSender;
use App\Shop\OrderService;
use PHPUnit\Framework\TestCase;
class OrderServiceTest extends TestCase
{
public function test_it_sends_a_confirmation_on_order_confirm(): void
{
$mockSender = $this->createMock(NotificationSender::class);
$mockSender->expects($this->once())
->method('send')
->with('ada@example.com', $this->stringContains('Order #42'))
->willReturn(true);
$service = new OrderService($mockSender);
$this->assertTrue($service->confirmOrder('ada@example.com', 42));
}
}
createMock(NotificationSender::class) is only possible because
OrderService depends on the NotificationSender
interface from Week 3, not a concrete email class directly — exactly the
dependency-injection payoff flagged back then. The test proves
confirmOrder() calls send() correctly, with the right
arguments, without ever sending a real email.
3. A GitHub Actions Pipeline
A CI pipeline runs your test suite automatically on every push — the goal being a broken build is caught within minutes, not discovered in production:
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: testing
ports: ['3306:3306']
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, pdo_mysql
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Copy .env
run: cp .env.example .env && php artisan key:generate
- name: Run tests
run: php artisan test
- name: Static analysis
run: ./vendor/bin/phpstan analyse
The services: mysql block spins up a real, disposable MySQL container
for the pipeline to test against — GitHub Actions runs the entire job in a fresh,
isolated environment every single time, so there's no risk of a stale local
database masking a real bug. A failing step (either the test suite or PHPStan)
fails the whole workflow, which GitHub surfaces directly on the pull request.
4. Static Analysis
Static analysis examines code without running it, catching a category of bugs tests often miss — type mismatches, unreachable code, calling a method that doesn't exist on a given type:
composer require --dev phpstan/phpstan
composer require --dev larastan/larastan # Laravel-aware rules, if applicable
./vendor/bin/phpstan analyse app/ --level=5
<?php
function getDiscountedPrice(Product $product): float
{
return $product->pricee; // typo -- 'pricee', not 'price'
}
// PHPUnit: only fails if a test happens to call this exact path
// PHPStan: flags "Access to an undefined property Product::$pricee" immediately,
// without ever running the code
PHPStan's "levels" (0 through 9, increasingly strict) let a team ratchet up rigor gradually rather than requiring full strictness from day one — starting at a lower level on an existing codebase and raising it over time is the realistic adoption path, rather than an all-or-nothing switch.
5. CI as a Quality Gate
Configuring a repository to require the CI workflow to pass before a pull request can be merged turns "tests exist" into "tests are actually enforced" — a real distinction. A test suite nobody's required to keep green tends to rot; failing tests get skipped, ignored, or "fixed later."
This closes the loop on everything from Weeks 8, 13, 15 and 25: a Pest test proving the ownership Policy works, a PHPStan rule catching a typo, a security audit step — none of them protect anything unless they actually block a broken change from merging. That's the practical difference between having tests and having a real safety net.
6. Hands-on Exercise
Add PHPUnit, PHPStan and CI to two projects
Testing and static analysis on the raw PHP project, plus a real CI pipeline on the Laravel one.
Requirements:
- Add PHPUnit to the Week 7/8 raw PHP project, with tests for
ProductRepository(or your task manager's equivalent) using an in-memory SQLite database. - Add PHPStan to the same project, fix every reported issue at level 5, and add a test using a mock for any class with an external dependency.
- Push the Laravel task tracker to a GitHub repository and add a
.github/workflows/ci.ymlrunningphp artisan testandphpstan analyseagainst a real MySQL service container. - Open a pull request with a deliberately broken test and confirm the CI workflow correctly fails and blocks the merge (via branch protection rules).
- Fix the test, push again, and confirm CI goes green.
Deliberately breaking a test and watching CI catch it is the single most convincing way to confirm a pipeline actually works — a pipeline that's never been observed failing hasn't really been verified, the same principle from Week 15's ownership test.
7. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why does ProductRepositoryTest use an in-memory SQLite database instead of connecting to the real MySQL database?
Why does ProductRepositoryTest use an in-memory SQLite database instead of connecting to the real MySQL database?
Speed and isolation. An in-memory database is created fresh and destroyed automatically for every test run — no leftover state to clean up, no risk of tests affecting a real database (development or otherwise), and no dependency on a MySQL server being available and configured wherever the tests run, including in a CI pipeline with no persistent database.
Q2
Why is createMock(NotificationSender::class) only possible because OrderService depends on an interface rather than a concrete class?
Why is createMock(NotificationSender::class) only possible because OrderService depends on an interface rather than a concrete class?
PHPUnit's mock objects work by generating a fake implementation of a given type at test time. Because OrderService's constructor is typed as the NotificationSender interface (not a specific concrete email class), a mock implementing that same interface can be substituted in without OrderService needing to know or care — this is the exact same dependency-injection payoff from Week 3's Discountable example, now applied to testability specifically.
Q3
What kind of bug does PHPStan catch that a passing PHPUnit/Pest test suite might miss entirely?
What kind of bug does PHPStan catch that a passing PHPUnit/Pest test suite might miss entirely?
A test suite only exercises the specific code paths its tests actually call — a typo like $product->pricee only surfaces as a test failure if some test happens to run that exact line. Static analysis examines every line of code without executing any of it, so it catches type errors, undefined properties, and similar structural mistakes across the entire codebase regardless of whether a test happens to cover that path.
Q4
What's the actual difference between "a project has tests" and "a project enforces tests via CI with branch protection"?
What's the actual difference between "a project has tests" and "a project enforces tests via CI with branch protection"?
Tests that exist but aren't required to pass before merging can be ignored, skipped, or left broken under deadline pressure — nothing actually stops a change that breaks them from shipping. Requiring the CI workflow to pass as a branch protection rule turns the test suite from documentation of intended behavior into a real, enforced gate: a pull request genuinely cannot be merged while a test (or a static analysis check) is failing.