Writing My First Tests with PHPUnit
From installing PHPUnit to writing my first unit tests — what worked, what tripped me up, and the lessons learned along the way.
I put off writing tests for a long time. The phrase “I’ll add tests once the project is done” sat in the back of my mind for years — and that “once” never came. This year I did things differently: I sat down and actually wrote tests with PHPUnit for a small feature. This post is about that experience — not theory, but what I did in practice.
Installing PHPUnit
If you’re using Laravel, PHPUnit already ships under the require-dev section of composer.json. To add it to a standalone project:
composer require --dev phpunit/phpunit
Creating a phpunit.xml configuration file at the project root is a good habit. Laravel provides this file by default; in other projects, the bare minimum looks like this:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
<testsuites>
<testsuite name="Application Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
My First Unit Test
A unit test means testing a single class or method in isolation. You disable external dependencies, the database, and third-party services, then ask one question: does this method behave correctly on its own?
I started with a price calculation class. It’s simple: it takes an amount, adds tax, and returns the total price.
<?php
namespace App\Services;
class PriceCalculator
{
public function withTax(float $price, float $taxRate = 0.18): float
{
return round($price * (1 + $taxRate), 2);
}
}
The test for this class:
<?php
namespace Tests\Unit;
use App\Services\PriceCalculator;
use PHPUnit\Framework\TestCase;
class PriceCalculatorTest extends TestCase
{
public function test_withTax_returns_correct_total()
{
$calculator = new PriceCalculator();
$result = $calculator->withTax(100.00);
$this->assertEquals(118.00, $result);
}
public function test_withTax_applies_custom_rate()
{
$calculator = new PriceCalculator();
$result = $calculator->withTax(200.00, 0.08);
$this->assertEquals(216.00, $result);
}
}
To run the test:
./vendor/bin/phpunit tests/Unit/PriceCalculatorTest.php
Seeing the green output gave me a small but tangible sense of satisfaction. Not just knowing “this code works” — but knowing “there is something that proves this code works.”
Things I Noticed While Writing Tests
After the first few tests, I realized something: writing tests isn’t hard, but writing testable code requires deliberate thought.
Early on, I used to pack database queries, file reads, and business logic all into the same method. When I wanted to test it, it was impossible without a database. That was a dividing line: if I can’t test a method in isolation, that method is probably carrying too many responsibilities.
Writing the tests forced me to gradually clean up the code.
Laravel Feature Tests
Beyond unit tests, Laravel makes it easy to write feature tests as well. These tests simulate an HTTP request, letting you verify that routes, controllers, and middleware all work together — without a real browser.
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class OrderTest extends TestCase
{
use RefreshDatabase;
public function test_authenticated_user_can_create_order()
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson('/api/orders', [
'product_id' => 1,
'quantity' => 2,
]);
$response->assertStatus(201);
$this->assertDatabaseHas('orders', ['user_id' => $user->id]);
}
}
The RefreshDatabase trait resets the database before each test, so tests don’t bleed into one another. With actingAs I can simulate a logged-in user. This infrastructure keeps test writing practical even under complex conditions.
Choosing What to Test
You don’t need to test everything — and you can’t. What matters early on is deciding what’s important. My current focus areas are:
- Service classes that contain business logic
- Pure functions: calculations, transformations, filtering
- API endpoints — especially those that must accept or reject specific data
Testing trivial methods like getters and setters, or simple output, is a waste of time. I prioritize logic where a mistake has a high cost.
Conclusion
Writing your first tests isn’t a steep learning curve. The hard part is turning it into a habit. I started small: I added tests to an existing service class, stopped there, then added one more. Once the rhythm of writing tests set in, I started asking “can I test this?” while writing code too. That question, in turn, shapes the code for the better.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.