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 testscolors="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 
$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  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  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
mainand on every pull request targetingmain - 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-phpaction, which is the standard for PHP CI. We enable theintlandmbstringextensions our package needs, andpcovfor 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:

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.