Forms::fake() swaps the manager for FormsFake — a recording FormsManager subtype, so injected managers get it too — and returns it as your handle. It still performs against the database: the rows you’d expect are really created, events fire, and a review still opens its approvals request. The fake records every call on top — through the facade, an injected manager, the builders, Forms::submission(), the HasForms trait and forms:sync:
use RoundlyConsulting\Approvals\Models\ApprovalRequest;
use RoundlyConsulting\Forms\Facades\Forms;
use RoundlyConsulting\Forms\Models\FormSubmission;
$fake = Forms::fake();
// ... exercise your application code that submits the form ...
$fake->assertSubmitted($form);
$fake->assertSubmitted($form, fn ($result, $form) => $result->fieldCount === 3);
$fake->assertSubmittedCount(1);
$fake->assertNotSubmitted($otherForm);
$fake->assertReviewOpened(fn (ApprovalRequest $request, FormSubmission $submission) => $request->quorum === 2);Every assertion
| Records | Assert | Assert none |
|---|---|---|
| submit(), $user->submitTo() | assertSubmitted(?Form, ?callable), assertSubmittedCount(int), assertNotSubmitted(?Form) | assertNothingSubmitted() |
| draft(), $user->draftTo() | assertDrafted(?Form, ?callable) | assertNothingDrafted() |
| finalize(), submission()->finalize() | assertFinalized(?string $uuid) | assertNothingFinalized() |
| define() | assertFormDefined(string $key) | assertNoFormDefined() |
| create(), define()->create() | assertFormCreated(?callable) | assertNoFormCreated() |
| update()->save() | assertFormUpdated(string $key) | assertNoFormUpdated() |
| createSubmission() | assertSubmissionCreated(?callable) | assertNoSubmissionCreated() |
| sync(), forms:sync | assertSynced() | assertNothingSynced() |
| review()->…->open() | assertReviewOpened(?callable) | assertNothingReviewed() |
use RoundlyConsulting\Approvals\Models\ApprovalRequest;
use RoundlyConsulting\Forms\DataTransferObjects\SubmissionResult;
use RoundlyConsulting\Forms\Models\Form;
use RoundlyConsulting\Forms\Models\FormSubmission;
use RoundlyConsulting\Forms\Models\Submission;
// submit(), $user->submitTo()
$fake->assertSubmitted($form, fn (SubmissionResult $result, Form $form) => $result->fieldCount === 3);
$fake->assertSubmittedCount(1);
$fake->assertNotSubmitted($otherForm);
$fake->assertNothingSubmitted();
// draft(), $user->draftTo()
$fake->assertDrafted($form, fn (SubmissionResult $result, Form $form) => $result->fieldCount > 0);
$fake->assertNothingDrafted();
// finalize(), Forms::submission($uuid)->finalize()
$fake->assertFinalized($draft->uuid);
$fake->assertNothingFinalized();
// define(), create(), update()->save()
$fake->assertFormDefined('contact');
$fake->assertNoFormDefined();
$fake->assertFormCreated(fn (Form $form) => $form->key === 'contact');
$fake->assertNoFormCreated();
$fake->assertFormUpdated('contact'); // only once save() has run
$fake->assertNoFormUpdated();
// createSubmission()
$fake->assertSubmissionCreated(fn (Submission $row) => $row->uuid === $uuid);
$fake->assertNoSubmissionCreated();
// sync(), php artisan forms:sync
$fake->assertSynced();
$fake->assertNothingSynced();
// review()->…->open()
$fake->assertReviewOpened(fn (ApprovalRequest $request, FormSubmission $submission) => $request->quorum === 2);
$fake->assertNothingReviewed();- assertSubmitted() and assertDrafted() match by form key; their callbacks receive the SubmissionResult and the Form and must return true for one recorded call.
- assertFormCreated() sees forms stored through Forms::create() and define()->create(); forms created inside sync() satisfy assertSynced() only.
- assertFormDefined($key) passes when define($key, …) ran or a form with that key was created.
- An update is recorded when save() runs — Forms::update($key) alone records nothing, so assertFormUpdated() passes only after save().
- assertReviewOpened() callbacks receive the ApprovalRequest and the FormSubmission.
Test helpers
The InteractsWithForms trait adds fakeForms(), submitForm($form, $values, $sender) and draftForm(...). Values are nested per group or flat by group_key.field_key (one style per group) — the helper wraps them in the form key for you:
uses(RoundlyConsulting\Forms\Testing\InteractsWithForms::class);
$fake = $this->fakeForms();
$this->submitForm($form, ['details' => ['name' => 'Ann']]);
$this->submitForm($form, ['details.name' => 'Ann']); // the same submission, flat
$fake->assertSubmitted($form);Pest matchers
Opt into the expectation matchers from your tests/Pest.php:
RoundlyConsulting\Forms\Testing\FormExpectations::register();
expect($form)->toBeAcceptingSubmissions();
expect($expiredForm)->toBeExpired();A full example
use RoundlyConsulting\Forms\Facades\Forms;
use RoundlyConsulting\Forms\GroupBuilder;
it('stores a contact submission', function () {
$fake = Forms::fake();
$form = Forms::define('contact', 'Contact us')
->public()
->group('details', 'Details', function (GroupBuilder $g): void {
$g->field('name', 'Name')->required();
})
->create();
$this->post('/contact', ['contact' => ['details' => ['name' => 'Ann']]])
->assertRedirect();
$fake->assertSubmitted($form, fn ($result) => $result->fieldCount === 1);
expect(Forms::submissions($form)->first()->value('name'))->toBe('Ann');
});Factories
Every model has a factory, with states for the common cases:
use RoundlyConsulting\Forms\Models\Form;
use RoundlyConsulting\Forms\Models\FormSubmission;
use RoundlyConsulting\Forms\Models\Submission;
Form::factory()->public()->create();
Form::factory()->withExpiration()->create();
Submission::factory()->draft()->create();
FormSubmission::factory()->pending()->create();Run your suite with the forms migrations (and the dependency packages’ migrations) loaded, e.g. via RefreshDatabase. To run the package’s own tests:
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.