100% code coverage means nothing if your tests only verify that happy paths return 200. I have seen codebases with perfect coverage that crash on the first invalid input. Coverage is a metric. It is not a goal. The goal is confidence — the certainty that your code behaves correctly when reality deviates from the ideal.
A test that never fails is a test that never tells you anything.
Unit tests verify logic in isolation. They are fast, deterministic, and the foundation of your test pyramid. But they are also the most abused. Mocking everything turns your test into a tautology — you verify that your mock behaves the way you told it to behave. That is not testing. That is performance art.
<?php
use PHPUnit\Framework\TestCase;

class PaymentProcessorTest extends TestCase
{
    // WRONG: testing the mock, not the logic
    public function testProcessPaymentWithMock(): void
    {
        $gateway = $this->createMock(PaymentGateway::class);
        $gateway->method('charge')->willReturn(true);

        $processor = new PaymentProcessor($gateway);
        $this->assertTrue($processor->process(100));
        // Congratulations, you proved that true === true
    }

    // CORRECT: test behavior, not implementation
    public function testDeclinesInvalidAmount(): void
    {
        $processor = new PaymentProcessor(new FakePaymentGateway());

        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage('Amount must be positive');

        $processor->process(-50);
    }

    public function testRecordsFailedTransaction(): void
    {
        $gateway = new FakePaymentGateway();
        $gateway->failNext('Card declined');

        $processor = new PaymentProcessor($gateway);
        $result = $processor->process(100);

        $this->assertFalse($result->success);
        $this->assertEquals('Card declined', $result->error);
        $this->assertNotNull($result->failedAt);
    }
}
Integration tests verify that your components work together. They hit the database. They make HTTP requests. They are slower than unit tests but infinitely more valuable because they catch the bugs that unit tests cannot — connection pool exhaustion, serialization mismatches, transaction deadlocks. A good integration test is worth fifty unit tests that mock the database.
<?php
// Integration test with real database
class PostRepositoryTest extends TestCase
{
    private PDO $db;
    private PostRepository $repo;

    protected function setUp(): void
    {
        $this->db = new PDO('sqlite::memory:');
        $this->db->exec(file_get_contents(__DIR__ . '/schema.sql'));
        $this->repo = new PostRepository($this->db);
    }

    public function testFindBySlugReturnsPublishedPost(): void
    {
        $this->db->exec("INSERT INTO posts (slug, title, published) VALUES ('hello', 'Hello', 1)");

        $post = $this->repo->findBySlug('hello');

        $this->assertNotNull($post);
        $this->assertEquals('Hello', $post->title);
    }

    public function testFindBySlugExcludesUnpublished(): void
    {
        $this->db->exec("INSERT INTO posts (slug, title, published) VALUES ('draft', 'Draft', 0)");

        $post = $this->repo->findBySlug('draft');

        $this->assertNull($post);
    }

    protected function tearDown(): void
    {
        $this->db = null; // Close connection
    }
}
Property-based testing is the next level. Instead of writing individual test cases, you define properties that must always hold. The test framework generates thousands of random inputs and verifies the property. It finds edge cases you would never think to test — empty strings, negative numbers, Unicode edge cases, maximum integer values.
<?php
// Property-based testing with Eris
use Eris\Generator;
use Eris\TestTrait;

class StringManipulationTest extends TestCase
{
    use TestTrait;

    public function testReversingTwiceReturnsOriginal(): void
    {
        $this->forAll(Generator::string())
            ->then(function (string $input) {
                $this->assertEquals(
                    $input,
                    strrev(strrev($input))
                );
            });
    }

    public function testSlugifyAlwaysProducesValidSlug(): void
    {
        $this->forAll(Generator::string())
            ->then(function (string $input) {
                $slug = slugify($input);

                // These must ALWAYS be true, no matter the input
                $this->assertMatchesRegularExpression('/^[a-z0-9-]*$/', $slug);
                $this->assertStringStartsNotWith('-', $slug);
                $this->assertStringEndsNotWith('-', $slug);
                $this->assertStringNotContainsString('--', $slug);
            });
    }
}
End-to-end tests verify the complete user journey. They are slow, flaky, and expensive. But they are the only tests that prove your application actually works from the user's perspective. A login flow. A checkout process. A file upload. These cannot be tested with unit tests alone. Use them sparingly — for critical paths only — and run them before every production deployment.
The test pyramid still matters. 70% unit tests for fast feedback on logic. 20% integration tests for component interaction. 10% end-to-end tests for critical user flows. But quality beats quantity. One integration test that verifies database constraints is worth ten unit tests that mock the database into oblivion. Write tests that would catch the bugs that have hurt you before. Everything else is coverage theater.