Skip to content
SaaS4Builders
Testing

Backend Testing (PHPUnit)

How to write backend tests: base TestCase, WithTenantContext trait, model factories, Stripe mocking patterns, feature and unit test conventions, and running tests.

The backend uses PHPUnit v12 with Mockery for mocking. The test suite is organized to mirror the application's layered architecture: feature tests validate HTTP endpoints end-to-end with a real database, while unit tests verify individual actions, queries, and infrastructure components in isolation.


Base TestCase

Every backend test extends the project's custom TestCase class, which handles common setup automatically:

backend/tests/TestCase.php
namespace Tests;

use App\Models\Currency;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Facades\Schema;
use Mockery;
use Spatie\Permission\PermissionRegistrar;

abstract class TestCase extends BaseTestCase
{
    protected bool $seed = true;

    protected string $seeder = RolesAndPermissionsSeeder::class;

    protected function setUp(): void
    {
        parent::setUp();

        $this->ensureCurrenciesExist();
    }

    protected function tearDown(): void
    {
        // Reset Spatie permission team context (teams mode enabled)
        setPermissionsTeamId(null);
        app(PermissionRegistrar::class)->forgetCachedPermissions();

        // Close Mockery mocks (Socialite, etc.)
        Mockery::close();

        parent::tearDown();
    }

    protected function ensureCurrenciesExist(): void
    {
        if (! Schema::hasTable('currencies')) {
            return;
        }

        $currencies = [
            ['code' => 'EUR', 'name' => 'Euro', 'symbol' => '', 'minor_units' => 2, 'is_active' => true],
            ['code' => 'USD', 'name' => 'US Dollar', 'symbol' => '$', 'minor_units' => 2, 'is_active' => true],
            ['code' => 'GBP', 'name' => 'British Pound', 'symbol' => '£', 'minor_units' => 2, 'is_active' => true],
        ];

        foreach ($currencies as $currency) {
            Currency::firstOrCreate(
                ['code' => $currency['code']],
                $currency
            );
        }
    }
}

What this gives you automatically on every test:

  • Role seeding — The RolesAndPermissionsSeeder runs before each test, setting up the platform-admin, admin, member, and owner roles via Spatie Permission
  • Currency fixtures — EUR, USD, and GBP currencies are created (required by the tenants table FK constraint on preferred_currency)
  • Clean Spatie context — The tearDown() resets the Spatie Permission team context to prevent state leaking between tests
  • Mockery cleanup — All Mockery mocks are closed after each test

The WithTenantContext Trait

Most tests that involve tenant-scoped resources use the WithTenantContext trait. It provides four helper methods that handle the boilerplate of creating users with tenants, setting up authentication, and configuring the Spatie Permission team context.

HelperReturnsWhat It Sets Up
createUserWithTenant()['user' => User, 'tenant' => Tenant]User + tenant + pivot relationship + admin role + currencies
actingAsWithTenant()$this (fluent)Sets tenant context (set_current_tenant) + Spatie team + Sanctum auth
createTenantMember()UserCreates a user and attaches them to an existing tenant with a specified role
createPlatformAdmin()UserCreates a user with is_platform_admin = true and the platform-admin role
Always use actingAsWithTenant() instead of Laravel's actingAs() alone. Using plain actingAs() does not set the tenant context, which means current_tenant() returns null, BelongsToTenant scopes fail silently, and your tests will pass without actually testing tenant isolation.

Here is a typical test setup using the trait:

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use Tests\Traits\WithTenantContext;

class MyFeatureTest extends TestCase
{
    use RefreshDatabase;
    use WithTenantContext;

    private array $data;

    protected function setUp(): void
    {
        parent::setUp();

        // Creates a user, a tenant, attaches them, assigns admin role
        $this->data = $this->createUserWithTenant();
    }

    public function test_something(): void
    {
        // Authenticates as the user with tenant context set
        $this->actingAsWithTenant($this->data['user'], $this->data['tenant'])
            ->getJson('/api/v1/tenant/subscription')
            ->assertStatus(200);
    }
}

For a comprehensive guide on testing multi-tenant isolation specifically, see Testing Multi-Tenant Isolation.


Writing Feature Tests

Feature tests live in tests/Feature/ and test HTTP endpoints with a real database (SQLite in-memory). They use RefreshDatabase to reset the database between tests.

Directory Conventions

Test DirectoryWhat to Test
tests/Feature/Api/V1/Tenant/Tenant-scoped API endpoints (subscriptions, team, usage)
tests/Feature/Api/V1/Admin/Platform admin endpoints (catalog management, impersonation)
tests/Feature/Api/V1/Auth/Authentication endpoints (login, register, OAuth, tokens)
tests/Feature/Auth/Auth flow integration tests (registration, password reset)
tests/Feature/Tenancy/Tenant isolation, resolution, and strict mode
tests/Feature/Webhooks/Stripe webhook signature verification and idempotency

Example: Tenant Isolation Test

This test verifies that a user can only access their own tenant's data — the most critical invariant in the system:

backend/tests/Feature/Tenancy/TenantIsolationTest.php
class TenantIsolationTest extends TestCase
{
    use RefreshDatabase;
    use WithTenantContext;

    public function test_user_can_only_access_their_tenant(): void
    {
        $data1 = $this->createUserWithTenant();
        $data2 = $this->createUserWithTenant();

        $token = $data1['user']->createToken('test')->plainTextToken;

        // Access own tenant - should work
        $response = $this->withHeaders([
            'Authorization' => "Bearer $token",
            'X-Tenant-ID' => $data1['tenant']->id,
        ])->getJson('/api/v1/tenant');

        $response->assertStatus(200);
        $this->assertEquals($data1['tenant']->id, $response->json('tenant.id'));
    }

    public function test_user_cannot_access_other_tenant(): void
    {
        $data1 = $this->createUserWithTenant();
        $data2 = $this->createUserWithTenant();

        $token = $data1['user']->createToken('test')->plainTextToken;

        // Try to access other tenant via header — falls back to user's own tenant
        $response = $this->withHeaders([
            'Authorization' => "Bearer $token",
            'X-Tenant-ID' => $data2['tenant']->id,
        ])->getJson('/api/v1/tenant');

        // User gets their own tenant data (fallback behavior — correct isolation)
        $response->assertStatus(200);
        $this->assertEquals($data1['tenant']->id, $response->json('tenant.id'));
    }

    public function test_tenant_member_can_access_tenant_routes(): void
    {
        $data = $this->createUserWithTenant();
        $member = $this->createTenantMember($data['tenant']);

        $token = $member->createToken('test')->plainTextToken;

        $response = $this->withHeaders([
            'Authorization' => "Bearer $token",
            'X-Tenant-ID' => $data['tenant']->id,
        ])->getJson('/api/v1/tenant');

        $response->assertStatus(200);
    }
}

The pattern is consistent: create users with tenants, authenticate via Bearer token, set the X-Tenant-ID header, and assert the expected response.


Writing Unit Tests

Unit tests live in tests/Unit/ and test individual classes — primarily actions, queries, and infrastructure components. The convention is to mock domain contracts (interfaces), not providers directly.

Example: Action Unit Test

This test verifies the RegisterUser action — it creates a user, a tenant, assigns the owner role, and attaches them:

backend/tests/Unit/Application/Auth/Actions/RegisterUserTest.php
class RegisterUserTest extends TestCase
{
    use RefreshDatabase;

    private RegisterUser $action;

    protected function setUp(): void
    {
        parent::setUp();
        $this->action = app(RegisterUser::class);
    }

    public function test_creates_user_with_valid_data(): void
    {
        $data = new RegisterUserData(
            name: 'John Doe',
            email: 'john@example.com',
            password: 'password123',
        );

        $result = $this->action->execute($data);

        $this->assertArrayHasKey('user', $result);
        $this->assertArrayHasKey('tenant', $result);
        $this->assertInstanceOf(User::class, $result['user']);
        $this->assertInstanceOf(Tenant::class, $result['tenant']);
    }

    public function test_creates_tenant_with_custom_name(): void
    {
        $data = new RegisterUserData(
            name: 'Alice',
            email: 'alice@example.com',
            password: 'password123',
            tenantName: 'Acme Corp',
        );

        $result = $this->action->execute($data);

        $this->assertEquals('Acme Corp', $result['tenant']->name);
    }

    public function test_assigns_owner_role_to_user(): void
    {
        $data = new RegisterUserData(
            name: 'Frank',
            email: 'frank@example.com',
            password: 'password123',
        );

        $result = $this->action->execute($data);

        setPermissionsTeamId($result['tenant']->id);
        $this->assertTrue($result['user']->hasRole('owner'));
    }
}

Key patterns in unit tests:

  • Resolve the action from the containerapp(RegisterUser::class) ensures dependency injection works correctly
  • Use DTOs as input — Actions receive typed data objects, not raw arrays
  • Assert database state — Use assertDatabaseHas() to verify records were persisted
  • Check Spatie roles with team context — Call setPermissionsTeamId() before checking hasRole(), because Spatie Permission runs in team mode

Model Factories

Factories are in backend/database/factories/ and provide rich state methods for creating models in various configurations. When writing tests, prefer factory states over manual attribute overrides — they are self-documenting and consistent.

Key Factories

FactoryKey States
UserFactoryunverified(), platformAdmin(), withOAuth($provider)
TenantFactorywithOwner(), withBillingInfo(), withStripeCustomer(), onboardingIncomplete()
SubscriptionFactorytrialing(), free(), canceled(), cancelingAtPeriodEnd(), withStripeIds(), forTenant(), forPlan()
PlanFactoryactive(), inactive(), free(), pro(), enterprise(), flatRate(), seatBased(), usageBased(), monthly(), yearly()
PlanPriceFactoryforPlan(), inCurrency(), withPrice(), usd(), eur(), gbp()
InvoiceFactoryopen(), paid(), void(), forTenant(), forSubscription(), withStripeIds()

Example: SubscriptionFactory

backend/database/factories/SubscriptionFactory.php
final class SubscriptionFactory extends Factory
{
    public function definition(): array
    {
        $now = now();

        return [
            'tenant_id' => Tenant::factory(),
            'plan_id' => Plan::factory(),
            'status' => SubscriptionStatus::Active,
            'currency' => 'EUR',
            'price_cents' => fake()->numberBetween(1000, 10000),
            'interval_unit' => 'month',
            'interval_count' => 1,
            'current_period_start' => $now,
            'current_period_end' => $now->copy()->addMonth(),
            'quantity' => 1,
        ];
    }

    public function trialing(?int $trialDays = 14): static
    {
        $now = now();

        return $this->state(fn (): array => [
            'status' => SubscriptionStatus::Trialing,
            'trial_ends_at' => $now->copy()->addDays($trialDays),
        ]);
    }

    public function canceled(?string $reason = null): static
    {
        return $this->state(fn (): array => [
            'status' => SubscriptionStatus::Canceled,
            'canceled_at' => now(),
            'cancellation_reason' => $reason ?? 'User requested cancellation',
        ]);
    }

    public function cancelingAtPeriodEnd(?string $reason = null): static
    {
        return $this->state(fn (): array => [
            'cancel_at_period_end' => true,
            'cancellation_reason' => $reason ?? 'User requested cancellation at period end',
        ]);
    }

    public function withStripeIds(?string $subscriptionId = null, ?string $itemId = null): static
    {
        return $this->state(fn (): array => [
            'stripe_subscription_id' => $subscriptionId ?? 'sub_'.Str::random(14),
            'stripe_item_id' => $itemId ?? 'si_'.Str::random(14),
        ]);
    }

    public function forTenant(Tenant $tenant): static
    {
        return $this->state(fn (): array => [
            'tenant_id' => $tenant->id,
        ]);
    }

    public function forPlan(Plan $plan): static
    {
        return $this->state(fn (): array => [
            'plan_id' => $plan->id,
            'currency' => $plan->default_currency,
            'interval_unit' => $plan->interval_unit->value,
            'interval_count' => $plan->interval_count,
        ]);
    }
}

Usage in tests:

// Active subscription with defaults
$subscription = Subscription::factory()->create();

// Trialing subscription for a specific tenant and plan
$subscription = Subscription::factory()
    ->trialing(14)
    ->forTenant($tenant)
    ->forPlan($plan)
    ->create();

// Canceled subscription with Stripe IDs
$subscription = Subscription::factory()
    ->canceled('Too expensive')
    ->withStripeIds()
    ->create();
When you need a user with a tenant for tests, prefer $this->createUserWithTenant() (from the WithTenantContext trait) over manually composing User::factory() and Tenant::factory(). The trait method handles the pivot table, Spatie role assignment, and currency fixtures in one call.

Stripe Mocking Patterns

The codebase uses three distinct mocking patterns for Stripe, depending on the test layer.

Pattern 1: Domain Contract Mocking (for Action Tests)

This is the recommended pattern for testing actions and queries. Mock the domain contract (interface), not the Stripe SDK:

// In your test
$gateway = Mockery::mock(PaymentGatewayInterface::class);
$gateway->shouldReceive('createCheckoutSession')
    ->once()
    ->andReturn(new CheckoutSession('cs_123', 'https://checkout.stripe.com/cs_123'));

$this->app->instance(PaymentGatewayInterface::class, $gateway);

This keeps your tests decoupled from the payment provider. If you switch from Stripe to another gateway, your action tests remain unchanged.

Pattern 2: Stripe Object Construction (for Infrastructure Tests)

When testing the Stripe provider implementation itself, use constructFrom() to create Stripe SDK objects without making API calls:

use Stripe\Checkout\Session as StripeSession;
use Stripe\Event;

// Create a mock Stripe session
$session = StripeSession::constructFrom([
    'id' => 'cs_test',
    'url' => 'https://checkout.stripe.com/cs_test',
]);

// Create a mock Stripe event
$event = Event::constructFrom([
    'id' => 'evt_test',
    'type' => 'checkout.session.completed',
    'data' => ['object' => ['id' => 'cs_test']],
]);

Pattern 3: Service Mocking (for Webhook and Handler Tests)

For testing webhook handlers and feature-level Stripe interactions, mock the StripeClient directly in the container:

backend/tests/Feature/Webhooks/StripeWebhookSignatureTest.php
public function test_it_rejects_invalid_signature(): void
{
    $this->mock(StripeClient::class, function ($mock): void {
        $mock->shouldReceive('constructWebhookEvent')
            ->andThrow(new SignatureVerificationException('Invalid signature'));
    });

    $response = $this->postJson('/api/v1/webhooks/stripe', [
        'id' => 'evt_test',
        'type' => 'checkout.session.completed',
    ], [
        'Stripe-Signature' => 'invalid_signature',
    ]);

    $response->assertStatus(400);
    $response->assertJson(['error' => 'Invalid signature']);
}

public function test_it_accepts_valid_signature(): void
{
    $this->mock(StripeClient::class, function ($mock): void {
        $mock->shouldReceive('constructWebhookEvent')
            ->andReturn(Event::constructFrom([
                'id' => 'evt_valid',
                'type' => 'ping',
                'data' => ['object' => []],
            ]));
    });

    $response = $this->postJson('/api/v1/webhooks/stripe', [
        'id' => 'evt_valid',
        'type' => 'ping',
    ], [
        'Stripe-Signature' => 'any_signature_mocked',
    ]);

    $response->assertStatus(200);
}
For action and query tests, always use Pattern 1 (domain contract mocking). This keeps your tests decoupled from Stripe. Use Patterns 2 and 3 only when you are testing the Stripe integration layer itself.

Static Analysis and Code Style

The backend enforces two quality gates that run before tests in CI:

PHPStan (Level 9)

PHPStan runs at level 9 — the strictest setting. It catches type errors, missing return types, incorrect property access, and more:

docker compose exec php ./vendor/bin/phpstan analyse --memory-limit=512M

The configuration in backend/phpstan.neon includes project-specific ignores for Stripe SDK dynamic properties, Spatie Permission custom columns, and a few other framework-level patterns. You should not need to modify these unless you add a new external package with similar dynamic behavior.

Laravel Pint (Code Formatting)

Pint enforces a consistent code style across the entire backend:

# Fix formatting
docker compose exec php ./vendor/bin/pint

# Check-only (used in CI)
docker compose exec php ./vendor/bin/pint --test

# Format only changed files
docker compose exec php ./vendor/bin/pint --dirty
CI runs pint --test and PHPStan before the test suite. If either fails, the pipeline stops immediately. Run make lint-back locally before pushing to catch formatting and type issues early.

Running Tests

GoalCommand
All backend testsmake test-back
Single test filedocker compose exec php php artisan test tests/Feature/Tenancy/TenantIsolationTest.php
Filter by test namedocker compose exec php php artisan test --filter=test_user_can_only_access_their_tenant
Run tests in paralleldocker compose exec php php artisan test --parallel
With coverage reportdocker compose exec php php artisan test --coverage
With coverage (Clover XML)docker compose exec php php artisan test --parallel --coverage-clover coverage.xml
Linting + tests (CI sim)make check
During development, use --filter to run only the tests relevant to your current change. The full suite can take a while — save it for before you push.

What's Next