--- description: Testing standards for Laravel API and Vue frontend globs: ["**/tests/**", "**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx", "**/Test.php"] alwaysApply: true --- # Testing Standards ## Philosophy 1. **Test behavior, not implementation** - Focus on what, not how 2. **Feature tests for APIs** - Test full request/response cycles 3. **Unit tests for logic** - Test Actions and complex functions 4. **Integration tests for Vue** - Test component interactions 5. **Fast and isolated** - Each test should be independent ## Laravel Testing ### Directory Structure ``` api/tests/ ├── Feature/ │ └── Api/ │ ├── AuthTest.php │ ├── EventTest.php │ ├── MemberTest.php │ ├── MusicTest.php │ ├── SetlistTest.php │ ├── LocationTest.php │ └── CustomerTest.php ├── Unit/ │ ├── Actions/ │ │ ├── CreateEventActionTest.php │ │ └── ... │ └── Models/ │ ├── EventTest.php │ └── ... └── TestCase.php ``` ### Feature Test Template ```php admin = User::factory()->admin()->create(); $this->member = User::factory()->member()->create(); } // ========================================== // INDEX // ========================================== public function test_guests_cannot_list_events(): void { $response = $this->getJson('/api/v1/events'); $response->assertStatus(401); } public function test_authenticated_users_can_list_events(): void { Event::factory()->count(3)->create(); $response = $this->actingAs($this->member) ->getJson('/api/v1/events'); $response->assertStatus(200) ->assertJsonStructure([ 'success', 'data' => [ '*' => ['id', 'title', 'event_date', 'status'], ], 'meta' => ['pagination'], ]) ->assertJsonCount(3, 'data'); } public function test_events_are_paginated(): void { Event::factory()->count(20)->create(); $response = $this->actingAs($this->member) ->getJson('/api/v1/events?per_page=10'); $response->assertStatus(200) ->assertJsonCount(10, 'data') ->assertJsonPath('meta.pagination.total', 20) ->assertJsonPath('meta.pagination.per_page', 10); } // ========================================== // SHOW // ========================================== public function test_can_view_single_event(): void { $event = Event::factory()->create(['title' => 'Summer Concert']); $response = $this->actingAs($this->member) ->getJson("/api/v1/events/{$event->id}"); $response->assertStatus(200) ->assertJsonPath('success', true) ->assertJsonPath('data.title', 'Summer Concert'); } public function test_returns_404_for_nonexistent_event(): void { $response = $this->actingAs($this->member) ->getJson('/api/v1/events/nonexistent-id'); $response->assertStatus(404); } // ========================================== // STORE // ========================================== public function test_admin_can_create_event(): void { $location = Location::factory()->create(); $eventData = [ 'title' => 'New Year Concert', 'event_date' => now()->addMonth()->toDateString(), 'start_time' => '20:00', 'location_id' => $location->id, 'status' => 'draft', ]; $response = $this->actingAs($this->admin) ->postJson('/api/v1/events', $eventData); $response->assertStatus(201) ->assertJsonPath('success', true) ->assertJsonPath('data.title', 'New Year Concert'); $this->assertDatabaseHas('events', [ 'title' => 'New Year Concert', 'location_id' => $location->id, ]); } public function test_member_cannot_create_event(): void { $eventData = [ 'title' => 'Unauthorized Event', 'event_date' => now()->addMonth()->toDateString(), 'start_time' => '20:00', ]; $response = $this->actingAs($this->member) ->postJson('/api/v1/events', $eventData); $response->assertStatus(403); } public function test_validation_errors_are_returned(): void { $response = $this->actingAs($this->admin) ->postJson('/api/v1/events', []); $response->assertStatus(422) ->assertJsonPath('success', false) ->assertJsonValidationErrors(['title', 'event_date', 'start_time']); } public function test_event_date_must_be_future(): void { $response = $this->actingAs($this->admin) ->postJson('/api/v1/events', [ 'title' => 'Past Event', 'event_date' => now()->subDay()->toDateString(), 'start_time' => '20:00', ]); $response->assertStatus(422) ->assertJsonValidationErrors(['event_date']); } // ========================================== // UPDATE // ========================================== public function test_admin_can_update_event(): void { $event = Event::factory()->create(['title' => 'Old Title']); $response = $this->actingAs($this->admin) ->putJson("/api/v1/events/{$event->id}", [ 'title' => 'Updated Title', ]); $response->assertStatus(200) ->assertJsonPath('data.title', 'Updated Title'); $this->assertDatabaseHas('events', [ 'id' => $event->id, 'title' => 'Updated Title', ]); } public function test_can_update_event_status(): void { $event = Event::factory()->draft()->create(); $response = $this->actingAs($this->admin) ->putJson("/api/v1/events/{$event->id}", [ 'status' => 'confirmed', ]); $response->assertStatus(200) ->assertJsonPath('data.status', 'confirmed'); } // ========================================== // DELETE // ========================================== public function test_admin_can_delete_event(): void { $event = Event::factory()->create(); $response = $this->actingAs($this->admin) ->deleteJson("/api/v1/events/{$event->id}"); $response->assertStatus(200) ->assertJsonPath('success', true); $this->assertDatabaseMissing('events', ['id' => $event->id]); } public function test_member_cannot_delete_event(): void { $event = Event::factory()->create(); $response = $this->actingAs($this->member) ->deleteJson("/api/v1/events/{$event->id}"); $response->assertStatus(403); $this->assertDatabaseHas('events', ['id' => $event->id]); } // ========================================== // RELATIONSHIPS // ========================================== public function test_event_includes_location_when_loaded(): void { $location = Location::factory()->create(['name' => 'City Hall']); $event = Event::factory()->create(['location_id' => $location->id]); $response = $this->actingAs($this->member) ->getJson("/api/v1/events/{$event->id}"); $response->assertStatus(200) ->assertJsonPath('data.location.name', 'City Hall'); } } ``` ### Unit Test Template (Action) ```php action = new CreateEventAction(); } public function test_creates_event_with_valid_data(): void { $user = User::factory()->create(); $this->actingAs($user); $data = [ 'title' => 'Test Event', 'event_date' => now()->addMonth()->toDateString(), 'start_time' => '20:00', ]; $event = $this->action->execute($data); $this->assertInstanceOf(Event::class, $event); $this->assertEquals('Test Event', $event->title); $this->assertEquals($user->id, $event->created_by); } public function test_sets_default_values(): void { $user = User::factory()->create(); $this->actingAs($user); $event = $this->action->execute([ 'title' => 'Test', 'event_date' => now()->addMonth()->toDateString(), 'start_time' => '20:00', ]); $this->assertEquals('EUR', $event->currency); $this->assertEquals('draft', $event->status->value); } } ``` ### Model Factory Template ```php */ final class EventFactory extends Factory { protected $model = Event::class; public function definition(): array { return [ 'title' => fake()->sentence(3), 'description' => fake()->optional()->paragraph(), 'event_date' => fake()->dateTimeBetween('+1 week', '+6 months'), 'start_time' => fake()->time('H:i'), 'end_time' => fake()->optional()->time('H:i'), 'fee' => fake()->optional()->randomFloat(2, 100, 5000), 'currency' => 'EUR', 'status' => fake()->randomElement(EventStatus::cases()), 'visibility' => EventVisibility::Members, 'created_by' => User::factory(), ]; } public function draft(): static { return $this->state(fn () => ['status' => EventStatus::Draft]); } public function confirmed(): static { return $this->state(fn () => ['status' => EventStatus::Confirmed]); } public function withLocation(): static { return $this->state(fn () => ['location_id' => Location::factory()]); } public function upcoming(): static { return $this->state(fn () => [ 'event_date' => fake()->dateTimeBetween('+1 day', '+1 month'), 'status' => EventStatus::Confirmed, ]); } public function past(): static { return $this->state(fn () => [ 'event_date' => fake()->dateTimeBetween('-6 months', '-1 day'), 'status' => EventStatus::Completed, ]); } } ``` ### User Factory States ```php state(fn () => [ 'type' => 'member', 'role' => 'admin', 'status' => 'active', ]); } public function member(): static { return $this->state(fn () => [ 'type' => 'member', 'role' => 'member', 'status' => 'active', ]); } public function bookingAgent(): static { return $this->state(fn () => [ 'type' => 'member', 'role' => 'booking_agent', 'status' => 'active', ]); } public function customer(): static { return $this->state(fn () => [ 'type' => 'customer', 'role' => null, 'status' => 'active', ]); } ``` ### Test Helpers (TestCase.php) ```php admin()->create(); $this->actingAs($admin); return $admin; } /** * Create and authenticate as regular member. */ protected function actingAsMember(): User { $member = User::factory()->member()->create(); $this->actingAs($member); return $member; } /** * Assert API success response structure. */ protected function assertApiSuccess($response, int $status = 200): void { $response->assertStatus($status) ->assertJsonStructure(['success', 'data']) ->assertJsonPath('success', true); } /** * Assert API error response structure. */ protected function assertApiError($response, int $status = 400): void { $response->assertStatus($status) ->assertJsonPath('success', false) ->assertJsonStructure(['success', 'message']); } } ``` ## Running Tests ### Laravel ```bash # Run all tests cd api && php artisan test # Run with coverage php artisan test --coverage # Run specific test file php artisan test tests/Feature/Api/EventTest.php # Run specific test method php artisan test --filter test_admin_can_create_event # Run in parallel php artisan test --parallel ``` ### Pest PHP Syntax ```php admin = User::factory()->admin()->create(); }); it('allows admin to create events', function () { $response = $this->actingAs($this->admin) ->postJson('/api/v1/events', [ 'title' => 'Concert', 'event_date' => now()->addMonth()->toDateString(), 'start_time' => '20:00', ]); $response->assertStatus(201); expect(Event::count())->toBe(1); }); it('validates required fields', function () { $response = $this->actingAs($this->admin) ->postJson('/api/v1/events', []); $response->assertStatus(422) ->assertJsonValidationErrors(['title', 'event_date']); }); it('returns paginated results', function () { Event::factory()->count(25)->create(); $response = $this->actingAs($this->admin) ->getJson('/api/v1/events?per_page=10'); expect($response->json('data'))->toHaveCount(10); expect($response->json('meta.pagination.total'))->toBe(25); }); ``` ## Vue Testing (Vitest + Vue Test Utils) ### File Organization ``` src/ ├── components/ │ └── EventCard/ │ ├── EventCard.vue │ └── EventCard.test.ts ├── composables/ │ └── useEvents.test.ts └── test/ ├── setup.ts ├── mocks/ │ ├── handlers.ts # MSW handlers │ └── server.ts └── utils.ts # Custom render with providers ``` ### Vitest Configuration ```typescript // vitest.config.ts import { defineConfig } from 'vitest/config' import vue from '@vitejs/plugin-vue' import { fileURLToPath } from 'node:url' export default defineConfig({ plugins: [vue()], test: { environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'], }, resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)), }, }, }) ``` ### Test Setup ```typescript // src/test/setup.ts import { vi, beforeAll, afterAll, afterEach } from 'vitest' import { config } from '@vue/test-utils' import { server } from './mocks/server' // Start MSW server beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) afterEach(() => server.resetHandlers()) afterAll(() => server.close()) // Global stubs for Vuexy components config.global.stubs = { VBtn: true, VCard: true, VCardText: true, VCardTitle: true, VTable: true, VDialog: true, VProgressCircular: true, RouterLink: true, } ``` ### MSW Setup for API Mocking ```typescript // src/test/mocks/handlers.ts import { http, HttpResponse } from 'msw' export const handlers = [ http.get('/api/v1/events', () => { return HttpResponse.json({ success: true, data: [ { id: '1', title: 'Event 1', status: 'confirmed' }, { id: '2', title: 'Event 2', status: 'draft' }, { id: '3', title: 'Event 3', status: 'pending' }, ], meta: { pagination: { current_page: 1, total: 3, per_page: 15 } } }) }), http.post('/api/v1/events', async ({ request }) => { const body = await request.json() return HttpResponse.json({ success: true, data: { id: '4', ...body }, message: 'Event created successfully' }, { status: 201 }) }), http.get('/api/v1/auth/user', () => { return HttpResponse.json({ success: true, data: { id: '1', name: 'Test User', email: 'test@example.com', role: 'admin' } }) }), ] // src/test/mocks/server.ts import { setupServer } from 'msw/node' import { handlers } from './handlers' export const server = setupServer(...handlers) ``` ### Test Utilities ```typescript // src/test/utils.ts import { mount, VueWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query' import { createRouter, createWebHistory } from 'vue-router' import type { Component } from 'vue' export function createTestingPinia() { const pinia = createPinia() setActivePinia(pinia) return pinia } export function createTestQueryClient() { return new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false }, }, }) } export function mountWithProviders(component: Component, options = {}) { const pinia = createTestingPinia() const queryClient = createTestQueryClient() const router = createRouter({ history: createWebHistory(), routes: [{ path: '/', component: { template: '
' } }], }) return mount(component, { global: { plugins: [pinia, router, [VueQueryPlugin, { queryClient }]], stubs: { VBtn: true, VCard: true, VTable: true, }, }, ...options, }) } ``` ### Component Test Pattern ```typescript // src/components/EventCard.test.ts import { describe, it, expect, vi } from 'vitest' import { mount } from '@vue/test-utils' import EventCard from './EventCard.vue' describe('EventCard', () => { const mockEvent = { id: '1', title: 'Summer Concert', event_date: '2025-07-15', status: 'confirmed', status_label: 'Confirmed', } it('displays event title', () => { const wrapper = mount(EventCard, { props: { event: mockEvent }, }) expect(wrapper.text()).toContain('Summer Concert') }) it('emits edit event when edit button clicked', async () => { const wrapper = mount(EventCard, { props: { event: mockEvent }, }) await wrapper.find('[data-test="edit-btn"]').trigger('click') expect(wrapper.emitted('edit')).toBeTruthy() expect(wrapper.emitted('edit')?.[0]).toEqual([mockEvent]) }) it('shows correct status color', () => { const wrapper = mount(EventCard, { props: { event: mockEvent }, }) const chip = wrapper.find('[data-test="status-chip"]') expect(chip.classes()).toContain('bg-success') }) }) ``` ### Composable Test Pattern ```typescript // src/composables/__tests__/useEvents.test.ts import { describe, it, expect, vi, beforeEach } from 'vitest' import { flushPromises } from '@vue/test-utils' import { useEvents } from '../useEvents' import { createTestQueryClient, createTestingPinia } from '@/test/utils' import { VueQueryPlugin } from '@tanstack/vue-query' import { createApp } from 'vue' describe('useEvents', () => { beforeEach(() => { createTestingPinia() }) it('fetches events successfully', async () => { const app = createApp({ template: '' }) const queryClient = createTestQueryClient() app.use(VueQueryPlugin, { queryClient }) // Mount and test... await flushPromises() // Assertions }) }) ``` ### Pest Expectations (Laravel) ```php // Use fluent expectations expect($user)->toBeInstanceOf(User::class); expect($collection)->toHaveCount(5); expect($response->json('data'))->toMatchArray([...]); // Chain expectations expect($user) ->name->toBe('John') ->email->toContain('@') ->created_at->toBeInstanceOf(Carbon::class); ``` ## Test Coverage Goals | Area | Target | Priority | |------|--------|----------| | API Endpoints | 90%+ | High | | Actions | 100% | High | | Models | 80%+ | Medium | | Vue Composables | 80%+ | Medium | | Vue Components | 60%+ | Low | **Minimum Coverage**: 80% line coverage ## Coverage Requirements - **Feature tests**: All API endpoints must have tests - **Unit tests**: All Actions and Services with business logic - **Component tests**: All interactive components - **Composable tests**: All custom composables that fetch data ## Best Practices ### Do - Test happy path AND edge cases - Use descriptive test names - One assertion focus per test - Use factories for test data - Clean up after tests (RefreshDatabase) - Test authorization separately ### Don't - Test framework code (Laravel, Vue) - Test private methods directly - Share state between tests - Use real external APIs - Over-mock (test real behavior)