Testing
Git::fake() swaps the manager for a recording GitFake — for the facade and for anything that injected GitManager — and returns it. Every driver it hands out is a seedable ProviderFake, no HTTP leaves the process (Http::preventStrayRequests()), and every call is recorded, whether it went through the flat driver methods, a repo() / pullRequest() / installations() handle, webhooks() or batch(). Seed what your code reads, run it, then assert:
use Illuminate\Support\Carbon;
use RoundlyConsulting\Git\Dto\Owner;
use RoundlyConsulting\Git\Dto\Repository;
use RoundlyConsulting\Git\Enums\ProviderName;
use RoundlyConsulting\Git\Facades\Git;
it('lists the connected repositories', function () {
$fake = Git::fake();
$fake->github()->seedRepositories([new Repository(
provider: ProviderName::Github,
id: '1',
path: 'acme/api',
name: 'api',
description: null,
defaultBranch: 'main',
owner: new Owner(id: '1', name: 'acme', avatar: null),
createdAt: Carbon::now(),
lastActivityAt: Carbon::now(),
)]);
// run code that calls Git::github()->repositories() ...
Git::assertSent(ProviderName::Github, 'repositories');
Git::assertNotSent(ProviderName::Github, 'createRepository');
});Credentials under the fake
The fake authenticates exactly as the real manager does — it only never signs or sends anything with the credential. Each Git::github() / githubApp() / gitlab() / bitbucket() / provider() call gets its own fake driver, sharing the provider’s seeds and records, with the credential passed, else the configured one, else none:
- A write — and listWebhooks() — with no credential throws InvalidCredentialsException::missing(); reads stay anonymous, and isAuthenticated() answers accordingly.
- A credential type the forge doesn’t take is refused (unsupported() — an OauthToken on Bitbucket, say), and authenticationMethods() is the real driver’s list.
- installations() needs a GithubApp and installationRepositories() a GithubAppToken (wrongCredentialType()); Git::githubApp() with no app configured throws missingAppConfig().
- Seed through fakeFor(ProviderName::…), which needs no credential.
So a host suite configures fake credentials — any values, nothing is signed — or passes one per call (Git::github(Token::from('test'))):
config([
'git.providers.github.token' => 'test-token', // writes on Git::github()
'git.providers.github.app.id' => '123', // Git::githubApp(): numeric id
'git.providers.github.app.private_key' => 'test-key', // … and any non-blank key
// 'git.providers.github.app.installation_id' => '456', // Git::github() as the installation
]);Assertions
Every assert names the driver method the call reached — the handles call the flat methods, so ->pullRequest(12)->merge() is recorded as mergePullRequest — and an assertSent() / assertNotSent() callback receives that method’s arguments positionally. Once Git::fake() ran, all of them work statically on the facade or on the returned fake:
// run code that calls Git::github()->repo('acme/app')->pullRequest(12)->merge() ...
Git::assertSent(ProviderName::Github, 'mergePullRequest');
Git::assertSent(ProviderName::Github, 'mergePullRequest', fn (string $path, int $number) => $number === 12);
Git::assertSentTimes(ProviderName::Github, 'approvePullRequest', 1);
Git::assertNotSent(ProviderName::Github, 'closePullRequest');
Git::assertNotSent(ProviderName::Github, 'mergePullRequest', fn (string $path) => $path === 'acme/billing');
Git::assertNothingSent(ProviderName::Gitlab); // nothing reached GitLab
Git::assertNothingSent(); // nothing reached any provider
Git::assertBatched(ProviderName::Github, 'languages');
Git::assertNotBatched(ProviderName::Github, 'repositories');
Git::assertRepositoryCreated('new-repo');
Git::assertRepositoryCreated('new-repo', owner: 'acme', template: 'acme/package-template');
Git::assertNoRepositoryCreated();| Method | Passes when |
|---|---|
assertSent(provider, method, ?callback) | The driver method was called — and, with a callback, one call’s arguments (passed positionally) satisfied it. |
assertSentTimes(provider, method, times) | The driver method was called exactly that many times. |
assertNotSent(provider, method, ?callback) | No call (matching the callback) happened. |
assertNothingSent(?provider) | No call at all — or none to that provider. |
assertBatched(provider, method) / assertNotBatched(…) | A batch()->{method}() was / was not issued. |
assertRepositoryCreated(name, ?owner, ?template) | A createRepository() matched the name — and the owner and template when given. |
assertNoRepositoryCreated() | No createRepository() call happened. |
assertWorkflowDispatched(workflow, ?ref, ?inputs, ?repository) | A dispatch of that workflow happened — on that ref and repository when given; inputs must match exactly: the same names, values and types, in any order. |
assertNoWorkflowDispatched() | No workflow was dispatched on any provider. |
assertWorkflowRunCancelled(runId, ?repository) | A cancel of that run happened — in that repository when given. |
assertNoWorkflowRunCancelled() | No run was cancelled on any provider. |
recorded(provider, ?method) | Returns the list of RecordedCall (method, arguments), oldest first. |
fakeFor(provider) | Returns the one seedable ProviderFake for that provider — unauthenticated, so seeding needs no credential. |
Inspecting recorded calls
$calls = Git::recorded(ProviderName::Github, 'mergePullRequest'); // list<RecordedCall>
$calls[0]->method; // "mergePullRequest"
$calls[0]->arguments; // ['acme/app', 12, MergeMethod::Merge, null, null, null]
Git::recorded(ProviderName::Github); // every GitHub call, oldest first
Git::fakeFor(ProviderName::Gitlab)->seedRepositories([$repositoryDto]);Injected managers
Because GitFake extends GitManager, a class that constructor-injects the manager is faked too — nothing to rebind:
use RoundlyConsulting\Git\Facades\Git;
it('merges the release pull request', function () {
config(['git.providers.github.token' => 'test-token']); // the fake authenticates like production
Git::fake()->github()->seedMergeCommit('abc123');
$sha = app(ShipRelease::class)('acme/app', 12); // constructor-injects GitManager
expect($sha)->toBe('abc123');
Git::assertSent(ProviderName::Github, 'mergePullRequest', fn (string $path, int $number) => $number === 12);
});Unseeded behaviour
The double answers the whole Provider contract — every read, write, installation lookup and the commit query — so a host application never hits an undefined method as it grows. Three rules make its behaviour predictable:
| Kind of call | Unseeded behaviour |
|---|---|
| List reads (pullRequests, issues, tags, releases, contributors, branches, languages) | An empty page — a real provider answer. Seeded lists are paged like the forge’s: a page size below 1 throws, above 100 is capped (50 for a Bitbucket pullRequests()), and items are sliced by page with hasMore set. |
| Single-resource reads (repository, commit, contents, issue, release, installation, branch, workflowRun) | Throws a RuntimeException naming the seeder to call. |
| Writes (createPullRequest, comment, mergePullRequest, …) | Synthesized from the input — once the call passes the credential and input checks the real driver runs. |
Each fake driver supports exactly what its real driver supports: supports(), capabilities() and featureMatrix() answer the same, and an operation the forge lacks throws the same FeatureNotSupportedException before anything is recorded — a fake Bitbucket refuses ->pullRequest(1)->merge() just as Bitbucket does, so a test cannot pass against a flow production rejects. The driver’s own input checks run on the fake too — Bitbucket commit filters by author or date, a GitLab comment with no target, a Bitbucket comment on an issue, Bitbucket createRepository() with autoInit or a defaultBranch, a non-numeric GitLab owner, an inactive GitLab webhook or one with an unknown event — as do the page-size rule, a malformed batch()->pullRequest() reference and register()’s event comparison.
Seeders
All chainable: seedRepositories, seedRepository, seedCreatedRepository, seedCommits, seedCommit, seedBranches, seedPullRequests, seedPullRequest, seedIssues, seedIssue, seedTags, seedReleases, seedRelease, seedContents, seedComparison, seedContributors, seedLanguages, seedSearchResults, seedComment, seedMergeCommit, seedApprovalState, seedSubmittedReview, seedPullRequestReviews, seedUser, seedInstallation, seedInstallations, seedWebhooks, seedCreatedWebhook, seedBatch, seedBranch, seedActivity, seedWorkflowRuns, seedWorkflowRun, seedWorkflowJobs, seedDispatchWithoutRunDetails.
use RoundlyConsulting\Git\Batch\BatchResult;
$fake->github()
->seedPullRequest($pullRequestDto)
->seedMergeCommit('abc123');
// ->pullRequest(n)->close() returns the seeded PR with state Closed; ->merge() returns 'abc123'
$fake->fakeFor(ProviderName::Gitlab)->seedRepositories([$repositoryDto]);
$fake->github()->seedBatch('languages', new BatchResult(['acme/api' => ['PHP' => 100]], []));- review() needs no seeding: it answers the state the event actually means (Comment → COMMENTED, never APPROVED), so a test asserting on the verdict cannot pass against a fake that would fail against GitHub; seedSubmittedReview() overrides it when a test needs a specific id or URL.
- seedPullRequestReviews($reviews, $comments) seeds the two halves separately, because the real call reads them from two endpoints.
- createRepository() shapes the returned Repository from its input — owner sets the path and Owner; defaultBranch, or main when autoInit or a template asked for a commit and empty otherwise, sets the default branch — unless seedCreatedRepository() is set.
- Deliberate fallbacks: installationRepositories() reads the repositories bucket, installations()->all() stands in the single seeded installation, searchRepositories() falls back to the repositories bucket, pullRequest() / issue() / release() take the first of their list, and an unseeded pull request answers with the number you asked for.
- A fake clone URL carries the username the real driver uses — token (x-access-token for an installation token) on GitHub, oauth2 on GitLab, the caller’s on Bitbucket — and a placeholder secret for a refreshable credential; an empty secret keeps its :, as on the real drivers.
GitHub Actions on the fake
The fake plays the whole dispatch → find → poll → cancel flow. Unlike commits(), whose filters are only recorded, the run filters and job attempts are applied — an ignored filter would hand back another workflow’s run:
use RoundlyConsulting\Git\Enums\ProviderName;
use RoundlyConsulting\Git\Facades\Git;
config(['git.providers.github.token' => 'test-token']); // dispatch and cancel need a credential
$fake = Git::fake();
$actions = Git::github()->repo('acme/app')->actions();
$dispatched = $actions->dispatch('deploy.yml', 'refs/heads/main', ['request_id' => 'r-1']);
$run = $actions->runs('deploy.yml')->event('workflow_dispatch')->branch('main')->first(); // the queued run
$fake->fakeFor(ProviderName::Github)->seedWorkflowRun($completed); // the next poll's state
$actions->cancel($dispatched->runId); // false — already completed
$fake->assertWorkflowDispatched('deploy.yml', 'refs/heads/main', ['request_id' => 'r-1'], 'acme/app');- dispatch() needs a credential and creates the queued run it reports: the next numeric id (1 on an empty fake), event workflow_dispatch, headBranch from the ref and headSha from seedBranch(), else fake-sha. After seedDispatchWithoutRunDetails() it answers without a runId, as GitHub Enterprise Server does — the run is created either way, so the fallback finder can be tested.
- runs() answers the seeded runs newest first, paged with total, honouring the workflow and every filter. run($id) answers the seeded run, else a RuntimeException naming seedWorkflowRun(); seedWorkflowRun() is an upsert by id, so it seeds the next poll’s state.
- jobs() answers seedWorkflowJobs($runId, …) — the latest attempt by default, all after allAttempts(), one after attempt($n).
- cancel() needs a credential and answers false when the seeded run is completed, else true. Seeds stay as they are: GitHub cancels asynchronously, so a test seeds the next state itself.
- activity() pages seedActivity() in the order seeded, filtered by ref; a cursor the fake never handed out throws InvalidArgumentException. An unseeded compare() answers Identical with no commits.
- A fake GitLab or Bitbucket refuses every Actions call and activity(), as production does.
GitHub App flows
With fake app keys configured, Git::githubApp() authenticates as the app and Git::github() as the installation, so an install-and-verify flow runs end to end without HTTP:
config([
'git.providers.github.app.id' => '123',
'git.providers.github.app.private_key' => 'test-key',
'git.providers.github.app.installation_id' => '456',
]);
$fake = Git::fake();
$fake->fakeFor(ProviderName::Github)->seedInstallation($installation)->seedRepositories([$repositoryDto]);
Git::githubApp()->installations()->forOrganization('acme-inc');
Git::github()->installationRepositories();
Git::assertSent(ProviderName::Github, 'organizationInstallation');installUrl() needs app.slug, as on the real driver — without it, it throws missingAppConfig() — and encodes the slug the same way.
Show 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.