Testing
This page covers the testing infrastructure, conventions, and tools used in Ignite.
Overview
Testing stack
| Layer | Tool |
|---|---|
| Backend test framework | PHPUnit (via Laravel) |
| Backend database | SQLite in-memory (fast, isolated) |
| Frontend test runner | Vitest |
| Frontend component testing | @vue/test-utils |
| Static analysis | Larastan / PHPStan (level 3) |
| CI | GitHub Actions |
Testing conventions
Test categories
The backend test suite is divided into three categories:
Unit tests: tests/Unit/
- No Laravel boot, no database
- Pure PHP logic: helpers, value objects, standalone services
- Fast and side-effect-free
Feature tests: tests/Feature/
- Laravel fully booted, database available
- Mirror the
app/directory structure - Cover controllers, models, observers, policies
Integration tests: tests/Integration/
- Multi-component workflows (e.g. creation, progress, auto-completion)
- Do not map to a single class
Naming conventions
- All test classes use the
*Testsuffix - Test method names use snake_case prefixed with
test_ - Names describe the behavior, not the implementation
// Good
test_user_can_create_a_goal()
test_guest_is_redirected_to_login()
test_progress_percentage_is_capped_at_100()
// Avoid
test_goal()
test_create()
test_it_works()Backend architecture (PHPUnit)
Directory structure
Feature tests mirror the app/ directory structure:
app/
├── Http/Controllers/Goals/GoalController.php
├── Models/Goal.php
└── Observers/GoalObserver.php
tests/
├── Feature/
│ ├── Auth/
│ ├── Settings/
│ ├── Http/
│ │ └── Controllers/
│ │ ├── Goals/
│ │ │ ├── GoalControllerTest.php
│ │ │ └── GoalEntryControllerTest.php
│ │ ├── CategoryControllerTest.php
│ │ └── DashboardControllerTest.php
│ ├── Models/
│ │ ├── GoalTest.php
│ │ ├── GoalEntryTest.php
│ │ ├── CategoryTest.php
│ │ ├── UserTest.php
│ │ └── MilestoneTest.php
│ └── Observers/
│ ├── GoalObserverTest.php
│ └── CategoryObserverTest.php
├── Integration/
│ └── GoalProgressFlowTest.php
├── Unit/
│ └── (pure logic, no DB)
└── TestCase.phpBase test class
All feature tests extend Tests\TestCase and use RefreshDatabase:
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class GoalTest extends TestCase
{
use RefreshDatabase;
// ...
}RefreshDatabase wraps each test in a transaction and rolls it back, so no manual cleanup is needed. The test database is SQLite in-memory, configured in phpunit.xml.
Frontend architecture (Vitest)
Directory structure
Frontend tests mirror resources/js/:
resources/js/
├── components/goals/BaseGoalCard.vue
├── components/categories/CategoryFormModal.vue
├── composables/useLocale.ts
├── pages/Goals/Index.vue
└── lib/utils.ts
tests/js/
├── components/
│ ├── goals/
│ │ ├── BaseGoalCardTest.ts
│ │ ├── GoalBadgesTest.ts
│ │ ├── GoalFormTest.ts
│ │ └── progress/RecurringProgressTest.ts
│ ├── milestones/
│ │ ├── MilestoneFormModalTest.ts
│ │ └── TimelineTest.ts
│ ├── categories/
│ │ └── CategoryFormModalTest.ts
│ └── ui/
│ ├── HelpTooltipTest.ts
│ ├── PasswordInputTest.ts
│ └── StatusDotTest.ts
├── composables/
│ ├── useChartThemeTest.ts
│ └── useLocaleTest.ts
├── pages/
│ ├── CategoriesIndexTest.ts
│ ├── ErrorPageTest.ts
│ ├── GoalsIndexTest.ts
│ └── GoalsShowTest.ts
├── lib/
│ ├── chartThemeTest.ts
│ ├── chartUtilsTest.ts
│ ├── i18nBootTest.ts
│ ├── momentLocaleTest.ts
│ └── utilsTest.ts
└── setup.tsTest types
Component tests mount a Vue component in isolation and assert its rendered output and behavior based on props.
Page tests mount an Inertia page component from resources/js/pages/ and assert what it renders from its props. Pages depend on Inertia's router and head manager, so those are stubbed rather than avoided (see "Page tests" below).
Composable tests exercise the composition functions in resources/js/composables/.
Utility tests exercise pure TypeScript functions (e.g. getDateDiffFromNow) without mounting any component.
Every page and component added to the app is expected to carry a test. Backend controller tests assert that the correct Inertia component and props are returned; the page test asserts what the page does with those props. The two are complementary, not alternatives.
Running tests
Backend
# Run all tests
php artisan test
# Run a specific testsuite
php artisan test --testsuite=Feature
php artisan test --testsuite=Unit
php artisan test --testsuite=Integration
# Run a specific file
php artisan test tests/Feature/Models/GoalTest.php
# Run a specific method
php artisan test --filter=test_progress_percentage_for_ascending_goal
# Stop on first failure
php artisan test --stop-on-failure
# Run with coverage (requires Xdebug)
php artisan test --coveragecomposer test runs php artisan config:clear followed by php artisan test.
Frontend
# Run all frontend tests once
npm run test:js
# Watch mode
npm run test:js:watch
# Run a specific file
npx vitest tests/js/components/goals/QuantifiableGoalCardTest.ts
# With coverage
npx vitest --coveragenpm run test:js maps to vitest run in package.json. CI (and any environment without a real .env/APP_KEY already set up) runs it as:
LARAVEL_BYPASS_ENV_CHECK=1 npx vitest --runThe LARAVEL_BYPASS_ENV_CHECK flag skips the Laravel Vite plugin's environment checks, which otherwise expect a valid .env with APP_KEY set.
Static analysis
./vendor/bin/phpstan analyse --memory-limit=512Mcomposer check runs Pint (formatting check), PHPStan, and the full backend test suite in sequence.
Do not run the full backend or frontend suite speculatively during day-to-day work; run only the tests you created or changed, or the ones in the same group/folder. Let CI run the full suite.
Writing backend tests
Model tests
Model tests live in tests/Feature/Models/ and verify relationships, casts, accessors, and methods. Organize with section headers:
class GoalTest extends TestCase
{
use RefreshDatabase;
// =========================================================================
// RELATIONSHIP TESTS
// =========================================================================
// =========================================================================
// CAST TESTS
// =========================================================================
// =========================================================================
// ACCESSOR TESTS
// =========================================================================
// =========================================================================
// METHOD TESTS
// =========================================================================
}Relationship tests
For each relationship, verify:
- The FK record exists in the database
- The relationship method returns the correct model type
// BelongsTo
public function test_goal_belongs_to_user()
{
$user = User::factory()->create();
$goal = Goal::factory()->create(['user_id' => $user->id]);
$this->assertDatabaseHas('goals', ['user_id' => $user->id]);
$this->assertInstanceOf(User::class, $goal->user);
$this->assertEquals($user->id, $goal->user->id);
}
// HasMany (test via inverse)
public function test_goal_has_many_entries()
{
$goal = Goal::factory()->create();
$entry = GoalEntry::factory()->create(['goal_id' => $goal->id]);
$this->assertDatabaseHas('goal_entries', ['goal_id' => $goal->id]);
$this->assertCount(1, $goal->entries);
$this->assertInstanceOf(GoalEntry::class, $goal->entries->first());
}Cast tests
public function test_goal_casts_current_value_as_decimal()
{
$goal = Goal::factory()->create(['current_value' => 10.5]);
$this->assertIsFloat($goal->current_value); // or assertIsString if cast to decimal string
$this->assertEquals(10.5, $goal->current_value);
}Accessor tests
public function test_progress_percentage_for_ascending_goal()
{
$goal = Goal::factory()->create([
'type' => 'quantifiable',
'direction' => 'ascending',
'initial_value' => 0,
'current_value' => 50,
'target_value' => 100,
]);
$this->assertEquals(50, $goal->progress_percentage);
}Common pitfalls
Never use hardcoded IDs for foreign keys:
// Fails on FK constraint
Goal::factory()->create(['user_id' => 999]);
// Correct: always create the related record
$user = User::factory()->create();
Goal::factory()->create(['user_id' => $user->id]);Observer vs factory: factories bypass observers by default in some configurations. To test observer logic, use Model::create() directly instead of factories.
Controller tests
Controller tests live in tests/Feature/Http/Controllers/, mirroring app/Http/Controllers/.
Every controller test must cover:
- Authorization: guest redirect, ownership enforcement
- Happy path: successful action with correct DB state and redirect
- Validation: required fields, type constraints
class GoalControllerTest extends TestCase
{
use RefreshDatabase;
public function test_guest_is_redirected_to_login()
{
$response = $this->get(route('goals.index'));
$response->assertRedirect(route('login'));
}
public function test_user_can_create_a_goal()
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post(route('goals.store'), [
'title' => 'Run a marathon',
'type' => 'simple',
]);
$response->assertRedirect();
$this->assertDatabaseHas('goals', [
'user_id' => $user->id,
'title' => 'Run a marathon',
]);
}
public function test_user_cannot_edit_other_users_goal()
{
$owner = User::factory()->create();
$other = User::factory()->create();
$goal = Goal::factory()->create(['user_id' => $owner->id]);
$response = $this->actingAs($other)->get(route('goals.edit', $goal));
$response->assertForbidden();
}
}For Inertia responses, use assertInertia():
$response->assertInertia(fn ($page) =>
$page->component('Goals/Index')
->has('goals')
);Observer tests
Observer tests live in tests/Feature/Observers/. Use Model::create() directly (not factories) to exercise the observer lifecycle:
class GoalObserverTest extends TestCase
{
use RefreshDatabase;
public function test_ascending_goal_auto_completes_when_target_reached()
{
$user = User::factory()->create();
$goal = Goal::create([
'user_id' => $user->id,
'title' => 'Save money',
'type' => 'quantifiable',
'direction' => 'ascending',
'initial_value' => 0,
'current_value' => 0,
'target_value' => 1000,
'status' => 'in_progress',
]);
$goal->update(['current_value' => 1000]);
$this->assertEquals('completed', $goal->fresh()->status);
$this->assertNotNull($goal->fresh()->completed_at);
}
}MCP tool tests
Tool tests live in tests/Feature/Mcp/Tools/ and drive a tool through the real server dispatch, so registration, scopes, validation, and authorization all apply:
IgniteServer::tool(ListGoalsTool::class, ['goal_id' => $goal->id])
->assertOk();Authenticate with the abilities you mean to test. Tool visibility is decided by the token's abilities, so scope tests go through Sanctum:
Sanctum::actingAs($user, ['read', 'write']);Three things about this are easy to get wrong:
Sanctum::actingAs($user)with no abilities argument grants an empty set, sotokenCan()is false for everything and every tool disappears. Always pass the abilities explicitly.actingAsdoes not apply thewriteimpliesreadnormalization that token creation does. A realwritetoken stores['read', 'write'], so simulate it as['read', 'write']or read tools will wrongly appear unavailable.- To test that an ability is missing, pick one that genuinely excludes it.
['delete']is a good stand-in for "no read access";['write']is not, because a real write token carries read.
A scope test needs both directions. Assert that a granted ability works and that a withheld one is refused. A lone negative assertion passes just as well when the mechanism is broken and refuses everything.
assertSee and assertStructuredContent check different payloads. assertSee inspects the text content; assertStructuredContent inspects the structured JSON. A tool can return correct text while its structured payload is wrong, and clients that prefer structured content would then receive nothing useful. Assert on whichever payload the consumer actually reads, and on both when a tool returns both:
IgniteServer::tool(GetGoalTool::class, ['goal_id' => $goal->id])
->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json
->has('entries', 2)
->missing('user')
->etc());Asserting missing() on fields that must never be exposed is worth doing explicitly, since tool output is sent to a third-party AI provider.
Writing frontend tests
Setup
Each test file mounts a component using @vue/test-utils and asserts on the rendered output:
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import QuantifiableGoalCard from '@/components/goals/QuantifiableGoalCard.vue'
import type { Goal } from '@/types/models'
const baseGoal: Goal = {
id: 1,
title: 'Save money',
status: 'in_progress',
type: 'quantifiable',
progress_percentage: 50,
// ... other required fields
}
describe('QuantifiableGoalCard', () => {
it('shows pause option when goal is in_progress', () => {
const wrapper = mount(QuantifiableGoalCard, {
props: { item: { ...baseGoal, status: 'in_progress' } },
})
expect(wrapper.text()).toContain('Pause')
expect(wrapper.text()).not.toContain('Resume')
})
it('shows resume option when goal is paused', () => {
const wrapper = mount(QuantifiableGoalCard, {
props: { item: { ...baseGoal, status: 'paused' } },
})
expect(wrapper.text()).toContain('Resume')
expect(wrapper.text()).not.toContain('Pause')
})
})Page tests
Page components live in resources/js/pages/ and are mounted the same way as components, with two Inertia dependencies stubbed:
<Head>requires the app head manager, which does not exist in a unit test. Stub only that export and keep the rest of@inertiajs/vue3real.routerperforms real navigation. Mock the methods the page calls so you can assert against them.
Layout wrappers and any child component that is not under test are replaced with pass-through stubs, so the page's own markup is what gets asserted.
import CategoriesIndex from '@/pages/Categories/Index.vue';
import { mount } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest';
// <Head> needs the app head manager, absent in unit tests; stub only that export.
vi.mock('@inertiajs/vue3', async (importOriginal) => {
const actual = await importOriginal<typeof import('@inertiajs/vue3')>();
return {
...actual,
Head: { name: 'Head', render: () => null },
router: { delete: vi.fn() },
};
});
const passthrough = (componentNames: string[]) =>
Object.fromEntries(
componentNames.map((componentName) => [
componentName,
{ template: '<div><slot /></div>' },
]),
);
const stubs = passthrough(['AppLayout', 'PageHeader']);
describe('Categories/Index', () => {
it('renders a row per category', () => {
const wrapper = mount(CategoriesIndex, {
props: { categories: [{ id: 1, name: 'Health' }] },
global: { stubs },
});
expect(wrapper.text()).toContain('Health');
});
});Assert on what the page renders from its props, and on the router calls it triggers. Do not assert on the internals of stubbed children; those belong in that child's own test.
Utility tests
Pure functions need no component mounting:
import { describe, it, expect } from 'vitest'
import { getDateDiffFromNow } from '@/lib/utils'
describe('getDateDiffFromNow', () => {
it('returns 0 for today', () => {
const today = new Date().toISOString().split('T')[0]
expect(getDateDiffFromNow(today)).toBe(0)
})
it('returns negative for past dates', () => {
expect(getDateDiffFromNow('2020-01-01')).toBeLessThan(0)
})
})Best practices
Arrange-Act-Assert
Every test follows this pattern:
public function test_user_can_update_their_goal()
{
// Arrange
$user = User::factory()->create();
$goal = Goal::factory()->create(['user_id' => $user->id, 'title' => 'Old title']);
// Act
$response = $this->actingAs($user)->patch(route('goals.update', $goal), [
'title' => 'New title',
'type' => $goal->type,
]);
// Assert
$response->assertRedirect();
$this->assertEquals('New title', $goal->fresh()->title);
}One assertion focus per test
Split separate behaviors into separate tests. A test named test_user_can_create_a_goal should not also assert validation errors.
Use factories, not raw create()
// Preferred
$goal = Goal::factory()->inProgress()->create(['user_id' => $user->id]);
// Avoid
Goal::create(['user_id' => 1, 'title' => 'Test', 'type' => 'simple', ...]);Exception: observer tests must use Model::create() to trigger the observer.
Always use named routes
// Preferred
$this->get(route('goals.index'));
// Avoid
$this->get('/goals');CI/CD integration
Tests run automatically on every push and pull request to main via the ci GitHub Actions workflow (.github/workflows/ci.yml). The pipeline sets up PHP and Node, installs dependencies, builds frontend assets, then runs the backend suite (./vendor/bin/phpunit) and the frontend suite (LARAVEL_BYPASS_ENV_CHECK=1 npx vitest --run).