Testing¶
IndexNowKit\Testing is part of the published package, not a dev-only helper: application and adapter test suites
are expected to use it. Four doubles, no framework, no HTTP.
| Double | Replaces | Gives you |
|---|---|---|
FakeTransport |
Http\TransportInterface |
recorded POSTs with the decoded body, queued responses and failures |
ArrayLogger |
Psr\Log\LoggerInterface |
every record, plus messages() with the context interpolated |
FrozenClock |
Psr\Clock\ClockInterface |
a clock that only moves when you call advance() |
RecordingDispatcher |
Dispatch\DispatcherInterface |
the batches handed over, without sending them |
Asserting what would be submitted¶
use IndexNowKit\{Config, IndexNowKit};
use IndexNowKit\Debounce\NullDebounceStore;
use IndexNowKit\Testing\{ArrayLogger, FakeTransport};
$transport = new FakeTransport();
$logger = new ArrayLogger();
$indexNow = IndexNowKit::create(
new Config(key: 'test-key-1234', baseUrl: 'https://www.example.com'),
transport: $transport,
logger: $logger,
debounce: new NullDebounceStore(),
);
$results = $indexNow->submit(['/posts/hello', '/posts/hello', '/about']);
self::assertCount(1, $transport->posts);
self::assertSame('https://api.indexnow.org/indexnow', $transport->posts[0]['url']);
self::assertSame(
['https://www.example.com/posts/hello', 'https://www.example.com/about'],
$transport->posts[0]['body']['urlList'],
);
self::assertTrue($results[0]->isSuccess());
Every entry of $transport->posts is ['url' => ..., 'json' => ..., 'headers' => ..., 'body' => ...], where body
is the decoded payload, so you assert on host, key, keyLocation and urlList directly.
NullDebounceStore keeps a test from depending on the debounce window. Use MemoryDebounceStore with a
FrozenClock instead when the window is what you are testing.
Entities and rules¶
$urls = $indexNow->urlsFor($post, IndexNowKit\Event::Updated);
self::assertSame(['https://www.example.com/posts/hello'], $urls);
foreach ($indexNow->explain($post, IndexNowKit\Event::Updated) as $resolved) {
// $resolved->rule, ->class, ->event, ->locale, ->url, ->source()
}
urlsFor() and explain() never throw, so a test that expects a broken attribute to be reported asserts on the log
instead:
self::assertStringContainsString(
'invalid #[IndexNow] on ' . Broken::class,
implode("\n", $logger->messages('error')),
);
Engine responses and failures¶
willRespond() queues responses in order; anything beyond the queue gets the constructor default. Queue a
Throwable to simulate a network failure.
use IndexNowKit\Http\Response;
use IndexNowKit\Testing\FakeTransport;
$transport = (new FakeTransport())->willRespond(
new Response(429, '', 30), // rate limited, Retry-After: 30
new Response(200),
);
$results = $indexNow->submit(['/a']);
self::assertTrue($results[0]->retryable);
self::assertSame(30, $results[0]->retryAfter);
self::assertSame(IndexNowKit\Reason::RateLimited, $results[0]->reason);
$transport->willRespond(FakeTransport::failing('connection refused')); // TransportException on the next POST
FakeTransport::failing() returns a ready-made TransportException; Response::parseRetryAfter() is what a real
transport uses to turn the header into seconds, and takes a $now argument so HTTP-date values are testable.
Retries without waiting¶
RetryingSubmitter takes a sleeper, so a retry test runs instantly and can assert on the delay. Continuing the
queue above (429 with Retry-After: 30, then 200):
use IndexNowKit\Retry\{RetryPolicy, RetryingSubmitter};
$slept = [];
$submitter = new RetryingSubmitter(
$indexNow->submitter,
new RetryPolicy(maxAttempts: 3, baseDelay: 60),
$logger,
static function (int $seconds) use (&$slept): void { $slept[] = $seconds; },
);
$submitter->submit(['/a']);
self::assertSame([30], $slept); // Retry-After won over the exponential base
Debounce windows¶
use IndexNowKit\Debounce\MemoryDebounceStore;
use IndexNowKit\Testing\FrozenClock;
$clock = new FrozenClock('2026-01-01 00:00:00');
$indexNow = IndexNowKit::create($config, transport: $transport, debounce: new MemoryDebounceStore($clock));
$indexNow->submit(['/a']);
$indexNow->submit(['/a']);
self::assertCount(1, $transport->posts); // second call debounced
$clock->advance(601);
$indexNow->submit(['/a']);
self::assertCount(2, $transport->posts);
TokenBucket takes the same clock plus its own sleeper, so throttling is testable the same way.
Collecting without sending¶
use IndexNowKit\Testing\RecordingDispatcher;
$dispatcher = new RecordingDispatcher();
$indexNow = IndexNowKit::create($config, transport: $transport, dispatcher: $dispatcher);
$indexNow->collect(['/a', '/b']);
self::assertSame(2, $indexNow->collector->count());
$indexNow->flush();
self::assertSame(['https://www.example.com/a', 'https://www.example.com/b'], $dispatcher->urls());
self::assertCount(1, $dispatcher->batches);
self::assertTrue($indexNow->collector->isEmpty());
This is the right double for adapter tests: it proves the unit-of-work hook fired without involving HTTP at all.
The key file¶
$transport->onGet('https://www.example.com/test-key-1234.txt', new Response(200, 'test-key-1234'));
$report = (new IndexNowKit\Check\Checker($config, $indexNow->keys, $transport))->run();
self::assertFalse($report->hasErrors());
Unregistered GET URLs answer 404, which is what a "key file missing" test wants.
Dry run¶
dry_run exercises the whole pipeline — normalization, deduplication, grouping, key lookup — and stops before the
POST. Results come back as skipped with reason dry_run, and the body is in the info log line.
$indexNow = IndexNowKit::create($config->with(dryRun: true), transport: $transport);
self::assertSame([], $transport->posts);
Prefer it in application test suites where you care that a change would have been announced; prefer FakeTransport
where you care about the exact payload.
Assertions for an adapter's HTTP and command tests¶
The conformance scenarios H01–H06 are the same in every framework, only the way a response or a command output is
captured differs. Two static helpers of indexnowkit/testing
(composer require --dev indexnowkit/testing) hold the assertions, so an adapter test parses its framework's objects and
asserts once:
use IndexNowKit\Testing\Conformance\CheckOutputAssertions;
use IndexNowKit\Testing\Conformance\KeyFileAssertions;
// H01: 200, text/plain, the key as the body, Cache-Control with public and max-age, Vary: Host exactly when the
// body depends on the host — a hosts map or strict_hosts, which is what Config::keyFileHeaders() decides
KeyFileAssertions::assertKeyFileResponse($response->getStatusCode(), $response->headers->all(), $response->getContent(), $key, maxAge: 300, expectVaryHost: true);
// H02/H03: an unknown key, another host's key, key_file.enabled: false
KeyFileAssertions::assertNotServed($response->getStatusCode());
// H04/H05: the check command
CheckOutputAssertions::assertExitCode(0, $exitCode, $output); // the output is the failure message
CheckOutputAssertions::assertReady($output, 'www.example.com'); // "<host>: key file OK" and the closing line
CheckOutputAssertions::assertKeyFileHint($output, 403); // the status and the hint about what the engines do
Cache-Control is compared by directive (frameworks order them differently), header names in any case, values as a
string or a list.
Conformance kits for adapters¶
Two abstract PHPUnit cases of indexnowkit/testing turn docs/spec/03 into runnable scenarios against your wiring
(the package is require-dev; the core itself ships no PHPUnit code):
Testing\Conformance\CoreConformanceTestCase(C01, C03, C04, C06, C09–C12, C14, C19, C20): return the facade your container built and theFakeTransportit is wired to; optionally a second configured host for C04.Testing\Conformance\OrmConformanceTestCase(A01–A21, plus A05b/A05c): implement the driver — the transaction verbs of your data layer (begin(),commit(),rollback()), the end of a unit of work (flush(),collectedCount()), and fixtures with fixed rule shapes (createPost(),createMultiPost(),createCategorizedPost(),createTag(),attachTag(),bulkUpdateTitle(), …). The docblock of the class lists the rules every fixture must carry; the URL conventions (postUrl(),ampUrl(),categoryUrl(),homeUrl()) are overridable.
indexnowkit/doctrine (tests/OrmConformanceTest.php) and indexnowkit/laravel (tests/Conformance/) are the
reference drivers. A scenario that does not apply to your framework is documented in your README, not skipped
silently.
Notes for adapter authors¶
- Assert on rules and events through
ObjectChangeHandler::createdEvents(),updatedEvents()anddeletedEvents()before resolving, so an ORM test does not need URLs to verify classification. IndexNowKit::create()rejects combining a customsubmitter:withtransport:,debounce:,throttle:ornormalizer:, because a custom submitter builds its own pipeline. Pass those to your submitter instead.indexnowkit/testingships a mock IndexNow server for end-to-end runs through a real PSR-18 client:php -S 127.0.0.1:8089 vendor/indexnowkit/testing/resources/mock-server/router.php, with scenarios selected by anX-Mock-Scenarioheader (ok200,pending202,forbidden403,ratelimit429, …),MOCK_KEYSfor the key files it serves and a request log atGET /_mock/requests. The core's ownPsr18TransportTestruns against a private copy of the same router (tests/Support/mock-server/), because the core cannot depend ontesting.
How the family itself is verified¶
Three floors, one rule each, all in the monorepo CI (.github/workflows/ci.yml, taint.yml):
| Floor | Where | Tool | Rule |
|---|---|---|---|
| Line coverage | packages/<package>/tests/coverage-floor.txt, every package |
PHPUnit + pcov, bin/coverage-floor |
the coverage measured when the floor was set; raising it is a normal commit, lowering it a separate commit with the reason |
| Mutation score (MSI) | packages/<package>/tests/msi-floor.txt — core, verify, sitemap, history, console |
Infection over the whole src, bin/mutation <package> |
the same ratchet; the mutation job is non-blocking until every floor is a CI measurement that held three weekly runs, the mutation / changed lines job of a pull request mutates only the lines it changes |
| Taint | packages/<package>/psalm.xml — the same five packages |
Psalm --taint-analysis, bin/taint <package> |
blocking; a flow that is the feature (a file path from the command line) is suppressed in psalm.xml with the reason next to it |
Coverage says a line ran; the mutation score says a test would notice if the line were wrong (< for <=, a dropped
return, an off-by-one in a limit), which is the difference between "covered" and "checked". Log texts and exception
messages are not API (bc.md), so the mutants that only reword them are ignored in infection.json5 by regex, not
by annotations in the code. Infection and Psalm are tools of the monorepo (tools/infection, tools/psalm, with their
locks), not dev dependencies of the packages: Infection needs PHP 8.3 while the packages support 8.2, and Psalm is used
for the taint analysis only — phpstan level 9 with strict rules is the type checker.
A library has no taint source of its own, so each package in the taint matrix carries tests/Taint/entrypoints.php: its
public API called with request data ($_GET, $_POST, php://input), the entry points Psalm follows into the sinks it
knows (PDO, file_put_contents(), header(), echo). What Psalm cannot see here, and the tests cover instead: a URL
that went through Url\UrlNormalizer is clean to Psalm (parse_url() ends the flow), so the SSRF class — redirects,
canonical, nested sitemaps — is proven by the allow-list tests of verify and sitemap, not by the taint job. The
framework adapters are outside the matrix: their inputs are the frameworks' request objects, which Psalm does not treat
as sources without a plugin per framework.