Part 4 of 12 in the PHP Package Development series

You've got a working package with real code. The Str class has methods for slugifying text, truncating strings, extracting excerpts, and converting Markdown to plain text. It works great - right now. But what happens when you refactor something next week? What about when someone submits a pull request? How do you know nothing is broken?

That's where testing comes in. And for packages specifically, tests aren't optional - they're essential. In this post, we'll set up a proper test suite for our jakovic/text-toolkit package using both PHPUnit and Pest, write thorough tests for every method, and set up GitHub Actions to run them automatically on every push.

Why Testing Matters Even More for Packages

When you're building an application, you can get away with manual testing for a while. Click around, see if things look right. But a package is different. You don't have a UI to click through. Your code runs in other people's projects - environments you've never seen and can't control.

Here's why testing is non-negotiable for packages:

  • Trust. Developers look at a package's test suite before installing it. No tests? No trust. Would you install a package with zero tests in your production app?
  • Confidence in releases. When you tag v1.2.0, you need to know you didn't accidentally break something from v1.1.0. Tests give you that confidence.
  • Documentation by example. Good tests show exactly how your code is supposed to be used. They're living documentation that never gets outdated.
  • Safe refactoring. Want to rewrite your slugify() method to be faster? Run the tests. If they pass, you're good.
  • Contribution-friendly. When someone submits a PR, CI runs the tests automatically. No manual review of edge cases needed - the tests catch regressions.

The PHP package ecosystem has high standards. Browse popular packages on Packagist and you'll see test coverage badges, CI status indicators, and well-organized test directories. Let's get our package up to that standard.

PHPUnit vs Pest - Which One?

PHP has two major testing frameworks: PHPUnit and Pest.

PHPUnit is the original. It's been around since 2004, it's battle-tested, and virtually every PHP package uses it under the hood. Tests are written as class methods, and the API is extensive.

Pest is built on top of PHPUnit. It adds an expressive, closure-based syntax inspired by Jest (from the JavaScript world). It's less verbose, easier to read, and has been adopted rapidly since its release. Under the hood, Pest runs PHPUnit - so you get all the power with a nicer API.

Here's the same test in both styles:

PHPUnit style:

use PHPUnit\Framework\TestCase;
use Jakovic\TextToolkit\Str;

class StrTest extends TestCase
{
 public function test_slugify_converts_text_to_slug(): void
 {
 $this->assertSame('hello-world', Str::slugify('Hello World'));
 }
}

Pest style:

use Jakovic\TextToolkit\Str;

it('converts text to a slug', function () {
 expect(Str::slugify('Hello World'))->toBe('hello-world');
});

Both do the same thing. Pest is just more concise and reads more naturally. For this series, we'll use Pest as our primary testing tool, but I'll show the PHPUnit equivalents where it helps. Since Pest is built on PHPUnit, everything we set up works with both.

Installing the Testing Tools

Let's install Pest (which includes PHPUnit as a dependency). From your package's root directory:

composer require pestphp/pest --dev --with-all-dependencies

The --dev flag is important. Testing tools are development dependencies - they shouldn't be installed when someone requires your package in their project. Composer handles this automatically: composer install in your package gets everything, but when someone does composer require jakovic/text-toolkit in their app, dev dependencies are skipped.

Now initialize Pest:

./vendor/bin/pest --init

This creates two things:

  • tests/Pest.php - Pest's configuration file (we'll use this later)
  • tests/ExampleTest.php - A sample test to get you started

You can delete tests/ExampleTest.php - we'll write our own tests from scratch.

Configuring phpunit.xml

Even though we're using Pest, we still need a phpunit.xml file. Remember, Pest runs on top of PHPUnit, so this file configures the test runner for both. Create phpunit.xml in your package root:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit
 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
 xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
 bootstrap="vendor/autoload.php"
 colors="true"
 failOnRisky="true"
 failOnWarning="true"
>
 <testsuites>
 <testsuite name="Unit">
 <directory>tests</directory>
 </testsuite>
 </testsuites>
 <source>
 <include>
 <directory>src</directory>
 </include>
 </source>
</phpunit>

Let's break down the key attributes:

  • bootstrap="vendor/autoload.php" - Loads Composer's autoloader so your classes are available in tests
  • colors="true" - Makes terminal output colorful (green for passing, red for failing)
  • failOnRisky="true" - Fails tests that don't make any assertions (catches tests that accidentally pass)
  • failOnWarning="true" - Treats warnings as failures so you catch deprecations early
  • The <testsuites> block tells PHPUnit where to find test files
  • The <source> block tells PHPUnit which source files to track for code coverage

Updating composer.json

We need to add autoloading for our test files and a convenient script to run tests. Update your composer.json:

{
 "name": "jakovic/text-toolkit",
 "description": "A collection of text manipulation utilities for PHP",
 "type": "library",
 "license": "MIT",
 "require": {
 "php": "^8.1"
 },
 "require-dev": {
 "pestphp/pest": "^3.0"
 },
 "autoload": {
 "psr-4": {
 "Jakovic\\TextToolkit\\": "src/"
 }
 },
 "autoload-dev": {
 "psr-4": {
 "Jakovic\\TextToolkit\\Tests\\": "tests/"
 }
 },
 "scripts": {
 "test": "pest",
 "test-coverage": "pest --coverage"
 },
 "config": {
 "allow-plugins": {
 "pestphp/pest-plugin": true
 }
 }
}

The autoload-dev section maps test namespaces, and the scripts section lets us run composer test instead of ./vendor/bin/pest. The allow-plugins config is required by Composer 2.2+ to explicitly allow Pest's plugin.

Your Package Structure So Far

Before we write tests, let's make sure we're on the same page. Your directory should look like this:

text-toolkit/
├── composer.json
├── phpunit.xml
├── src/
└── Str.php
└── tests/
 └── Pest.php

And here's the Str class we built in Part 3 (the code we're about to test):

<?php

namespace Jakovic\TextToolkit;

class Str
{
 public static function slugify(string $text, string $separator = '-'): string
 {
 $text = transliterator_transliterate(
 'Any-Latin; Latin-ASCII; Lower()',
 $text
 );

 $text = preg_replace('/[^a-z0-9]+/', $separator, $text);
 $text = trim($text, $separator);

 return $text;
 }

 public static function truncate(
 string $text,
 int $length = 100,
 string $suffix = '...'
 ): string {
 if (mb_strlen($text) <= $length) {
 return $text;
 }

 return mb_substr($text, 0, $length) . $suffix;
 }

 public static function excerpt(
 string $text,
 int $limit = 200,
 string $end = '...'
 ): string {
 $text = strip_tags($text);
 $text = preg_replace('/\s+/', ' ', $text);
 $text = trim($text);

 if (mb_strlen($text) <= $limit) {
 return $text;
 }

 $truncated = mb_substr($text, 0, $limit);
 $lastSpace = mb_strrpos($truncated, ' ');

 if ($lastSpace !== false) {
 $truncated = mb_substr($truncated, 0, $lastSpace);
 }

 return $truncated . $end;
 }

 public static function markdownToText(string $markdown): string
 {
 $text = $markdown;

 // Remove headers
 $text = preg_replace('/^#{1,6}\s+/m', '', $text);

 // Convert bold/italic
 $text = preg_replace('/\*\*(.+?)\*\*/', '$1', $text);
 $text = preg_replace('/\*(.+?)\*/', '$1', $text);
 $text = preg_replace('/__(.+?)__/', '$1', $text);
 $text = preg_replace('/_(.+?)_/', '$1', $text);

 // Convert links [text](url) to just text
 $text = preg_replace('/\[(.+?)\]\(.+?\)/', '$1', $text);

 // Remove images ![alt](url)
 $text = preg_replace('/!\[.*?\]\(.+?\)/', '', $text);

 // Remove inline code
 $text = preg_replace('/`(.+?)`/', '$1', $text);

 // Remove code blocks
 $text = preg_replace('/```[\s\S]*?```/', '', $text);

 // Remove blockquotes
 $text = preg_replace('/^>\s?/m', '', $text);

 // Remove horizontal rules
 $text = preg_replace('/^[-*_]{3,}\s*$/m', '', $text);

 // Clean up whitespace
 $text = preg_replace('/\n{3,}/', "\n\n", $text);
 $text = trim($text);

 return $text;
 }
}

Writing Your First Test

Create a file at tests/StrTest.php. In Pest, test files don't need to be classes - they're just PHP files with test functions:

<?php

use Jakovic\TextToolkit\Str;

it('converts a simple string to a slug', function () {
 expect(Str::slugify('Hello World'))->toBe('hello-world');
});

Run it:

./vendor/bin/pest

You should see something like:

 PASS Tests\StrTest
it converts a simple string to a slug

 Tests: 1 passed (1 assertions)
 Duration: 0.05s

Green output. That's the feeling you want. Now let's write thorough tests for every method.

Testing the slugify() Method

A good test suite doesn't just test the happy path. It tests edge cases, special characters, empty strings, and weird inputs. Here's what thorough slugify() tests look like:

<?php

use Jakovic\TextToolkit\Str;

// --- slugify() ---

it('converts a simple string to a slug', function () {
 expect(Str::slugify('Hello World'))->toBe('hello-world');
});

it('handles multiple spaces', function () {
 expect(Str::slugify('Hello World'))->toBe('hello-world');
});

it('removes special characters', function () {
 expect(Str::slugify('Hello, World! How are you?'))->toBe('hello-world-how-are-you');
});

it('handles uppercase text', function () {
 expect(Str::slugify('THIS IS UPPERCASE'))->toBe('this-is-uppercase');
});

it('trims leading and trailing separators', function () {
 expect(Str::slugify(' Hello World '))->toBe('hello-world');
});

it('uses a custom separator', function () {
 expect(Str::slugify('Hello World', '_'))->toBe('hello_world');
});

it('handles an empty string', function () {
 expect(Str::slugify(''))->toBe('');
});

it('transliterates accented characters', function () {
 expect(Str::slugify('cafe resume naive'))->toBe('cafe-resume-naive');
});

it('handles numbers in the string', function () {
 expect(Str::slugify('Article 42 is great'))->toBe('article-42-is-great');
});

Notice how each test has a descriptive name that reads like plain English. When a test fails, you'll see "it handles multiple spaces - FAILED" which immediately tells you what broke. Don't name your tests test1, test2, or testSlugify. Be specific about what behavior you're testing.

Testing truncate()

// --- truncate() ---

it('does not truncate text shorter than the limit', function () {
 expect(Str::truncate('Short', 100))->toBe('Short');
});

it('does not truncate text equal to the limit', function () {
 $text = str_repeat('a', 100);
 expect(Str::truncate($text, 100))->toBe($text);
});

it('truncates text longer than the limit', function () {
 $text = str_repeat('a', 150);
 $expected = str_repeat('a', 100) . '...';
 expect(Str::truncate($text, 100))->toBe($expected);
});

it('uses a custom suffix', function () {
 $text = str_repeat('a', 150);
 $expected = str_repeat('a', 100) . ' [more]';
 expect(Str::truncate($text, 100, ' [more]'))->toBe($expected);
});

it('truncates with an empty suffix', function () {
 $text = str_repeat('a', 150);
 $expected = str_repeat('a', 100);
 expect(Str::truncate($text, 100, ''))->toBe($expected);
});

it('handles an empty string for truncate', function () {
 expect(Str::truncate('', 100))->toBe('');
});

Notice the boundary test for text that is exactly equal to the limit. Boundary conditions are where bugs love to hide. If you have a <= vs < mistake, this test catches it.

Testing excerpt()

The excerpt() method is more complex - it strips HTML tags, normalizes whitespace, and breaks at word boundaries. That means more edge cases to test:

// --- excerpt() ---

it('strips HTML tags from the excerpt', function () {
 $html = '<p>Hello <strong>World</strong></p>';
 expect(Str::excerpt($html, 200))->toBe('Hello World');
});

it('normalizes whitespace in the excerpt', function () {
 $text = "Hello \n\n World ";
 expect(Str::excerpt($text, 200))->toBe('Hello World');
});

it('truncates at word boundaries', function () {
 $text = 'The quick brown fox jumps over the lazy dog';
 $result = Str::excerpt($text, 19);
 expect($result)->toBe('The quick brown fox...');
});

it('does not truncate text within the limit', function () {
 $text = 'Short text';
 expect(Str::excerpt($text, 200))->toBe('Short text');
});

it('uses a custom ending for excerpt', function () {
 $text = 'The quick brown fox jumps over the lazy dog';
 $result = Str::excerpt($text, 19, ' [...]');
 expect($result)->toBe('The quick brown fox [...]');
});

it('handles an empty string for excerpt', function () {
 expect(Str::excerpt('', 200))->toBe('');
});

Testing markdownToText()

For markdownToText(), we need to test each Markdown element individually. This makes it easy to pinpoint exactly which conversion fails:

// --- markdownToText() ---

it('removes headings', function () {
 expect(Str::markdownToText('# Title'))->toBe('Title');
 expect(Str::markdownToText('## Subtitle'))->toBe('Subtitle');
 expect(Str::markdownToText('###### Deep heading'))->toBe('Deep heading');
});

it('converts bold text', function () {
 expect(Str::markdownToText('This is **bold** text'))->toBe('This is bold text');
 expect(Str::markdownToText('This is __bold__ text'))->toBe('This is bold text');
});

it('converts italic text', function () {
 expect(Str::markdownToText('This is *italic* text'))->toBe('This is italic text');
 expect(Str::markdownToText('This is _italic_ text'))->toBe('This is italic text');
});

it('converts links to plain text', function () {
 $markdown = 'Visit [Google](https://google.com) for search';
 expect(Str::markdownToText($markdown))->toBe('Visit Google for search');
});

it('removes images', function () {
 $markdown = 'Text before ![alt text](image.png) text after';
 expect(Str::markdownToText($markdown))->toBe('Text before text after');
});

it('removes inline code backticks', function () {
 $markdown = 'Use the `echo` command';
 expect(Str::markdownToText($markdown))->toBe('Use the echo command');
});

it('removes code blocks', function () {
 $markdown = "Before\n\n```php\necho 'hello';\n```\n\nAfter";
 $result = Str::markdownToText($markdown);
 expect($result)->toContain('Before');
 expect($result)->toContain('After');
 expect($result)->not->toContain('echo');
});

it('removes blockquotes', function () {
 $markdown = "> This is a quote";
 expect(Str::markdownToText($markdown))->toBe('This is a quote');
});

it('handles an empty string for markdown', function () {
 expect(Str::markdownToText(''))->toBe('');
});

The Complete Test File

Here's the full tests/StrTest.php with all tests in one file:

<?php

use Jakovic\TextToolkit\Str;

// --- slugify() ---

it('converts a simple string to a slug', function () {
 expect(Str::slugify('Hello World'))->toBe('hello-world');
});

it('handles multiple spaces', function () {
 expect(Str::slugify('Hello World'))->toBe('hello-world');
});

it('removes special characters', function () {
 expect(Str::slugify('Hello, World! How are you?'))->toBe('hello-world-how-are-you');
});

it('handles uppercase text', function () {
 expect(Str::slugify('THIS IS UPPERCASE'))->toBe('this-is-uppercase');
});

it('trims leading and trailing separators', function () {
 expect(Str::slugify(' Hello World '))->toBe('hello-world');
});

it('uses a custom separator', function () {
 expect(Str::slugify('Hello World', '_'))->toBe('hello_world');
});

it('handles an empty string', function () {
 expect(Str::slugify(''))->toBe('');
});

it('transliterates accented characters', function () {
 expect(Str::slugify('cafe resume naive'))->toBe('cafe-resume-naive');
});

it('handles numbers in the string', function () {
 expect(Str::slugify('Article 42 is great'))->toBe('article-42-is-great');
});

// --- truncate() ---

it('does not truncate text shorter than the limit', function () {
 expect(Str::truncate('Short', 100))->toBe('Short');
});

it('does not truncate text equal to the limit', function () {
 $text = str_repeat('a', 100);
 expect(Str::truncate($text, 100))->toBe($text);
});

it('truncates text longer than the limit', function () {
 $text = str_repeat('a', 150);
 $expected = str_repeat('a', 100) . '...';
 expect(Str::truncate($text, 100))->toBe($expected);
});

it('uses a custom suffix', function () {
 $text = str_repeat('a', 150);
 $expected = str_repeat('a', 100) . ' [more]';
 expect(Str::truncate($text, 100, ' [more]'))->toBe($expected);
});

it('truncates with an empty suffix', function () {
 $text = str_repeat('a', 150);
 $expected = str_repeat('a', 100);
 expect(Str::truncate($text, 100, ''))->toBe($expected);
});

it('handles an empty string for truncate', function () {
 expect(Str::truncate('', 100))->toBe('');
});

// --- excerpt() ---

it('strips HTML tags from the excerpt', function () {
 $html = '<p>Hello <strong>World</strong></p>';
 expect(Str::excerpt($html, 200))->toBe('Hello World');
});

it('normalizes whitespace in the excerpt', function () {
 $text = "Hello \n\n World ";
 expect(Str::excerpt($text, 200))->toBe('Hello World');
});

it('truncates at word boundaries', function () {
 $text = 'The quick brown fox jumps over the lazy dog';
 $result = Str::excerpt($text, 19);
 expect($result)->toBe('The quick brown fox...');
});

it('does not truncate text within the limit', function () {
 $text = 'Short text';
 expect(Str::excerpt($text, 200))->toBe('Short text');
});

it('uses a custom ending for excerpt', function () {
 $text = 'The quick brown fox jumps over the lazy dog';
 $result = Str::excerpt($text, 19, ' [...]');
 expect($result)->toBe('The quick brown fox [...]');
});

it('handles an empty string for excerpt', function () {
 expect(Str::excerpt('', 200))->toBe('');
});

// --- markdownToText() ---

it('removes headings', function () {
 expect(Str::markdownToText('# Title'))->toBe('Title');
 expect(Str::markdownToText('## Subtitle'))->toBe('Subtitle');
 expect(Str::markdownToText('###### Deep heading'))->toBe('Deep heading');
});

it('converts bold text', function () {
 expect(Str::markdownToText('This is **bold** text'))->toBe('This is bold text');
 expect(Str::markdownToText('This is __bold__ text'))->toBe('This is bold text');
});

it('converts italic text', function () {
 expect(Str::markdownToText('This is *italic* text'))->toBe('This is italic text');
 expect(Str::markdownToText('This is _italic_ text'))->toBe('This is italic text');
});

it('converts links to plain text', function () {
 $markdown = 'Visit [Google](https://google.com) for search';
 expect(Str::markdownToText($markdown))->toBe('Visit Google for search');
});

it('removes images', function () {
 $markdown = 'Text before ![alt text](image.png) text after';
 expect(Str::markdownToText($markdown))->toBe('Text before text after');
});

it('removes inline code backticks', function () {
 $markdown = 'Use the `echo` command';
 expect(Str::markdownToText($markdown))->toBe('Use the echo command');
});

it('removes code blocks', function () {
 $markdown = "Before\n\n```php\necho 'hello';\n```\n\nAfter";
 $result = Str::markdownToText($markdown);
 expect($result)->toContain('Before');
 expect($result)->toContain('After');
 expect($result)->not->toContain('echo');
});

it('removes blockquotes', function () {
 $markdown = "> This is a quote";
 expect(Str::markdownToText($markdown))->toBe('This is a quote');
});

it('handles an empty string for markdown', function () {
 expect(Str::markdownToText(''))->toBe('');
});

Run all tests:

./vendor/bin/pest

You should see all tests passing with green checkmarks. If any fail, that's actually a good thing - it means you found a bug in your code before your users did.

Using Data Providers for Edge Cases

Some tests share the same logic but with different inputs. Instead of writing separate test functions for each case, Pest gives us a clean way to handle this with the with() method (Pest's version of PHPUnit data providers):

it('slugifies various inputs correctly', function (string $input, string $expected) {
 expect(Str::slugify($input))->toBe($expected);
})->with([
 'simple text' => ['Hello World', 'hello-world'],
 'special characters' => ['Hello, World!', 'hello-world'],
 'multiple spaces' => ['Hello World', 'hello-world'],
 'uppercase' => ['UPPERCASE TEXT', 'uppercase-text'],
 'numbers' => ['Post 42', 'post-42'],
 'leading/trailing space'=> [' hello ', 'hello'],
 'empty string' => ['', ''],
]);

When this runs, Pest creates a separate test for each dataset entry. If the "special characters" case fails, it tells you exactly which dataset failed - not just "test failed on line 42".

Here's a data provider for truncate() that tests boundary conditions:

it('truncates text correctly at various lengths', function (string $input, int $length, string $expected) {
 expect(Str::truncate($input, $length))->toBe($expected);
})->with([
 'shorter than limit' => ['Hello', 10, 'Hello'],
 'equal to limit' => ['Hello', 5, 'Hello'],
 'longer than limit' => ['Hello World', 5, 'Hello...'],
 'empty string' => ['', 10, ''],
 'limit of 1' => ['Hello', 1, 'H...'],
]);

For comparison, here's what the PHPUnit equivalent looks like with a traditional data provider:

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use Jakovic\TextToolkit\Str;

class StrTest extends TestCase
{
 #[DataProvider('slugifyProvider')]
 public function test_slugify(string $input, string $expected): void
 {
 $this->assertSame($expected, Str::slugify($input));
 }

 public static function slugifyProvider(): array
 {
 return [
 'simple text' => ['Hello World', 'hello-world'],
 'special characters' => ['Hello, World!', 'hello-world'],
 'multiple spaces' => ['Hello World', 'hello-world'],
 ];
 }
}

Both work, but Pest's with() keeps everything together and reads more naturally. That's why we're leaning toward Pest for this series.

Grouping Tests with describe()

As your test file grows, you can organize tests into groups using describe(). This gives your test output a nice hierarchy:

<?php

use Jakovic\TextToolkit\Str;

describe('slugify', function () {
 it('converts text to a slug', function () {
 expect(Str::slugify('Hello World'))->toBe('hello-world');
 });

 it('uses a custom separator', function () {
 expect(Str::slugify('Hello World', '_'))->toBe('hello_world');
 });
});

describe('truncate', function () {
 it('does not truncate short text', function () {
 expect(Str::truncate('Short', 100))->toBe('Short');
 });

 it('truncates long text with suffix', function () {
 expect(Str::truncate('Hello World', 5))->toBe('Hello...');
 });
});

describe('excerpt', function () {
 it('strips HTML tags', function () {
 expect(Str::excerpt('<p>Hello</p>', 200))->toBe('Hello');
 });
});

describe('markdownToText', function () {
 it('removes headings', function () {
 expect(Str::markdownToText('# Title'))->toBe('Title');
 });
});

The output will be grouped nicely:

 PASS Tests\StrTest > slugify
it converts text to a slug
it uses a custom separator

 PASS Tests\StrTest > truncate
it does not truncate short text
it truncates long text with suffix

 PASS Tests\StrTest > excerpt
it strips HTML tags

 PASS Tests\StrTest > markdownToText
it removes headings

This is entirely optional - flat test files work perfectly fine for smaller test suites. But as your package grows, describe() blocks make the output much easier to scan.

Pest's Expectation API

We've been using expect()->toBe(), but Pest has a rich set of expectations. Here are the ones you'll use most often:

// Strict equality (uses ===)
expect($value)->toBe('exact match');

// Loose equality (uses ==)
expect($value)->toEqual('loose match');

// String contains
expect($value)->toContain('partial');

// String starts/ends with
expect($value)->toStartWith('Hello');
expect($value)->toEndWith('world');

// String matches regex
expect($value)->toMatch('/^[a-z-]+$/');

// Type checks
expect($value)->toBeString();
expect($value)->toBeInt();
expect($value)->toBeBool();
expect($value)->toBeEmpty();
expect($value)->toBeNull();

// Negation - just chain not
expect($value)->not->toBeEmpty();
expect($value)->not->toContain('bad');

// Length checks
expect($value)->toHaveLength(10);

The key difference between toBe() and toEqual(): toBe() uses strict comparison (===), while toEqual() uses loose comparison (==). For string comparisons, always use toBe() - you want exact matches.

Test Coverage

Test coverage tells you what percentage of your source code is actually executed by your tests. It's not a perfect metric - 100% coverage doesn't mean zero bugs - but it's a useful indicator of untested paths.

To generate coverage reports, you need either Xdebug or PCOV installed. PCOV is faster and designed specifically for coverage, so it's the recommended choice:

# Install PCOV (if you don't have it)
pecl install pcov

# Run tests with coverage
./vendor/bin/pest --coverage

The output shows a coverage breakdown per file:

 PASS Tests\StrTest
it converts a simple string to a slug
it handles multiple spaces
 ... (all tests)

 Tests: 25 passed (30 assertions)
 Duration: 0.12s

 Str ...................................................... 100.0%

 Total Coverage: 100.0%

You can also set a minimum coverage threshold. If coverage drops below it, the test suite fails:

./vendor/bin/pest --coverage --min=90

This is great for CI pipelines - it prevents anyone from merging code that reduces test coverage below your threshold.

For an HTML coverage report that you can open in your browser:

./vendor/bin/pest --coverage --coverage-html=coverage-report

This creates a coverage-report/ directory with HTML files. Open index.html in your browser to see a visual breakdown of which lines are covered and which aren't. Remember to add coverage-report/ to your .gitignore.

Setting Up GitHub Actions CI

Continuous Integration (CI) runs your tests automatically on every push and pull request. This is how you catch bugs before they reach your users. GitHub Actions is free for open-source projects and easy to set up.

Create .github/workflows/tests.yml in your package:

name: Tests

on:
 push:
 branches: [main]
 pull_request:
 branches: [main]

jobs:
 tests:
 runs-on: ubuntu-latest

 strategy:
 matrix:
 php: ['8.1', '8.2', '8.3', '8.4']

 name: PHP ${{ matrix.php }}

 steps:
 - name: Checkout code
 uses: actions/checkout@v4

 - name: Setup PHP
 uses: shivammathur/setup-php@v2
 with:
 php-version: ${{ matrix.php }}
 extensions: intl, mbstring
 coverage: pcov

 - name: Install dependencies
 run: composer install --no-interaction --prefer-dist

 - name: Run tests
 run: ./vendor/bin/pest --coverage --min=80

Let's walk through what this does:

  • Triggers: Runs on every push to main and on every pull request targeting main
  • Matrix strategy: Runs your tests on PHP 8.1, 8.2, 8.3, and 8.4. This catches version-specific issues (like a function that was deprecated in 8.2 or a new feature you accidentally used from 8.3)
  • Setup PHP: Uses the shivammathur/setup-php action, which is the standard for PHP CI. We enable the intl and mbstring extensions our package needs, and pcov for coverage
  • Install dependencies: Runs Composer install with --prefer-dist (faster, downloads zips instead of cloning repos)
  • Run tests: Runs Pest with coverage and a minimum threshold of 80%

Once you push this file to GitHub, you'll see a "Tests" check on every PR. Green checkmark means all tests pass on all PHP versions. Red X means something broke.

Adding a Status Badge

Once your workflow is running, add a badge to your README to show the world your tests pass:

![Tests](https://github.com/your-username/text-toolkit/actions/workflows/tests.yml/badge.svg)

Replace your-username with your GitHub username. This badge updates automatically - green when tests pass, red when they fail. It's one of the first things developers look at when evaluating a package.

Running Tests Locally

Here's a quick reference for running tests during development:

# Run all tests
./vendor/bin/pest

# Or use the Composer script we defined
composer test

# Run a specific test file
./vendor/bin/pest tests/StrTest.php

# Run tests matching a filter
./vendor/bin/pest --filter="slugify"

# Run tests with coverage
composer test-coverage

# Run with minimum coverage threshold
./vendor/bin/pest --coverage --min=90

# Stop on first failure (useful during debugging)
./vendor/bin/pest --stop-on-failure

# Run tests in parallel (faster for large suites)
./vendor/bin/pest --parallel

The --filter flag is your best friend during development. Working on the slugify() method? Just run --filter="slugify" and only those tests execute. Once you're done, run the full suite to make sure nothing else broke.

Tips for Writing Good Tests

Before we wrap up, here are some testing principles that will serve you well:

  • Test behavior, not implementation. Don't test that your code calls preg_replace() three times. Test that the output is correct. If you refactor the internals, your tests should still pass.
  • One assertion per concept. It's fine to have multiple expect() calls in a test, but they should all be testing the same concept. Don't test slugify and truncate in the same test function.
  • Test edge cases first. Empty strings, null-like values, very long strings, strings with only special characters, Unicode text. These are where bugs hide.
  • Make test names readable. "it converts a simple string to a slug" is better than "testSlugify". When a test fails in CI, the name should tell you what broke without looking at the code.
  • Keep tests fast. Unit tests should run in milliseconds. If a test needs a database, file system, or network, it's an integration test - and you should structure it differently (we'll cover this in Part 10 when we test Laravel packages).
  • Don't test the language. You don't need to test that PHP's mb_strlen() works correctly. Test your logic, not built-in functions.

What's Next

Our package now has a solid test suite and CI pipeline. Tests run locally with a single command and automatically on GitHub for every push. That's a professional setup that gives both you and your users confidence in the code.

In Part 5, we'll take this tested, working package and publish it to Packagist. We'll cover Git setup, semantic versioning, tagging releases, and everything you need so that anyone in the world can composer require your package. See you there.