test: add performance test suite with query count ceilings (#148)
## Why ### Problem As new features are added, dashboard and settings pages have been accumulating extra database queries — risking N+1 regressions and degraded response times. There was no automated way to detect these regressions before they reach production. ## What ### Changes - **New `Performance` test suite** (`tests/Performance/`) with 25 tests that enforce query count ceilings on every page and API endpoint accessible from the sidebar - `PageQueryCountTest.php` — 15 tests covering Dashboard, Accounts, Transactions, Budgets, Cashflow, and all Settings pages, plus scaling tests that prove query count stays constant as data volume grows - `ApiQueryCountTest.php` — 10 tests covering all Dashboard and Cashflow analytics API endpoints, plus scaling tests - **Separate CI job** (`performance-tests`) that runs in parallel with `tests` and `linter`, and gates both `build-image` and `deploy` - **Excluded from the main `tests` job** to avoid running them twice (`--exclude-testsuite=Performance`) - Helper functions (`performanceSeedUser`, `countQueries`, `assertMaxQueries`) added to `tests/Pest.php` for reuse - `phpunit.xml` updated with the new `Performance` testsuite entry ### How it works Each test seeds a user with realistic data (3 accounts, 30 transactions, 15 balances, 5 categories, 3 labels, 1 budget) and asserts the endpoint executes **at most N queries**. Thresholds have a small buffer (~3 queries) above current counts — tight enough to catch N+1 regressions (which add dozens of queries) but loose enough to not break on minor legitimate changes. On failure, every executed SQL query is dumped for easy debugging. ### Run locally ```bash php artisan test --testsuite=Performance ``` ## Verification ### Tests All 25 performance tests pass. Existing Feature (633) and Unit (22) suites unaffected.
This commit is contained in:
parent
9a12d86063
commit
93f1f82ac5
|
|
@ -58,7 +58,7 @@ jobs:
|
|||
run: php artisan key:generate
|
||||
|
||||
- name: Tests
|
||||
run: ./vendor/bin/pest --exclude-testsuite=Browser
|
||||
run: ./vendor/bin/pest --exclude-testsuite=Browser,Performance
|
||||
env:
|
||||
DB_CONNECTION: mysql
|
||||
DB_HOST: 127.0.0.1
|
||||
|
|
@ -155,9 +155,58 @@ jobs:
|
|||
- name: Frontend Tests
|
||||
run: bun run test
|
||||
|
||||
performance-tests:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: password
|
||||
MYSQL_DATABASE: testing
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.4
|
||||
tools: composer:v2
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install Node Dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Install Dependencies
|
||||
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
- name: Build Assets
|
||||
run: bun run build
|
||||
|
||||
- name: Copy Environment File
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Generate Application Key
|
||||
run: php artisan key:generate
|
||||
|
||||
- name: Performance Tests
|
||||
run: ./vendor/bin/pest --testsuite=Performance
|
||||
env:
|
||||
DB_CONNECTION: mysql
|
||||
DB_HOST: 127.0.0.1
|
||||
DB_PORT: 3306
|
||||
DB_DATABASE: testing
|
||||
DB_USERNAME: root
|
||||
DB_PASSWORD: password
|
||||
|
||||
build-image:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [tests, linter]
|
||||
needs: [tests, linter, performance-tests]
|
||||
if: (github.ref == 'refs/heads/main' && github.event_name == 'push') || github.event_name == 'workflow_dispatch'
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -201,7 +250,7 @@ jobs:
|
|||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [tests, linter]
|
||||
needs: [tests, linter, performance-tests]
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
steps:
|
||||
- name: Trigger deployment
|
||||
|
|
@ -227,4 +276,3 @@ jobs:
|
|||
fi
|
||||
|
||||
echo "Deployment triggered successfully!"
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@
|
|||
<testsuite name="Browser">
|
||||
<directory>tests/Browser</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Performance">
|
||||
<directory>tests/Performance</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| API Analytics Query Count Tests
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These tests enforce query count ceilings for all analytics API endpoints
|
||||
| used by the dashboard and cashflow pages. These endpoints are called
|
||||
| asynchronously from the frontend and are critical to page load performance.
|
||||
|
|
||||
| Run this suite in isolation:
|
||||
| php artisan test --testsuite=Performance
|
||||
|
|
||||
*/
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = performanceSeedUser();
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$this->dateParams = http_build_query([
|
||||
'from' => now()->subDays(30)->toDateString(),
|
||||
'to' => now()->toDateString(),
|
||||
]);
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Dashboard Analytics API
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('net worth API does not exceed query threshold', function () {
|
||||
assertMaxQueries(25, function () {
|
||||
$this->getJson("/api/dashboard/net-worth?{$this->dateParams}")->assertOk();
|
||||
}, 'API Net Worth');
|
||||
});
|
||||
|
||||
test('monthly spending API does not exceed query threshold', function () {
|
||||
assertMaxQueries(12, function () {
|
||||
$this->getJson("/api/dashboard/monthly-spending?{$this->dateParams}")->assertOk();
|
||||
}, 'API Monthly Spending');
|
||||
});
|
||||
|
||||
test('cash flow API does not exceed query threshold', function () {
|
||||
assertMaxQueries(15, function () {
|
||||
$this->getJson("/api/dashboard/cash-flow?{$this->dateParams}")->assertOk();
|
||||
}, 'API Cash Flow');
|
||||
});
|
||||
|
||||
test('top categories API does not exceed query threshold', function () {
|
||||
assertMaxQueries(12, function () {
|
||||
$this->getJson("/api/dashboard/top-categories?{$this->dateParams}")->assertOk();
|
||||
}, 'API Top Categories');
|
||||
});
|
||||
|
||||
test('net worth evolution API does not exceed query threshold', function () {
|
||||
assertMaxQueries(25, function () {
|
||||
$this->getJson("/api/dashboard/net-worth-evolution?{$this->dateParams}")->assertOk();
|
||||
}, 'API Net Worth Evolution');
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Cashflow Analytics API
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('cashflow summary API does not exceed query threshold', function () {
|
||||
assertMaxQueries(15, function () {
|
||||
$this->getJson("/api/cashflow/summary?{$this->dateParams}")->assertOk();
|
||||
}, 'API Cashflow Summary');
|
||||
});
|
||||
|
||||
test('cashflow trend API does not exceed query threshold', function () {
|
||||
assertMaxQueries(35, function () {
|
||||
$this->getJson('/api/cashflow/trend')->assertOk();
|
||||
}, 'API Cashflow Trend');
|
||||
});
|
||||
|
||||
test('cashflow breakdown API does not exceed query threshold', function () {
|
||||
assertMaxQueries(15, function () {
|
||||
$this->getJson("/api/cashflow/breakdown?type=expense&{$this->dateParams}")->assertOk();
|
||||
}, 'API Cashflow Breakdown');
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Query count must not scale with data volume
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('net worth API query count does not scale with number of accounts', function () {
|
||||
$categories = $this->user->categories;
|
||||
|
||||
$extraAccounts = App\Models\Account::factory(7)->create(['user_id' => $this->user->id]);
|
||||
foreach ($extraAccounts as $index => $account) {
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
App\Models\AccountBalance::factory()->create([
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => now()->subDays(($index * 5) + $i + 20)->toDateString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// The net worth endpoint queries per-account, so we allow proportional growth
|
||||
// but cap it to prevent unbounded scaling
|
||||
assertMaxQueries(55, function () {
|
||||
$this->getJson("/api/dashboard/net-worth?{$this->dateParams}")->assertOk();
|
||||
}, 'API Net Worth with 10 accounts');
|
||||
});
|
||||
|
||||
test('cashflow trend API query count does not scale with extra months', function () {
|
||||
assertMaxQueries(35, function () {
|
||||
$this->getJson('/api/cashflow/trend?months=12')->assertOk();
|
||||
}, 'API Cashflow Trend 12 months');
|
||||
});
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Page Query Count Tests
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These tests enforce query count ceilings for all dashboard and settings
|
||||
| pages. If a page exceeds its threshold, it means new queries were added
|
||||
| (likely N+1 regressions or unnecessary eager loads).
|
||||
|
|
||||
| Each threshold includes a small buffer above the current count so that
|
||||
| minor legitimate changes don't cause false failures, while N+1 issues
|
||||
| (which typically add dozens of queries) are reliably caught.
|
||||
|
|
||||
| Run this suite in isolation:
|
||||
| php artisan test --testsuite=Performance
|
||||
|
|
||||
*/
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = performanceSeedUser();
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Main Sidebar Pages
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('dashboard page does not exceed query threshold', function () {
|
||||
assertMaxQueries(15, function () {
|
||||
$this->get(route('dashboard'))->assertOk();
|
||||
}, 'Dashboard');
|
||||
});
|
||||
|
||||
test('accounts index page does not exceed query threshold', function () {
|
||||
assertMaxQueries(15, function () {
|
||||
$this->get(route('accounts.list'))->assertOk();
|
||||
}, 'Accounts Index');
|
||||
});
|
||||
|
||||
test('account show page does not exceed query threshold', function () {
|
||||
$account = $this->user->accounts()->first();
|
||||
|
||||
assertMaxQueries(17, function () use ($account) {
|
||||
$this->get(route('accounts.show', $account))->assertOk();
|
||||
}, 'Account Show');
|
||||
});
|
||||
|
||||
test('transactions index page does not exceed query threshold', function () {
|
||||
assertMaxQueries(20, function () {
|
||||
$this->get(route('transactions.index'))->assertOk();
|
||||
}, 'Transactions Index');
|
||||
});
|
||||
|
||||
test('budgets index page does not exceed query threshold', function () {
|
||||
assertMaxQueries(18, function () {
|
||||
$this->get(route('budgets.index'))->assertOk();
|
||||
}, 'Budgets Index');
|
||||
});
|
||||
|
||||
test('budget show page does not exceed query threshold', function () {
|
||||
$budget = $this->user->budgets()->first();
|
||||
|
||||
assertMaxQueries(20, function () use ($budget) {
|
||||
$this->get(route('budgets.show', $budget))->assertOk();
|
||||
}, 'Budget Show');
|
||||
});
|
||||
|
||||
test('cashflow page does not exceed query threshold', function () {
|
||||
assertMaxQueries(15, function () {
|
||||
$this->get(route('cashflow'))->assertOk();
|
||||
}, 'Cashflow');
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Settings Pages
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('settings accounts page does not exceed query threshold', function () {
|
||||
assertMaxQueries(15, function () {
|
||||
$this->get(route('accounts.index'))->assertOk();
|
||||
}, 'Settings Accounts');
|
||||
});
|
||||
|
||||
test('settings categories page does not exceed query threshold', function () {
|
||||
assertMaxQueries(15, function () {
|
||||
$this->get(route('categories.index'))->assertOk();
|
||||
}, 'Settings Categories');
|
||||
});
|
||||
|
||||
test('settings labels page does not exceed query threshold', function () {
|
||||
assertMaxQueries(15, function () {
|
||||
$this->get(route('labels.index'))->assertOk();
|
||||
}, 'Settings Labels');
|
||||
});
|
||||
|
||||
test('settings automation rules page does not exceed query threshold', function () {
|
||||
assertMaxQueries(15, function () {
|
||||
$this->get(route('automation-rules.index'))->assertOk();
|
||||
}, 'Settings Automation Rules');
|
||||
});
|
||||
|
||||
test('settings account/profile page does not exceed query threshold', function () {
|
||||
assertMaxQueries(15, function () {
|
||||
$this->get(route('account.edit'))->assertOk();
|
||||
}, 'Settings Account/Profile');
|
||||
});
|
||||
|
||||
test('settings appearance page does not exceed query threshold', function () {
|
||||
assertMaxQueries(15, function () {
|
||||
$this->get(route('appearance.edit'))->assertOk();
|
||||
}, 'Settings Appearance');
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Query count must not scale with data volume
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('dashboard query count does not scale with number of accounts', function () {
|
||||
// Add 7 more accounts (10 total) with transactions and balances
|
||||
$categories = $this->user->categories;
|
||||
|
||||
$extraAccounts = App\Models\Account::factory(7)->create(['user_id' => $this->user->id]);
|
||||
foreach ($extraAccounts as $index => $account) {
|
||||
App\Models\Transaction::factory(10)->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $account->id,
|
||||
'category_id' => $categories->random()->id,
|
||||
]);
|
||||
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
App\Models\AccountBalance::factory()->create([
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => now()->subDays(($index * 5) + $i + 20)->toDateString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Same threshold as 3 accounts — query count must not grow with data
|
||||
assertMaxQueries(15, function () {
|
||||
$this->get(route('dashboard'))->assertOk();
|
||||
}, 'Dashboard with 10 accounts');
|
||||
});
|
||||
|
||||
test('transactions page query count does not scale with number of transactions', function () {
|
||||
$account = $this->user->accounts()->first();
|
||||
$category = $this->user->categories()->first();
|
||||
|
||||
// Add 90 more transactions (120 total)
|
||||
App\Models\Transaction::factory(90)->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $account->id,
|
||||
'category_id' => $category->id,
|
||||
]);
|
||||
|
||||
// Same threshold — paginated queries should not scale
|
||||
assertMaxQueries(20, function () {
|
||||
$this->get(route('transactions.index'))->assertOk();
|
||||
}, 'Transactions with 120 records');
|
||||
});
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
|
||||
pest()->extend(Tests\TestCase::class)
|
||||
->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
|
||||
->in('Feature', 'Browser');
|
||||
->in('Feature', 'Browser', 'Performance');
|
||||
|
||||
pest()->browser()->timeout(15000);
|
||||
|
||||
|
|
@ -43,6 +43,84 @@ pest()->browser()->timeout(15000);
|
|||
|
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a user with realistic data for performance testing.
|
||||
*
|
||||
* Includes 3 accounts, 30 transactions, 15 balances, 5 categories,
|
||||
* 3 labels, and 1 budget with a current period.
|
||||
*/
|
||||
function performanceSeedUser(): App\Models\User
|
||||
{
|
||||
$user = App\Models\User::factory()->onboarded()->create();
|
||||
|
||||
$categories = App\Models\Category::factory(5)->create(['user_id' => $user->id]);
|
||||
App\Models\Label::factory(3)->create(['user_id' => $user->id]);
|
||||
|
||||
$accounts = App\Models\Account::factory(3)->create(['user_id' => $user->id]);
|
||||
|
||||
foreach ($accounts as $index => $account) {
|
||||
App\Models\Transaction::factory(10)->plaintext()->create([
|
||||
'user_id' => $user->id,
|
||||
'account_id' => $account->id,
|
||||
'category_id' => $categories->random()->id,
|
||||
]);
|
||||
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
App\Models\AccountBalance::factory()->create([
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => now()->subDays(($index * 5) + $i + 1)->toDateString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$budget = App\Models\Budget::factory()->monthly()->create(['user_id' => $user->id]);
|
||||
App\Models\BudgetPeriod::factory()->create([
|
||||
'budget_id' => $budget->id,
|
||||
'start_date' => now()->startOfMonth(),
|
||||
'end_date' => now()->endOfMonth(),
|
||||
]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of database queries executed by the given callback.
|
||||
*
|
||||
* @return array{count: int, queries: list<string>}
|
||||
*/
|
||||
function countQueries(\Closure $callback): array
|
||||
{
|
||||
$queryLog = [];
|
||||
|
||||
Illuminate\Support\Facades\DB::listen(function ($query) use (&$queryLog) {
|
||||
$queryLog[] = $query->sql;
|
||||
});
|
||||
|
||||
$callback();
|
||||
|
||||
return ['count' => count($queryLog), 'queries' => $queryLog];
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the callback executes at most $max database queries.
|
||||
*
|
||||
* On failure, dumps all executed queries for easy debugging.
|
||||
*/
|
||||
function assertMaxQueries(int $max, \Closure $callback, string $context = ''): void
|
||||
{
|
||||
$result = countQueries($callback);
|
||||
|
||||
if ($result['count'] > $max) {
|
||||
$message = "{$context}: Expected at most {$max} queries, but {$result['count']} were executed.\n\nQueries:\n";
|
||||
foreach ($result['queries'] as $i => $sql) {
|
||||
$message .= sprintf(" %d. %s\n", $i + 1, $sql);
|
||||
}
|
||||
test()->fail($message);
|
||||
}
|
||||
|
||||
expect($result['count'])->toBeLessThanOrEqual($max);
|
||||
}
|
||||
|
||||
function createCategoryViaUI($page, string $name, string $color = 'green', string $type = 'Expense'): void
|
||||
{
|
||||
$page->click('Create Category')
|
||||
|
|
|
|||
Loading…
Reference in New Issue