---
title: "Testing — Git for Laravel | Roundly"
description: "Git::fake() records every provider call for the facade and injected managers — seed responses, run code without HTTP, assert what was sent."
url: https://roundly-consulting.com/open-source/docs/git-for-laravel/testing
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Git for Laravel](https://roundly-consulting.com/open-source/docs/git-for-laravel.md)

# 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:

```php
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'))):

```php
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:

```php
// 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

```php
$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:

```php
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.

```php
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:

```php
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:

```php
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 crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/git-for-laravel.md)
