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:
Víctor Falcón 2026-02-24 10:47:51 +01:00 committed by GitHub
parent 9a12d86063
commit 93f1f82ac5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 406 additions and 5 deletions

View File

@ -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!"

View File

@ -14,6 +14,9 @@
<testsuite name="Browser">
<directory>tests/Browser</directory>
</testsuite>
<testsuite name="Performance">
<directory>tests/Performance</directory>
</testsuite>
</testsuites>
<source>
<include>

View File

@ -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');
});

View File

@ -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');
});

View File

@ -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')