Lesson 21 / 21
Testing
The feature test and the unit test, a small verification function; a job being recorded but not running in a fake queue, the job running once the queue is drained, and the fake queue not measuring the body of the job.
Published Updated
In this lesson
- Writes a feature test that goes from request to response with a small verification function
- Measures that a job is recorded but does not run in a fake queue
- Tests the body of the job separately with a unit test where the feature test passed
Contents
In the Localization lesson the fallback language hid a missing translation behind an error-free response. The same happened in earlier lessons too: a job put in a queue runs after the response, and the response returns without waiting for the job. So how can we know inside a test, without a real worker, that the job ran? This lesson asks: if a test verifies that a job was recorded, has it also verified that the job ran?
A feature test gives a request to the application and verifies the response and the traces left beside it. A unit test tests a single class or function, separated from its surroundings. A fake is a simple look-alike put in place of a real dependency that records what is done to it: in this lesson the fake queue does not run the job, it only stores it.
AS12. No test library is used. The lesson builds a verifier with two functions in a Test class: verify() throws an error if the condition is false, and run() runs a test and writes passed or failed. There is no equality check, array comparison, detailed diff output or test discovery; every test is called by hand.
AS13. The queue is not real: FakeQueue puts the job in an array and does not run it until process() is called. push() marks the job with defer(), and process() marks every job with ran() and runs it. A real driver, retries, delays and error handling are not modeled. The job is a small calculation: ThumbnailJob, which divides the width by four, and FaultyThumbnailJob, which deliberately works wrongly.
The Measurement Core
The same measurement is used in every lesson of the course. ran.php records whether an expected part ran in this request; the file is given verbatim in every lesson, and it has no output block, since it is only saved to disk. Ledger keeps three lists: the parts placed on the request’s path, the parts called within the request, and the parts left outside the request. classify() checks them in order. The decision order is classify() itself: ran, deferred, skipped, not wired. The developer names the part; what is measured are the parts the developer expects to run in this request. The part measured in this lesson is the thumbnail job: when the job is recorded, onPath() and defer() are called, and when the queue is drained, ran() is called.
<?php // ran.php — did an expected part run in this request? declare(strict_types=1); final class Ledger { public static array $path = []; // parts placed on the request's path public static array $ran = []; // parts called within the request public static array $deferred = []; // parts left outside the request public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; } public static function onPath(string $part): void { self::$path[] = $part; } public static function ran(string $part): void { self::$ran[] = $part; } public static function defer(string $part): void { self::$deferred[] = $part; } } function classify(string $part): string { return match (true) { in_array($part, Ledger::$ran, true) => 'ran', in_array($part, Ledger::$deferred, true) => 'deferred', in_array($part, Ledger::$path, true) => 'skipped', default => 'not wired', }; }
The Code Under Test
app.php holds everything that is tested: the Job interface, two jobs, FakeQueue and UploadController. The controller takes a width, puts the job in the queue and returns 201. Which job class is used is given to the constructor; the default is the correct one. test.php is the verifier.
<?php // app.php — the code under test: the job interface, the fake queue and the upload controller declare(strict_types=1); require_once 'ran.php'; interface Job { public function name(): string; public function run(): int; } final class ThumbnailJob implements Job { public function __construct(private int $width) {} public function name(): string { return 'thumbnail'; } public function run(): int { return intdiv($this->width, 4); } } final class FaultyThumbnailJob implements Job { public function __construct(private int $width) {} public function name(): string { return 'thumbnail'; } public function run(): int { return $this->width * 4; } // wrong direction } final class FakeQueue { public array $jobs = []; public function push(Job $job): void { $this->jobs[] = $job; Ledger::defer($job->name()); } public function process(): array { $results = []; foreach ($this->jobs as $job) { Ledger::ran($job->name()); $results[] = $job->run(); } $this->jobs = []; return $results; } } final class UploadController { public function __construct(private FakeQueue $queue, private string $jobClass = ThumbnailJob::class) {} public function upload(int $width): array { Ledger::newRequest(); Ledger::onPath('thumbnail'); $this->queue->push(new ($this->jobClass)($width)); return [201, 'uploaded']; } }
<?php // test.php — no library: a small verifier with two functions declare(strict_types=1); final class Test { public static int $passed = 0; public static int $failed = 0; public static function verify(bool $condition, string $message): void { if (!$condition) { throw new RuntimeException($message); } } public static function run(string $name, callable $test): void { try { $test(); self::$passed++; $status = 'passed'; } catch (RuntimeException $error) { self::$failed++; $status = 'failed — ' . $error->getMessage(); } printf("%-28s %s\n", $name, $status); } public static function summary(): void { printf("total: %d passed, %d failed\n", self::$passed, self::$failed); } }
The Feature Test: The Job Was Recorded and Did Not Run
feature.php gives the request to the controller and verifies three things: is the response 201, did the job enter the queue, and did the job not run during the response. The last line writes the class of the thumbnail part.
<?php // feature.php — feature test: from request to response, with a fake queue require_once 'app.php'; require_once 'test.php'; $queue = new FakeQueue(); $response = (new UploadController($queue))->upload(800); Test::run('response returns 201', fn() => Test::verify($response[0] === 201, "status {$response[0]}")); Test::run('job entered the queue', fn() => Test::verify(count($queue->jobs) === 1, 'queue empty')); Test::run('job did not run in response', fn() => Test::verify(classify('thumbnail') === 'deferred', 'class ' . classify('thumbnail'))); Test::summary(); printf("thumbnail: %s\n", classify('thumbnail'));
response returns 201 passed job entered the queue passed job did not run in response passed total: 3 passed, 0 failed thumbnail: deferred
Three tests passed and the job was deferred: it was recorded, but it did not run. This is exactly what the test verifies. The sentence “the job entered the queue” is true, and the sentence “the job ran” was never tried in this test. Because the fake queue does not run the job, the feature test cannot say anything about what the job does.
The Contrast: The Job Ran When the Queue Was Drained
unit.php makes the same request, then drains the queue with process(). This time thumbnail ran. The result of the job is also verified: with a width of 800, the result must be 200. The last test tests the job directly, independent of the queue; this is the unit test.
<?php // unit.php — contrast: when the queue is drained with process(), the job runs; the unit test tests the job directly require_once 'app.php'; require_once 'test.php'; $queue = new FakeQueue(); (new UploadController($queue))->upload(800); $results = $queue->process(); Test::run('process() ran the job', fn() => Test::verify(classify('thumbnail') === 'ran', 'class ' . classify('thumbnail'))); Test::run('thumbnail width', fn() => Test::verify($results === [200], 'result ' . json_encode($results))); Test::run('job on its own', fn() => Test::verify((new ThumbnailJob(100))->run() === 25, 'result wrong')); Test::summary(); printf("thumbnail: %s\n", classify('thumbnail'));
process() ran the job passed thumbnail width passed job on its own passed total: 3 passed, 0 failed thumbnail: ran
The same job, the same controller, the same request: the class passed from deferred to ran only when process() was called. So what changes the class is not the request itself, it is draining the queue. The third test does not touch the queue at all: ThumbnailJob is built directly, run() is called and the result is read as 25.
The Bounding Measurement
The fake queue does not measure the body of the job. limit.php gives the controller the deliberately wrong FaultyThumbnailJob. First the same feature test runs, then the unit test of the same job.
<?php // limit.php — the fake queue does not measure the body of the job: a faulty job passes the feature test require_once 'app.php'; require_once 'test.php'; $queue = new FakeQueue(); $response = (new UploadController($queue, FaultyThumbnailJob::class))->upload(800); echo "feature test\n"; Test::run('response returns 201', fn() => Test::verify($response[0] === 201, "status {$response[0]}")); Test::run('job entered the queue', fn() => Test::verify(count($queue->jobs) === 1, 'queue empty')); Test::run('job did not run in response', fn() => Test::verify(classify('thumbnail') === 'deferred', 'class wrong')); echo "unit test\n"; $result = (new FaultyThumbnailJob(800))->run(); Test::run('thumbnail width', fn() => Test::verify($result === 200, "expected 200, got $result")); Test::summary();
feature test response returns 201 passed job entered the queue passed job did not run in response passed unit test thumbnail width failed — expected 200, got 3200 total: 3 passed, 1 failed
All three verifications of the feature test passed with the faulty job as well, because none of them looks at the body of the job: with a width of 800 the job returns 3200, but the fake queue never ran it. Only the unit test caught the fault. A feature test verifies that the path is wired; it does not verify that the right work is done at the end of the path. The two cannot replace each other.
This distinction has a cost too. A feature test that really runs the job has to drain the queue with process(), and the job then runs not after the response but inside the test. Such a test does not measure the deferral itself, only the result of the job. One of the two is chosen according to which question is asked.
Summary
- This lesson’s outcome is deferred: in the fake queue the
thumbnailjob was recorded (defer()), but it did not run in the feature test. - Without a test library, a verifier with two functions (
verify()andrun()) was enough to write feature and unit tests. - Its contrast: when
process()was called the same job ran; what changes the class is draining the queue, not the request. - In the bounding measurement a deliberately faulty job passed the feature test, and the unit test caught the result 3200 instead of 200.
- A feature test verifies that the path is wired, and a unit test verifies that the job does the right work; one does not take the place of the other.
Course Wrap-Up
In this course, across 21 lessons, we measured whether a part ran in a request: the foundations of the framework, views and data, and application services. Each lesson carried its own outcome and a contrasting measurement. The table below collects all of them in one place; the outcome and contrast cells are taken from the summary of the source lesson.
| Lesson | Part measured | Outcome | Contrasting measurement |
|---|---|---|---|
| Why a Framework | save action, cache listener |
ran | Core: 4 requests, 12 parts: 4 ran, 3 skipped, 3 deferred, 2 not wired |
| The Request–Response Lifecycle | Job left with afterResponse() |
deferred | The same job called directly in the action ran |
| Routing | Shadowed form route |
not wired | form ran after the order changed or after {id:\d+} narrowed the route |
| Controllers | Action, dependency resolved | ran | The undefined edit action gave 404, the part was not wired |
| Middleware | validation, save |
skipped | In the request with a session all four parts ran |
| The Object Container and Facades | Overridden FileWriter binding |
not wired | In the container with a single binding FileWriter ran |
| The Template Engine | Escaping step | ran | In raw output syntax escaping was not wired |
| Forms and Validation | Save step, invalid input 422 | skipped | With valid input the save ran, 201 |
| Object–Table Binding | Save hook | ran | A field not in the allow list was not wired |
| Relationships | Relationship query in lazy loading | deferred | In eager loading the relationship ran together with the list |
| Migrations and Seeding | Registered migration, second run | skipped | A newly added migration ran |
| Pagination and Resource Transformation | A column added to the model reaching the response | not wired | A column added to the transformation definition ran |
| Authentication and Authorization | edit action, gate 403 |
skipped | For the owner of the post edit ran, 200 |
| Queues and Jobs | Thumbnail job | deferred | With the sync driver the job ran |
| Events and Listeners | index listener, name did not match |
not wired | When the name matched the listener ran |
| Notifications | Selected mail and database channels | ran | Channels not selected were not wired |
| Caching | compute, hit on the second request |
skipped | After invalidation compute ran again |
| File Storage | upload() and write(), two disks |
ran | The remote disk, not in the configuration, was not wired, 500 |
| Scheduled Tasks | report task |
deferred | Ran at time 60, skipped at 30/59/61 |
| Localization | en/discount text |
not wired | When the key existed the requested language ran |
| Testing | Job in the fake queue | deferred | When process() was called the job ran |
The distribution of the lessons reads as 6 ran, 5 deferred, 5 skipped, 5 not wired. This counts the outcome of each of the 21 lessons. The distribution of the core in the Why a Framework lesson counts something else: of that lesson’s 12 expected parts, 4 ran, 3 were deferred, 3 were skipped and 2 were not wired; that is, in 4 requests 4 parts ran and 8 parts did not run.
The distribution in the table shows that in an application most of the expected parts do not run in a request, while the response may still be 200 or 201. Knowing which class came by which path helps one know what is being verified when writing a test. The question the course leaves is measuring the quality of this application: another subject area, such as the form of the code, its repetition and its classes of errors.
To keep your progress and take notes, Log in
My notes
Log in to take notes.