mazer / rewrite-dagger
A php dagger that chisel out the "seam" for testing by rewrite code instead of using any extensions like runkit.
Installs: 3
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 1
Forks: 0
Open Issues: 0
pkg:composer/mazer/rewrite-dagger
Requires
- php: ^7.2
Requires (Dev)
- friendsofphp/php-cs-fixer: *
- phpunit/phpunit: ^8 || ^9
This package is auto-updated.
Last update: 2025-11-27 14:17:10 UTC
README
A php test tool that mock anything without using any extensions.
Table of content
Install
composer require mazer/rewrite-dagger
Features
RewriteDagger can mock anything that test target dependened. No matter those function and class is from PHP buildin, third party or your project. By rewriting the test target code before including and evaluating it, RewriteDagger can replace any words and content present in the test target without any extension.
Usage
Quick start
There is a PHP function that needs to be tested as follows
- header set
- output json
- exit
<?php function apiErrorResponse(string $errorMsg): void { header('Content-type: application/json; charset=utf-8'); echo(json_encode([ 'data' => [ 'error' => true, 'message' => $errorMsg ] ])); exit(); }
This function is difficult to test because it has a built-in PHP function (header) that is difficult to sense input and a PHP language construct (exit) that terminates the script execution.
To solve this problem, we test it by PHPUnit and RewriteDagger.
<?php declare(strict_types=1); use PHPUnit\Framework\TestCase; use RewriteDagger\DaggerFactory; // Mock class that can sense and save function result class Mock { static $exitHasCalled = false; static $header = ''; static public function exit(): void { self::$exitHasCalled = true; } static public function header(string $header): void { self::$header = $header; } } final class ApiErrorResponseTest extends TestCase { public function testApiErrorResponse(): void { $dagger = (new DaggerFactory())->getDagger(); // add rewrite rule $dagger->addReplaceRule('exit', 'Mock::exit'); $dagger->addReplaceRule('header', 'Mock::header'); // include apiErrorResponse function $dagger->includeCode(__DIR__ . '/apiErrorResponse.php'); // call test function ob_start(); apiErrorResponse('test error message'); $output = ob_get_clean(); // assert expect and actual value $this->assertTrue(Mock::$exitHasCalled); $this->assertSame('Content-type: application/json; charset=utf-8', Mock::$header); $this->assertSame('{"data":{"error":true,"message":"test error message"}}', $output); } }
$ vendor/bin/phpunit ApiErrorResponseTest.php
PHPUnit 9.4.0 by Sebastian Bergmann and contributors.
Api Error Response
✔ Api error response
Time: 00:00.015, Memory: 6.00 MB
OK (1 test, 3 assertions)
With RewriteDagger, we can easily replace header and exit with other class that can sense input and will not terminate script execution.
How it works
As Features say, RewriteDagger rewrite the test target code before include and evaluate it.
To achieve this function, it has three core parts:
- Dagger: rewrite test target code
- CodeRepository: include and evaluate test target code
- DaggerFactory: create Dagger
Dagger mainly focuses on various rewriting rule itself, and uses the CodeRepository injected by DaggerFactory to operate, include and evaluate the code.
(Drawing by skanaar/nomnoml)
Next, we explain the usage of these three components separately.
Dagger
__construct(CodeRepositoryInterface $codeRepository)
Dagger dependents on any implementation of CodeRepositoryInterface to help it evaluate the rewritten code.
includeCode(String $path): void
Include, rewrite, evaluate code file corresponding to $path.
Dagger can have multiple rewriting rules. When includeCode is called, Dagger executes all these rules on the code before evaluating it.
addDeleteRule(String $from): void
$dagger->addDeleteRule('is a number.');
| before | after |
|---|---|
42 is a number. |
42 |
testAddRegexDeleteRule(String $from): void
$dagger->addRegexDeleteRule('/\d+/');
| before | after |
|---|---|
42 is a number. |
is a number. |
addReplaceRule(String $from, String $to): void
$dagger->addReplaceRule('is a number', ': Answer to the Ultimate Question of Everything');
| before | after |
|---|---|
42 is a number. |
42 : Answer to the Ultimate Question of Everything. |
addRegexReplaceRule(String $from, String $to): void
$dagger->addRegexReplaceRule('/\d+/', 'Number');
| before | after |
|---|---|
42 is a number. |
Number is a number. |
addInsertBeforeRule(String $from, String $to): void
$dagger->addInsertBeforeRule('number', 'answer and ');
| before | after |
|---|---|
42 is a number. |
42 is a answer and number. |
addRegexInsertBeforeRule(String $from, String $to): void
$dagger->addRegexInsertBeforeRule('/\d+/', '(Number) ');
| before | after |
|---|---|
42 is a number. |
(Number) 42 is a number. |
addInsertAfterRule(String $from, String $to): void
$dagger->addInsertAfterRule('number', ' and answer');
| before | after |
|---|---|
42 is a number. |
42 is a number and answer. |
addRegexInsertAfterRule(String $from, String $to): void
$dagger->addRegexInsertAfterRule('/\d+/', ' (Number)');
| before | after |
|---|---|
42 is a number. |
42 (Number) is a number. |
addRegexReplaceCallbackRule(String $from, callable $callback): void
$dagger->addRegexReplaceCallbackRule('/^(\d+).*(number)\.$/', function ($match) { return "[{$match[1]}] is a ({$match[2]})."; });
| before | after |
|---|---|
42 is a number. |
[42] is a (number). |
testRemoveAllRules(): void
Remove all rules set before.
CodeRepository
All codeRepository is the implementation of CodeRepositoryInterface which provide
getCodeContent(string $path): string: get code content that corresponds to$path.includeCode(string $codeContent): void: evaluate$codeContent.
In PHP, there are two ways to evaluate a string as code. One is to write the string as a real file then include() or require() it, the other is to use eval() function. RewriteDagger implements them in FileCodeRepository and EvalCodeRepository respectively.
(Drawing by skanaar/nomnoml)
FileCodeRepository
__construct(string $tempPath = null)
FileCodeRepository writes a string to a temporary file in $tempPath with a unique name, then includes and evaluates it. If $tempPath is null, FileCodeRepository automatically generated it by sys_get_temp_dir().
- IncludeFileCodeRepository: use
include()includes and evaluates the file. - RequireFileCodeRepository: use
require()includes and evaluates the file.
EvalCodeRepository
__construct()
EvalCodeRepository is much simpler than FileCodeRepository, it eval() the input string directly.
DaggerFactory
Generally, unless you want to use a custom CodeRepository in Dagger, Dagger and CodeRepository are usually created by DaggerFactory instead of manually.
getDagger(array $config = []): Dagger
<?php // default is use IncludeFileCodeRepository $dagger = (new DaggerFactory())->getDagger(); // explicit use IncludeFileCodeRepository $dagger = (new DaggerFactory())->getDagger([ 'codeRepositoryType' => 'include', 'tempPath' => 'your/temp/path/' ]);
| config key | description |
|---|---|
| codeRepositoryType | enum {include, require, eval} |
| tempPath | temp path for FileCodeRepository |
initDagger(Dagger $dagger): Dagger
initDagger is a protected function that allows you to customize DaggerFactory, which can operate on Dagger before return it (mainly adding default rules).
<?php class CustomDaggerFactory extends DaggerFactory { protected function initDagger(Dagger $dagger): Dagger { $dagger->addDeleteRule('exit()'); return $dagger; } } // all dagger create by CustomDaggerFactory has a delete exit() rule by default $dagger = (new CustomDaggerFactory())->getDagger();
Testing
All the commands for testing are defined in composer.json.
The only thing you need to notice is that phpunit varsion in composer.lock is 9, which don't support php 7.2.
But RewriteDagger does support php 7.2, so if you are using php 7.2, make sure to run compoesr update to change phpunit varsion to 8 before testing.
{
// ...
"scripts": {
"test": "phpunit",
"testWithCoverage": "phpunit --coverage-text --whitelist src/ --colors",
"codingStyleCheck": "php-cs-fixer fix ./ --dry-run --diff"
},
// ...
}
test without coverage
composer test
test with coverage
composer testWithCoverage
check coding style
composer codingStyleCheck
Disadvantage
The two biggest disadvantages of using RewriteDagger are reduced test coverage and readability.
- test coverage: Since the rewritten code does not belong to the original code in the project, for most test coverage tools, the original code in the project is not actually executed.
- readability: People who read the test program must understand all of the tested target to understand the side effects of each rewriting rule.
Conclusion
RewriteDagger is not intended to be a modern, full-featured mocking framework. It trades strict structure and advanced language features for simplicity, zero-extension deployment, and compatibility with legacy PHP systems (environments that may still run on PHP 4/5).
This design inevitably introduces complexity in the rewritten code, and prevents the use of certain newer PHP features or AST-based tooling. However, for teams dealing with legacy codebases where the original source cannot be modified safely, RewriteDagger provides a practical, low-friction shielding layer that enables testing without invasive refactoring.
Inspire
RewriteDagger is inspired by the book 《Working Effectively with Legacy Code》 ( ISBN 13: 978-0131177055). Hope to provide Link Seams in PHP to make legacy PHP code easier to test.
Related repo
License
MIT License