Search by

thesceneman / silverstripe-bifaux

TheSceneman

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

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-08-10 23:57 UTC

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:

  1. SS_ENVIRONMENT_TYPE=dev — the mock can never be reached from a test or live environment
  2. BIFAUX_MOCK_SEARCH=1 in 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 property getter runs. They are cached for seed_cache_ttl seconds 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 client
  • LoggerInterface.errorhandler → a collecting logger
  • IndexingInterface → a no-op, so Forager can build documents without Bifröst credentials
  • BIFROST_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.