0

Writing your first unit test — assertions, test methods

Beginner5 min read·php-14-003

Concept

A PHPUnit test is a public method in a class that extends PHPUnit\Framework\TestCase. The method must either be prefixed with test or carry the #[Test] attribute (PHPUnit 10+). The body of a test method follows the Arrange-Act-Assert pattern: set up the context, execute the code under test, and assert on the result.

Assertions are the core of PHPUnit. Each assert* call compares actual behavior against expected behavior. When an assertion fails, PHPUnit throws an AssertionFailedError, records the failure, and (depending on configuration) moves to the next test. The most important thing to understand is that assertions should be specific—prefer assertSame(42, $result) over assertTrue($result === 42) because the former produces a useful failure message ("Expected 42 but got 43") while the latter only says "false is not true."

PHPUnit 10+ moved away from annotations (@test, @covers) toward PHP 8 attributes (#[Test], #[CoversClass]). The attributes version is type-safe and IDE-discoverable. Always use attributes in new code.

The setUp() and tearDown() methods run before and after each test method respectively. Use setUp() to create the object under test—never share state between tests by storing it in properties that aren't reset in setUp(). Shared mutable state between tests is the number one cause of order-dependent test failures.

A test that makes no assertions is marked "risky" by PHPUnit. If you're testing that code runs without throwing, add $this->expectNotToPerformAssertions() to be explicit, or use $this->addToAssertionCount(1).

Code Example

php
<?php
declare(strict_types=1);

namespace Tests\Unit;

use App\Domain\Money;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;

#[CoversClass(Money::class)]
final class MoneyTest extends TestCase
{
    // setUp runs before EACH test method — reset all state here
    protected function setUp(): void
    {
        parent::setUp();
        // nothing shared needed here, but if you had a DB you'd reset it
    }

    // Method prefix 'test' — no attribute needed, but less explicit
    public function test_can_be_created_from_integer_cents(): void
    {
        // Arrange
        $cents = 1099;

        // Act
        $money = Money::fromCents($cents);

        // Assert — assertSame checks value AND type (strict)
        $this->assertSame(1099, $money->toCents());
        $this->assertSame('10.99', $money->format());
    }

    // PHP 8 attribute style — preferred in new code
    #[Test]
    public function it_adds_two_money_values(): void
    {
        $a = Money::fromCents(500);
        $b = Money::fromCents(300);

        $sum = $a->add($b);

        $this->assertSame(800, $sum->toCents());
        // assertInstanceOf for type checks
        $this->assertInstanceOf(Money::class, $sum);
    }

    #[Test]
    public function it_throws_when_created_with_negative_cents(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Cents cannot be negative');

        Money::fromCents(-1);
    }

    #[Test]
    public function it_formats_zero_correctly(): void
    {
        $money = Money::fromCents(0);

        // Multiple assertions are fine when they test the same behavior
        $this->assertSame('0.00', $money->format());
        $this->assertTrue($money->isZero());
    }
}

// The class under test
class Money
{
    private function __construct(private readonly int $cents) {}

    public static function fromCents(int $cents): self
    {
        if ($cents < 0) {
            throw new \InvalidArgumentException('Cents cannot be negative');
        }
        return new self($cents);
    }

    public function toCents(): int { return $this->cents; }
    public function isZero(): bool { return $this->cents === 0; }
    public function add(self $other): self { return new self($this->cents + $other->cents); }

    public function format(): string
    {
        return number_format($this->cents / 100, 2, '.', '');
    }
}

Interview Q&A

Q: What is the difference between assertEquals and assertSame in PHPUnit, and which should you default to?

assertEquals uses loose comparison (like ==), which means assertEquals(0, "foo") passes in PHP because "foo" coerces to 0. assertSame uses strict comparison (===), checking both value and type. You should default to assertSame for scalar values because it catches type coercion bugs. assertEquals is only appropriate when comparing objects by their properties (where === would require the same instance) or floating-point values where you use assertEqualsWithDelta.


Q: Why should each test method be fully independent, and what problems arise when tests share mutable state?

When tests share mutable state—such as a static property or a class-level array that isn't reset in setUp()—the result of one test can alter the preconditions of another. This creates order-dependent tests: the suite passes when run in one order but fails in another. PHPUnit can randomize test order (which you should do in CI), making such failures unpredictable and hard to diagnose. The fix is always: reset everything in setUp(), never rely on another test having run first.


Q: What does PHPUnit mean when it marks a test as "risky," and how should you handle it?

PHPUnit marks a test risky when it completes without executing any assertions. This is a warning that the test might not actually be testing anything—for example, if you wrote a test that exercises code but forgot to add assertions, or if an exception was silently swallowed. If the test is intentionally verifying that code runs without throwing, you must explicitly signal this with $this->expectNotToPerformAssertions(). Never leave risky tests silently in your suite—enable beStrictAboutTestsThatDoNotTestAnything in phpunit.xml to make them failures.