Reviews::fake() swaps in a recording ReviewsFake — a subtype of ReviewsManager, so an injected manager gets it too — and returns it. Operations still run against the database, so reads, aggregates and events behave normally, while every mutation is recorded — whether it came through the facade, an injected manager, a for()->by() builder, the HasReviews / CanVoteOnReviews traits or a Review model method:
use RoundlyConsulting\Reviews\Facades\Reviews;
use RoundlyConsulting\Reviews\Models\Review;
it('records the review and the owner reply', function () {
$fake = Reviews::fake();
$restaurant = Restaurant::factory()->create();
[$user, $owner] = User::factory()->count(2)->create();
$review = $restaurant->addReview($user)->rating(5)->create();
$review->respond($owner, 'Thanks!');
$fake->assertReviewCreated();
$fake->assertReviewCreated(fn (Review $review) => $review->rating === 5);
$fake->assertReviewResponded(fn (Review $response, Review $parent) => $parent->is($review));
$fake->assertNothingApproved();
});Every assertion
| Assert | Opposite | Callback receives |
|---|---|---|
assertReviewCreated() | assertNothingReviewed() | the review |
assertReviewApproved() | assertNothingApproved() | the review |
assertReviewRejected() | assertNothingRejected() | the review |
assertReviewUpdated() | assertNothingUpdated() | the review |
assertReviewDeleted() | assertNothingDeleted() | the review |
assertReviewResponded() | assertNothingResponded() | the response, the parent review |
assertReviewVoted() | assertNothingVoted() | the review, the voter |
assertReviewVoteRemoved() | assertNoVoteRemoved() | the review, the voter |
assertReviewVerified() | assertNothingVerified() | the review |
assertReviewUnverified() | assertNothingUnverified() | the review |
use Illuminate\Database\Eloquent\Model;
use RoundlyConsulting\Reviews\Models\Review;
$fake->assertReviewCreated(fn (Review $review) => $review->verified);
$fake->assertReviewApproved(fn (Review $review) => $review->is($pending));
$fake->assertReviewRejected();
$fake->assertReviewUpdated();
$fake->assertReviewDeleted();
$fake->assertReviewResponded(fn (Review $response, Review $parent) => $parent->is($review));
$fake->assertReviewVoted(fn (Review $review, Model $voter) => $voter->is($user));
$fake->assertReviewVoteRemoved(fn (Review $review, Model $voter) => $voter->is($user));
$fake->assertReviewVerified();
$fake->assertReviewUnverified();
// The opposites pass when nothing of that kind was recorded:
$fake->assertNothingReviewed();
$fake->assertNothingApproved();
$fake->assertNothingRejected();
$fake->assertNothingUpdated();
$fake->assertNothingDeleted();
$fake->assertNothingResponded();
$fake->assertNothingVoted();
$fake->assertNoVoteRemoved();
$fake->assertNothingVerified();
$fake->assertNothingUnverified();Every assert*() takes an optional callback and passes when at least one recorded call returns true. Only calls that change something are recorded: approving an approved review, rejecting a rejected one, verifying a verified one (or unverifying an unverified one) and removing a vote that was never cast are no-ops — no write, no event — so they are not. Because operations really run, keep the package’s migrations loaded (RefreshDatabase).
Pest expectations
Register the matchers once in tests/Pest.php:
// tests/Pest.php
RoundlyConsulting\Reviews\Testing\ReviewExpectations::register();
// In any test:
expect($product)->toHaveReview();
expect($product)->toHaveReview($author);
expect($product)->toHaveApprovedReview();Both matchers look at top-level reviews only, so owner responses never satisfy them.
InteractsWithReviews
Mix the trait into a test case for actingAsReviewer() / reviewAs() ergonomics:
use RoundlyConsulting\Reviews\Testing\InteractsWithReviews;
uses(InteractsWithReviews::class);
it('queues a new review for moderation', function () {
$review = $this->actingAsReviewer(User::factory()->create())
->reviewAs(Restaurant::factory()->create(), rating: 4, content: 'Great brunch');
expect($review->isPending())->toBeTrue();
});Factories
Both models ship factories with states for every shape of review:
use RoundlyConsulting\Reviews\Models\Review;
use RoundlyConsulting\Reviews\Models\ReviewVote;
$review = Review::factory()
->forReviewable($restaurant)
->byAuthor($user)
->approved()
->verified()
->rating(5)
->create();
Review::factory()->response($review)->byAuthor($owner)->approved()->create();
Review::factory()->forReviewable($restaurant)->withoutRating()->pending()->create();
Review::factory()->forReviewable($restaurant)->helpfulVotes(12, 3)->create();
ReviewVote::factory()->forReview($review)->byVoter($user)->unhelpful()->create();Factories write rows directly — they skip validation, moderation and events. Use the facade when the behaviour under test depends on those.
The package’s own suite
composer testShow your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.