Programming Tutorials

Writing Your First Automated Test Suite for a Small PHP App

A practical, no-jargon introduction to testing a small PHP app with PHPUnit, covering what to test first, how to write a real feature test, and the mistake most beginners make.

By Aissam Ait Ahmed Programming Tutorials 0 comments

I put off writing tests for a long time with the same excuse most people use: the app was small enough that I could just check things manually. That held up right until I shipped a one-line change that quietly broke a discount calculation, and it sat in production for four days before a customer noticed their total was wrong. Nothing crashed. No error logged. It just silently computed the wrong number, which is the specific kind of bug that manual checking is bad at catching and automated tests are good at catching.

This is what I wish someone had told me before I wrote my first test: you don't need to test everything, you don't need 100% coverage, and you don't need to understand every PHPUnit feature before you start. You need to test the few things that would actually hurt if they broke silently, and you need to write those tests in a way that survives you refactoring the code underneath them.

What to Test First

Not everything in a small app is equally worth testing. Framework glue code — a controller that just calls a model method and returns a view — is low-value to test because if it breaks, it breaks loudly and immediately when you load the page. The highest-value tests target code where a bug would be silent and costly: pricing calculations, permission checks, anything involving dates or money, and anything with more than one conditional branch.

A good rule of thumb: if you can imagine a version of this function returning a wrong-but-plausible answer instead of crashing, it needs a test. A discount calculator that returns $12.50 instead of $15.00 doesn't throw an exception — it just quietly costs someone money or gives away a wrong discount. That's the exact category of bug that got me to start testing in the first place.

Setting Up PHPUnit

Most PHP frameworks, Laravel included, ship with PHPUnit already configured. If you're starting from a plain PHP project, getting it running is two commands:

composer require --dev phpunit/phpunit
vendor/bin/phpunit --version

For a Laravel app it's already there — you just need to know the convention: tests live in tests/Unit for isolated logic with no framework dependencies, and tests/Feature for anything that touches the database, routes, or HTTP layer. That distinction matters more than it looks like at first. Unit tests should run in milliseconds with no database; feature tests are slower but tell you the whole request/response cycle actually works.

Your First Unit Test: A Pure Function

Pure functions — same input always produces the same output, no side effects — are the easiest and most valuable place to start, because there's nothing to mock or fake. Take a discount calculator like the one below:

function calculateDiscount(float $subtotal, array $user): float
{
    $isLargeOrder = $subtotal > 100;

    if ($user['is_member']) {
        return $subtotal * ($isLargeOrder ? 0.15 : 0.10);
    }

    return $isLargeOrder ? $subtotal * 0.05 : 0.0;
}

This function has four distinct behaviors hiding inside two conditionals: member on a large order, member on a small order, non-member on a large order, non-member on a small order. Each of those is a test:

use PHPUnit\Framework\TestCase;

final class DiscountCalculatorTest extends TestCase
{
    public function test_members_get_fifteen_percent_off_large_orders(): void
    {
        $user = ['is_member' => true];
        $this->assertEquals(22.5, calculateDiscount(150.0, $user));
    }

    public function test_members_get_ten_percent_off_small_orders(): void
    {
        $user = ['is_member' => true];
        $this->assertEquals(8.0, calculateDiscount(80.0, $user));
    }

    public function test_non_members_get_five_percent_off_large_orders(): void
    {
        $user = ['is_member' => false];
        $this->assertEquals(7.5, calculateDiscount(150.0, $user));
    }

    public function test_non_members_get_no_discount_on_small_orders(): void
    {
        $user = ['is_member' => false];
        $this->assertEquals(0.0, calculateDiscount(80.0, $user));
    }
}

Run it with vendor/bin/phpunit tests/Unit/DiscountCalculatorTest.php. Four tests, four branches, each one asserting on a concrete number rather than just "it doesn't crash." This is exactly the kind of function that comes out of breaking up a large, tangled one — if you've read our piece on refactoring a messy function into clean code, this discount calculator is the same one extracted there, and it's a good example of why extraction and testability go hand in hand: you can't easily write a test like this against logic that's still buried four levels deep inside a 120-line function.

A Second Candidate: Testing a Generator Function

Discount math is an easy first example because the expected output is a single predictable number, but plenty of small utilities are worth testing even when their output is intentionally random. Take a password generator — something like the one behind our own password generator tool. You can't assert on the exact string it returns, since it's different every time by design, but you absolutely can assert on the properties that string is supposed to guarantee:

final class PasswordGeneratorTest extends TestCase
{
    public function test_generated_password_is_the_requested_length(): void
    {
        $password = generatePassword(16);

        $this->assertSame(16, strlen($password));
    }

    public function test_generated_password_contains_at_least_one_digit(): void
    {
        $password = generatePassword(16);

        $this->assertMatchesRegularExpression('/[0-9]/', $password);
    }

    public function test_two_generated_passwords_are_not_identical(): void
    {
        $first = generatePassword(16);
        $second = generatePassword(16);

        $this->assertNotSame($first, $second);
    }
}

That last test looks strange the first time you write it — you're not testing a specific value, you're testing that the function doesn't produce the same value twice, which is really a rough proxy for "the randomness is actually working." It won't catch every possible flaw in the randomness, but it will catch the embarrassing bug where someone accidentally seeds the generator with a fixed value or caches the first result. Testing randomness means testing guarantees about the output's shape, not the output itself.

Your First Feature Test: An HTTP Endpoint

Unit tests check functions in isolation; feature tests check that the whole thing works when wired together — routing, database, response codes. Here's a feature test for something like the redirect route in a URL shortener:

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ShortUrlRedirectTest extends TestCase
{
    use RefreshDatabase;

    public function test_visiting_a_short_code_redirects_to_the_original_url(): void
    {
        DB::table('urls')->insert([
            'long_url' => 'https://example.com/a-very-long-page',
            'short_code' => 'ab12cd3',
            'created_at' => now(),
        ]);

        $response = $this->get('/ab12cd3');

        $response->assertRedirect('https://example.com/a-very-long-page');
    }

    public function test_an_unknown_short_code_returns_a_404(): void
    {
        $response = $this->get('/does-not-exist');

        $response->assertNotFound();
    }
}

The RefreshDatabase trait resets the test database between tests, so each test starts from a clean slate and one test's data can never leak into another. This matters more than it seems: tests that pass or fail depending on what ran before them are worse than no tests at all, because they train you to distrust your own test suite.

The Mistake Almost Every Beginner Makes

The single most common mistake I see — and the one I made constantly early on — is testing implementation details instead of behavior. That means asserting on how a function does something rather than what it produces. A test like "assert that this private method got called" or "assert that this internal array has exactly this shape" ties your test suite to the current internal structure of the code.

The practical consequence: the moment you refactor — rename a variable, extract a helper, reorder two lines that don't affect the outcome — the test breaks, even though the actual behavior of the code didn't change at all. That's backwards. A good test suite should let you refactor freely and only fail when you've actually broken something a user would notice.

Compare these two ways of testing the order-processing example from earlier:

// Fragile: tests HOW the function works internally
public function test_process_order_calls_mail_function(): void
{
    // trying to intercept the raw mail() call and assert it fired
    // breaks the instant notifyCustomer() is renamed or restructured
}

// Solid: tests WHAT the function produces
public function test_a_valid_order_returns_success_with_correct_total(): void
{
    $order = ['id' => 1];
    $user = ['is_member' => true, 'country' => 'US', 'email' => 'a@example.com'];
    $items = [['name' => 'Widget', 'price' => 100, 'quantity' => 2]];

    $result = processOrder($order, $user, $items);

    $this->assertTrue($result['success']);
    $this->assertEqualsWithDelta(191.85, $result['total'], 0.01);
}

The second test only cares about the public contract: given these inputs, what comes out. You can rewrite every line inside processOrder — rename internal variables, split it into more functions, change the order of operations — and this test keeps passing as long as the actual output is still correct. That's the behavior you want from a test suite: confidence to change code, not fear of it.

Reading a Failure and Actually Using It

When a test fails, PHPUnit shows you the expected value, the actual value, and the line it happened on. Resist the urge to just update the expected value to match whatever the code currently outputs — that turns your test suite into documentation of bugs rather than protection against them. Before changing an assertion, always answer: did the code's behavior change on purpose, or did I just break something?

A Realistic Starting Checklist

  • Pick 3-5 functions where a wrong-but-plausible answer would be a real problem (money, permissions, dates)
  • Write unit tests for those first — they're fast and don't need a database
  • Add one feature test per critical user-facing flow (login, checkout, the core action your app performs)
  • Assert on outputs and observable behavior, never on internal implementation details
  • Run the full suite before every deploy, not just when you remember to

You don't need dozens of tests to get real value from this. Even a small handful covering your riskiest logic — the kind of function you'd hesitate to touch without re-reading it carefully — changes how confidently you can ship changes. If you're also cleaning up an old function while you're at it, our guide on refactoring a messy function pairs naturally with this one: extract the logic into small pieces, then write a test for each piece before you trust it again.

The four-day silent bug that pushed me into testing never showed up again after I added tests around that discount logic. Not because the tests are magic, but because they turned "did I break this" from a question I had to guess at into one I could just run and answer in under a second.

Comments

Join the conversation on this article.

Comments are rendered server-side so the discussion stays visible to readers without relying on a separate widget or client-side app.

No comments yet.

Be the first visitor to add a thoughtful comment on this article.

Leave a comment

Share a useful thought, question, or response.

Be constructive, stay on topic, and avoid posting personal or sensitive information.

Back to Blog More in Programming Tutorials Free Resources Explore Tools