thesceneman / silverstripe-bifaux
A faux Bifröst — mock the Silverstripe Search Service (Bifröst) API for unit testing sites built on silverstripe-discoverer-bifrost
Package info
github.com/TheSceneman/silverstripe-bifaux
Type:silverstripe-vendormodule
pkg:composer/thesceneman/silverstripe-bifaux
Requires
- php: ^8.3
- php-http/discovery: ^1.20
- psr/http-client: ^1
- psr/http-factory: ^1
- psr/http-message: ^1 || ^2
- silverstripe/framework: ^6
- silverstripe/silverstripe-discoverer: ^3
- silverstripe/silverstripe-discoverer-bifrost: ^3.1
- silverstripe/silverstripe-forager: ^2
Requires (Dev)
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-02 03:15:51 UTC
README
A faux Bifröst. Bifaux mocks the Silverstripe Search Service (Bifröst) API so you can unit test a site's search implementation without a real search engine, real credentials, or a network.
It answers requests in genuine Bifröst JSON, and derives its mock content from the same
IndexConfiguration yml your search indexes are actually built from — so the mock knows your
fields without being told about them twice.
Why not just mock the discoverer classes?
Because then you'd be testing your mocks. Bifaux replaces only the HTTP hop: it registers an
in-process PSR-18 client as Silverstripe\Search\Client\Client.searchClient, so every real
silverstripe-discoverer-bifrost class still runs — the adaptors, SearchRequestProcessor,
SearchResultsProcessor and its response validation.
That has two consequences worth understanding:
- The JSON wire format is the contract. If Bifaux emitted a malformed response, the vendor's own validation would reject it and your test would fail.
- You can assert on what your site sent. The outbound request is a real serialised
SearchRequest, captured for inspection.
your controller → SearchService → Bifröst SearchAdaptor → SearchRequestProcessor
↓ real JSON
[ Bifaux client ]
↓ real JSON
your Records ← SearchResultsProcessor ←──┘
Requirements
silverstripe/silverstripe-discoverer ^3, silverstripe/silverstripe-discoverer-bifrost ^3.1,
silverstripe/silverstripe-forager ^2, Silverstripe CMS 6, PHP 8.3+.
Installation
composer require --dev thesceneman/silverstripe-bifaux
Nothing else. No environment variables, no Injector configuration, no base test class. Bifaux
registers itself into every SapphireTest run and is inert outside of tests.
Quickstart
Write a test. That's the whole setup:
class SearchPageControllerTest extends FunctionalTest { public function testSearchReturnsResults(): void { $response = $this->get('search?Search=apple'); $this->assertStringContainsString('Mock Page 1 title', $response->getBody()); } }
With no seeding at all, Bifaux generates documents from your index configuration — 10 per class in the index, with placeholder values derived from each field's name and type. Enough to prove the plumbing works end to end.
One thing to know up front
Generated placeholder values are strings like Mock Page 1 title. If your site filters on a
field whose real values matter — a locale, a status, a class name — the placeholder will never
satisfy that filter, and you'll get zero results. Pin those fields once, in yml:
TheSceneman\Bifaux\Documents\GeneratedDocumentSource: generated_field_values: locale: en_NZ # A list is cycled across generated documents status: ['Under construction', 'Complete']
Or seed documents explicitly, which is what most real tests end up doing.
Seeding
Add BifauxTestTrait to a test for the seeding helpers. (It's optional sugar — everything is
also available on Bifaux::singleton().)
use TheSceneman\Bifaux\Testing\BifauxTestTrait; class MySearchTest extends FunctionalTest { use BifauxTestTrait;
Explicit documents — exact values, no database needed. Field names are the snake_case names
from your index config; id is required.
$this->seedBifaux('main', [ [ 'id' => 'page_1', 'record_id' => '1', 'record_base_class' => 'Page', 'title' => 'Apple pie recipe', 'link' => '/apple-pie/', 'locale' => ['en_NZ', 'mi_NZ'], ], ]);
Multi-value fields are supported: a filter matches an array-valued field when the two intersect.
From a fixture file — pass a .json, .yml or .yaml path instead of an array:
$this->seedBifaux('main', __DIR__ . '/fixtures/search-documents.json');
From real DataObjects — builds documents through Forager's own DataObjectDocument
pipeline, so your property getters and updateSearchAttributes() extension hooks run exactly
as they do during real indexing. This is the highest-fidelity option.
$this->seedBifauxFromDataObjects('main'); // everything in the test database $this->seedBifauxFromDataObjects('main', [$pageOne, $two]); // or specific objects
Note that Forager's shouldIndex() gate (published, canView, ShowInSearch) is not applied —
test fixtures are usually draft, so Bifaux indexes what you give it.
Generated from the schema — deterministic documents for one class:
$this->seedBifauxGenerated('main', DevelopmentPage::class, 25);
Any explicit seeding — even seeding an empty array, meaning "this index is empty on purpose" — disables auto-seeding for that index.
Asserting on the request your site sent
Half the value of a search test is checking the query you built, not just the results:
$this->get('search?Search=apple'); $request = $this->getLastBifauxRequest(); $this->assertSame('apple', $request->query); $this->assertSame(['mi_NZ'], (array) $request->filters->all[0]->locale); $this->assertSame('asc', $request->sort[0]->title); $this->assertSame(2, $request->page->current);
This is the decoded body of a real SearchRequest, so its shape is the documented Bifröst
request format: query, filters (all / any / none), sort, page, result_fields,
search_fields, facets.
$this->bifaux()->getRequests() returns every captured request including suggestion and click
calls.
Simulating failures
The discoverer adaptor never throws — it catches transport errors, logs a warning, and returns
an unsuccessful Results. Test that path:
$this->failNextBifauxRequest(500); // HTTP failure $this->failNextBifauxRequest(200, ['Engine on fire']); // a 200 carrying Bifröst errors $results = SearchService::create()->search($query, 'main'); $this->assertFalse($results->isSuccess());
Warnings the adaptor logged are collected rather than printed, and stay assertable:
$this->assertStringContainsString( 'Search request failed with status 500', implode("\n", $this->bifaux()->getLogMessages()) );
What is and isn't faithful
Faithful: the response format (validated by the vendor's own processor), filters (all,
any, none, IN, ranges, array-value intersection), sorting including _score, pagination and
its API limits, result_fields projection with raw truncation, <em> snippet highlighting
with fallback, and value/range facets computed over the filtered set.
Like a real engine, a field is omitted from a result when there is nothing to return for it — an
empty or unmatched snippet-only field does not come back as "snippet": null. This matters:
a field carrying only a null snippet builds a discoverer Field(null, null), and
Field::forTemplate() is typed to return a string, so rendering it throws.
Approximate: relevance. Matching is case-insensitive substring containment scored by occurrence count. Don't write tests that depend on Bifröst's real ranking, precision or stemming — assert on explicit sorts and filters instead.
Not covered: indexing. Bifaux mocks the query side (discoverer). Forager's indexing side is only used as a document builder.
Prototyping in dev, before Bifröst exists
Everything above is about tests. Bifaux can also answer real browser requests on a local dev site, so you can build search controllers, templates and facet UI before the Bifröst endpoint is stood up or credentialed.
Enabling it takes two things, and both are required:
SS_ENVIRONMENT_TYPE=dev— the mock can never be reached from a test or live environmentBIFAUX_MOCK_SEARCH=1in your local.env— a per-developer opt-in that is never committed
# .env SS_ENVIRONMENT_TYPE="dev" BIFAUX_MOCK_SEARCH=1
Flush, and search works. No Bifröst endpoint, no API keys, no indexing run.
Both gates live on the single Only block in _config/dev-mock.yml, which is also what
registers the Injector services — so the wiring and the flag can never disagree. Ask
BifauxDevMode::isEnabled() when app code (a template banner, say) wants to know whether it is
looking at mock results.
Where the documents come from
By default, from the DataObjects already in your dev database, built through Forager's own document pipeline — so you search your real content, minus the search engine. Failing that (an empty database), you get the same generated placeholders tests get.
Point it at fixture files instead when you want exact, stable content:
TheSceneman\Bifaux\Dev\BifauxDevMode: fixtures: main: app/fixtures/search-documents.json
Two things about database seeding worth knowing:
- Documents come from the current versioned stage — Live on the front end. Draft-only pages will not appear, which is also true of real indexing.
- Building them is not cheap; every configured
propertygetter runs. They are cached forseed_cache_ttlseconds and cleared by?flush=1, so you pay for it once, not per search.
What else changes in dev mode
IndexingInterface is bound to a no-op. Publishing, dev/build and the Forager tasks keep
working instead of erroring on missing credentials, but nothing is really indexed — Bifaux mocks
the query side only.
Configuration reference
TheSceneman\Bifaux\Bifaux: # Documents auto-generated per class when an un-seeded index is searched. 0 disables. default_documents_per_class: 10 TheSceneman\Bifaux\Documents\GeneratedDocumentSource: # Pin generated values for specific fields; a list is cycled across documents generated_field_values: locale: en_NZ TheSceneman\Bifaux\Dev\BifauxDevMode: # Set by _config/dev-mock.yml when the dev + BIFAUX_MOCK_SEARCH gate passes. Set it false in # a later block to hard-disable the mock without touching your .env. enabled: false # Seed from the dev database. False falls back to generated documents. seed_from_database: true # DataObjects loaded per included class — a build-cost budget, not a corpus size seed_from_database_limit: 100 # Seconds built documents are cached for. 0 disables; ?flush=1 always clears. seed_cache_ttl: 300 # Index field types dropped from seeded documents. Binary fields are index-only — Bifröst # never returns one in a result, and a site indexing file contents would otherwise build a # corpus hundreds of megabytes wide. seed_exclude_field_types: ['binary'] # Fixture files to seed instead of the database, keyed by index suffix fixtures: {}
Test isolation
State lives on an Injector-managed singleton, and SapphireTest nests the Injector per test, so
seeded documents, queued failures and captured requests never leak between tests. Use
$this->resetBifaux() to clear them mid-test.
Using the real search service in one test
Bifaux's wiring is per-test Injector registration, so override it in setUp() after
parent::setUp():
Injector::inst()->registerService($realClient, Client::class . '.searchClient');
How the wiring works
BifauxTestState (a Silverstripe TestState, registered via _config/bifaux.yml) runs on every
test setup and registers:
- the seven Bifröst adaptor bindings — search, query/spelling suggestion, analytics, and the criteria/criterion/facet query adaptors
Silverstripe\Search\Client\Client.searchClient→ the Bifaux PSR-18 clientLoggerInterface.errorhandler→ a collecting loggerIndexingInterface→ a no-op, so Forager can build documents without Bifröst credentialsBIFROST_ENGINE_PREFIX=test, for deterministic engine names
The adaptors are re-registered in PHP rather than relying on the vendor yml on purpose: the
vendor's Injector blocks are gated behind Only: envvarset: BIFROST_QUERY_API_KEY, which is
never set in CI. Without re-declaring them you'd get discoverer's base adaptors, which throw
BadMethodCallException on any search.
Working on Bifaux itself
composer install cp phpunit.xml.dist phpunit.xml # optional; the .dist is used if you don't composer test composer lint
The test suite uses SapphireTest, so it needs a database — point SS_DATABASE_* at one (a
.env works) before running. Tests that build documents from DataObjects create their own
tables via $extra_dataobjects.
tests/Contract/ is the important part: those tests feed Bifaux output through the real vendor
SearchResultsProcessor. If a vendor upgrade changes the wire format, they fail loudly — which
is the intent.
When developing Bifaux inside a host project rather than standalone, run its suite from the
project root instead — e.g. vendor/bin/phpunit --testsuite bifaux if you registered one.