Backend Testing (PHPUnit)
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:
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
RolesAndPermissionsSeederruns before each test, setting up theplatform-admin,admin,member, andownerroles via Spatie Permission - Currency fixtures — EUR, USD, and GBP currencies are created (required by the
tenantstable FK constraint onpreferred_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.
| Helper | Returns | What 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() | User | Creates a user and attaches them to an existing tenant with a specified role |
createPlatformAdmin() | User | Creates a user with is_platform_admin = true and the platform-admin role |
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 Directory | What 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:
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:
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 container —
app(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 checkinghasRole(), 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
| Factory | Key States |
|---|---|
UserFactory | unverified(), platformAdmin(), withOAuth($provider) |
TenantFactory | withOwner(), withBillingInfo(), withStripeCustomer(), onboardingIncomplete() |
SubscriptionFactory | trialing(), free(), canceled(), cancelingAtPeriodEnd(), withStripeIds(), forTenant(), forPlan() |
PlanFactory | active(), inactive(), free(), pro(), enterprise(), flatRate(), seatBased(), usageBased(), monthly(), yearly() |
PlanPriceFactory | forPlan(), inCurrency(), withPrice(), usd(), eur(), gbp() |
InvoiceFactory | open(), paid(), void(), forTenant(), forSubscription(), withStripeIds() |
Example: SubscriptionFactory
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();
$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:
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);
}
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
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
| Goal | Command |
|---|---|
| All backend tests | make test-back |
| Single test file | docker compose exec php php artisan test tests/Feature/Tenancy/TenantIsolationTest.php |
| Filter by test name | docker compose exec php php artisan test --filter=test_user_can_only_access_their_tenant |
| Run tests in parallel | docker compose exec php php artisan test --parallel |
| With coverage report | docker 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 |
--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
- Frontend Testing (Vitest) — Vitest configuration, Nuxt test utilities, mock factories, composable and component test patterns
- Testing Overview — Toolchain comparison, CI pipeline, and quick start commands
- Testing Multi-Tenant Isolation — Deep dive on tenant isolation test patterns and the
WithTenantContexttrait - Billing Overview — Billing domain context for understanding billing test scenarios
Testing Overview
Testing philosophy, toolchain, directory structure, quick-start commands, and CI pipeline for the SaaS4Builders boilerplate.
Frontend Testing (Vitest)
How to write frontend tests: Vitest configuration, Nuxt test utilities, mock factories, store tests, composable tests, component tests, API module tests, and coverage.