---
title: "Testing — Two-Factor for Laravel | Roundly"
description: "TwoFactor::fake() swaps in a programmable, no-crypto double that records every call — or drive real codes against a frozen clock."
url: https://roundly-consulting.com/open-source/docs/two-factor-for-laravel/testing
language: en
---

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

[Two-Factor for Laravel](https://roundly-consulting.com/open-source/docs/two-factor-for-laravel.md)

# Testing

TwoFactor::fake() swaps the service for a programmable, no-crypto double and returns it as a TwoFactorFake for assertions. The facade, an injected TwoFactorService and the model verbs all see it, so you can assert your 2FA flow without freezing the clock or computing real codes:

```php
use RoundlyConsulting\TwoFactor\Enums\TwoFactorMethod;
use RoundlyConsulting\TwoFactor\Facades\TwoFactor;

// attempt() only passes a user with confirmed 2FA, as in production. Enrol them first:
// under the fake, enrolment runs for real on its canned secret and accepts any code.
TwoFactor::fake();
TwoFactor::for($user)->start();
TwoFactor::for($user)->confirm('123456');

// Accept any code (the default) and assert the challenge was verified:
$fake = TwoFactor::fake()->accept();
$this->post('/login/2fa', ['code' => '123456'])->assertOk();
$fake->assertVerifiedFor($user);

// Reject every code:
TwoFactor::fake()->reject();
$this->post('/login/2fa', ['code' => '000000'])->assertStatus(422);

// Accept only a specific code:
TwoFactor::fake()->acceptCode('424242');

// Drive attempt(): pass via a recovery code, report 2 left, assert the method:
$fake = TwoFactor::fake()->acceptRecoveryCode()->withRemainingRecoveryCodes(2);
$this->post('/login/2fa', ['code' => 'ABCDE-12345'])->assertOk();
$fake->assertVerifiedVia(TwoFactorMethod::RecoveryCode);

// Fail as a replay (VerificationResult::$replayed === true):
TwoFactor::fake()->replay();

// Enrolment writes run for real on the fake's canned secret and codes, and are recorded:
$fake = TwoFactor::fake();
$this->post('/two-factor/disable')->assertOk();       // or $user->disableTwoFactor()
$this->post('/two-factor/enable')->assertOk();        // calls TwoFactor::for($user)->start()
$fake->assertDisabled($user);
$fake->assertStarted($user);
$fake->assertNothingRegenerated();
```

## What the fake does

- attempt() performs no TOTP maths — it returns the programmed outcome and records the user, the code and the result.
- Like the real action, attempt() fails a user without confirmed 2FA whatever is programmed — no event, nothing spent — so a test can’t pass on something production would refuse. Enrol the user first with start() and confirm() under the fake, which accepts any code.
- For an enrolled user, attempt() fires the real action’s events — TwoFactorVerified, RecoveryCodeConsumed, TwoFactorVerificationFailed and TwoFactorReplayDetected (with timestep 0) — and a recovery-code pass spends one stored code, so status() agrees with the result. A code typed with spaces or dashes counts as its digits, as in the real action.
- start(), confirm(), recoveryCodes()->regenerate() and disable() still run the real actions, on the fake’s canned secret and recovery codes and its programmable verify(), and are recorded once they succeed — a failed confirm is not recorded.
- status() and recoveryCodes()->remaining() read through to the model.

## Programmable behaviour

| Method | Effect |
| --- | --- |
| `accept()` | Every verify() and attempt() passes via TOTP (the default) — attempt() still fails a user without confirmed 2FA. |
| `acceptRecoveryCode()` | attempt() passes via a recovery code and spends one stored code (the first). |
| `reject()` | Every verify() and attempt() fails. |
| `replay()` | attempt() fails as a replay (replayed: true). |
| `acceptCode($code)` | Accept only this exact code, via the current method. |
| `withRemainingRecoveryCodes($n)` | Pin the reported count. Unset, the fake reports the user’s stored count, one lower when a recovery code passes. A stored code is spent either way. |
| `withSecret($secret)` | The secret generateSecret() returns. |
| `withRecoveryCodes(...$codes)` | The codes generateRecoveryCodes() returns. |

## Assertions

Every assertion on the returned fake. The $user argument of assertStarted(), assertConfirmed(), assertRegenerated() and assertDisabled() is optional:

| Assertion | Passes when |
| --- | --- |
| `assertVerified()` | At least one attempt() passed. |
| `assertVerifiedFor($user)` | An attempt() passed for this user. |
| `assertVerifiedVia(TwoFactorMethod::RecoveryCode)` | An attempt() passed via this TwoFactorMethod. |
| `assertVerificationFailed()` | At least one attempt() failed. |
| `assertVerifyCount(2)` | Exactly this many attempt() calls were recorded. |
| `assertCodeAttempted('424242')` | This code went through attempt() or the flat verify(). |
| `assertNothingVerified()` | No attempt() was recorded. |
| `assertStarted($user)` | start() succeeded — for this user when one is passed. |
| `assertNothingStarted()` | No enrolment was started. |
| `assertConfirmed($user)` | confirm() succeeded — a failed confirm is not recorded. |
| `assertNothingConfirmed()` | No enrolment was confirmed. |
| `assertRegenerated($user)` | recoveryCodes()->regenerate() ran — for this user when one is passed. |
| `assertNothingRegenerated()` | No recovery codes were regenerated. |
| `assertDisabled($user)` | disable() ran — for this user when one is passed. |
| `assertNothingDisabled()` | Two-factor was not disabled for anyone. |

A full challenge test using the verification assertions:

```php
use RoundlyConsulting\TwoFactor\Enums\TwoFactorMethod;
use RoundlyConsulting\TwoFactor\Facades\TwoFactor;

it('lets the user through with the right code only', function (): void {
    $user = User::factory()->create();

    // attempt() only passes a user with confirmed 2FA — enrol under a first fake:
    TwoFactor::fake();
    TwoFactor::for($user)->start();
    TwoFactor::for($user)->confirm('123456');

    $fake = TwoFactor::fake()->acceptCode('424242');

    $this->actingAs($user)->post('/login/2fa', ['code' => '000000'])->assertStatus(422);
    $this->actingAs($user)->post('/login/2fa', ['code' => '424242'])->assertOk();

    $fake->assertVerifyCount(2);
    $fake->assertCodeAttempted('000000');
    $fake->assertVerificationFailed();
    $fake->assertVerified();
    $fake->assertVerifiedFor($user);
    $fake->assertVerifiedVia(TwoFactorMethod::Totp);
    $fake->assertNothingStarted();
    $fake->assertNothingConfirmed();
    $fake->assertNothingDisabled();
});
```

Assertions throw TwoFactorAssertionFailedException — a package exception, not a PHPUnit assertion — so they work under any test runner. The fake is test-only: it is bound solely through TwoFactor::fake() and never in production.

## Testing with real codes

To exercise the real crypto, freeze the clock with Carbon::setTestNow() and compute the current code with TwoFactor::currentCode():

```php
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Event;
use RoundlyConsulting\TwoFactor\Events\TwoFactorConfirmed;
use RoundlyConsulting\TwoFactor\Events\TwoFactorReplayDetected;
use RoundlyConsulting\TwoFactor\Facades\TwoFactor;

it('rejects the confirmation code replayed at first login', function (): void {
    Carbon::setTestNow(Carbon::createFromTimestamp(1_700_000_000));
    Event::fake();

    $user = User::factory()->create();
    $setup = TwoFactor::for($user)->start();
    $code = TwoFactor::currentCode($setup->secret);

    TwoFactor::for($user->fresh())->confirm($code);

    expect(TwoFactor::for($user->fresh())->status()->enabled)->toBeTrue();
    Event::assertDispatched(TwoFactorConfirmed::class);

    // The exact code used to confirm cannot double as the first login code.
    expect(TwoFactor::for($user->fresh())->attempt($code)->verified)->toBeFalse();
    Event::assertDispatched(TwoFactorReplayDetected::class);

    // A code at a later timestep still verifies.
    Carbon::setTestNow(Carbon::createFromTimestamp(1_700_000_060));
    expect(TwoFactor::for($user->fresh())->attempt(TwoFactor::currentCode($setup->secret))->verified)->toBeTrue();

    Carbon::setTestNow();
});
```

## 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/two-factor-for-laravel.md)
