Skip to main content

Testing

Every facade has a fake. Calling fake() swaps the real implementation out of the container for the rest of the test, so nothing reaches the network and you can assert on what would have happened.

Tracking

use MatomoAnalytics\Facades\Matomo;
use MatomoAnalytics\Tracking\PageView;

$fake = Matomo::fake();

$this->get('/pricing');

$fake->assertTracked(PageView::class);

The fake records hits as the typed objects they are, so you assert on type rather than on a serialized payload. Pass a closure to inspect the hit:

use MatomoAnalytics\Tracking\Hit;

$fake->assertTracked(PageView::class, fn (Hit $hit): bool => $hit instanceof PageView && $hit->title === 'Pricing');

Type the parameter as Hit, not as the class in the first argument. The callback only ever sees hits of the type you asked for, so a narrower type looks safe — and then breaks on CustomParameters. A decorated hit matches by its INNER type, which is what makes assertTracked(PageView::class, …) still find a page view somebody wrapped; but the callback is handed the hit as it was tracked, which is the CustomParameters wrapper. A closure typed fn (PageView $hit) throws a TypeError there.

Matomo::track(CustomParameters::for(new PageView('Pricing'))->dimension(1, 'plan:pro'));

// finds it, and hands the callback the CustomParameters wrapper
$fake->assertTracked(PageView::class, fn (Hit $hit): bool => true);

So the type is Hit and the narrowing happens inside the closure, with instanceof.

The hit types you can assert

$type is any class in MatomoAnalytics\\Tracking. Asserting an order should not mean reading vendor/ for the class name, so here is the full set with the properties a callback can read:

ClassProperties
PageViewtitle, url, serverTimeMs
Eventcategory, action, name, value
Goalid, revenue
Downloadurl
Outlinkurl
Ping
SiteSearchkeyword, category, count
EcommerceOrderorderId, grandTotal, items, subTotal, tax, shipping, discount
EcommerceCartUpdategrandTotal, items
EcommerceViewsku, name, category, price, title, url
ContentImpressionname, piece, target
ContentInteractioninteraction, name, piece, target
$fake->assertTracked(
EcommerceOrder::class,
fn (Hit $hit): bool => $hit instanceof EcommerceOrder && $hit->orderId === 'A-1001',
);

CustomParameters is a decorator rather than a hit type of its own — assert the inner type, which is what it matches by.

The available assertions:

AssertionAsserts
assertTracked($type, $callback)A hit of this type was tracked, optionally matching the callback
assertTrackedCount($count)Exactly this many hits were tracked
assertNothingTracked()No hit was tracked at all
assertAiChatbotTracked($callback)An AI-chatbot telemetry hit was recorded
assertNoAiChatbotTracked()No AI-chatbot telemetry was recorded

assertNothingTracked() is the one people forget, and it is the more valuable half of a gating test: proving that an excluded visitor produces no hit is what actually tests the gate.

The fake implements the full tracker contract, including the ecommerce and content methods, so it works with whatever your code calls.

Reporting

use MatomoAnalytics\Facades\MatomoReports;

$reports = MatomoReports::fake();
$reports->stub('VisitsSummary.get', ['nb_visits' => 42]);

// ... exercise code that calls MatomoReports ...

$reports->assertRequested('VisitsSummary.get');

stub() registers the response for a method; a method you did not stub returns null, which is exactly what the real client does on failure — so your error path is testable without simulating an outage:

$reports->setLastError('Matomo is unreachable');

assertRequested(), assertRequestedCount() and assertNothingRequested() mirror the tracking assertions. Stubbing a null response is how you make one specific method fail while others succeed.

The fake's query() returns a real query builder, so fluent code under test behaves the same way and the request it produces is recorded like any other.

GDPR

use MatomoAnalytics\Facades\MatomoGdpr;

$gdpr = MatomoGdpr::fake()->stubFound([['idsite' => 1, 'idvisit' => 10]]);

// ... exercise code that calls MatomoGdpr::forget() ...

$gdpr->assertForgotten('[email protected]');

stubFound() sets what the lookup returns and stubDeleted() the deletion counts, so an erasure flow can be asserted end to end. failMutations() makes forget() and export() fail while the lookup still succeeds — the case worth testing, because that is when your code has to decide what to tell the person who asked.

assertForgotten() takes an optional segment and callback; assertNothingForgotten() is how you prove a confirmation step actually blocked the erasure.

Annotations

use MatomoAnalytics\Facades\MatomoAnnotations;

$annotations = MatomoAnnotations::fake();

// ... exercise your deploy hook ...

$annotations->assertAnnotated('Deployed 1.4.0');

fail() makes annotating fail, which is the assertion that matters for a deploy hook: a failed annotation must not fail the deploy. assertNothingAnnotated() covers the opted-out case — see release annotations.

Testing without fakes

Sometimes the fake is the wrong tool, because what you want to test is the real path.

Use sync mode. In sync mode the send happens inline, so by the time your assertion runs the HTTP call has been made. Combine it with Laravel's HTTP fake to assert on the actual wire payload:

use Illuminate\Support\Facades\Http;
use MatomoAnalytics\Facades\Matomo;

Http::fake();
config()->set([
'matomo-analytics.enabled' => true,
'matomo-analytics.host' => 'https://analytics.example.test',
'matomo-analytics.site_id' => 1,
'matomo-analytics.mode' => 'sync',
]);

Matomo::pageView('Pricing');

Http::assertSent(fn ($request): bool => $request['action_name'] === 'Pricing');

All four lines are needed. enabled is the master switch and ships off; without host and site_id the gate refuses the hit as unconfigured, and Http::assertSent() then fails over a request that was never meant to happen.

Leave the package unconfigured. With no MATOMO_HOST, tracking is a no-op. That is the default state of a test suite, and it means a test that does not care about analytics needs no setup at all.

Use the array buffer driver to test batch behavior without a database:

config()->set([
'matomo-analytics.mode' => 'batch',
'matomo-analytics.batch.driver' => 'array',
]);

Remember that batch escalation to the dead-letter queue counts consecutive failures in the cache, so a test asserting that path needs a persistent cache store rather than the array one — see reliability.