Files
band-management/.cursor/rules/200_testing.mdc

871 lines
21 KiB
Plaintext

---
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
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Enums\EventStatus;
use App\Models\Event;
use App\Models\Location;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class EventTest extends TestCase
{
use RefreshDatabase;
private User $admin;
private User $member;
protected function setUp(): void
{
parent::setUp();
$this->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
<?php
declare(strict_types=1);
namespace Tests\Unit\Actions;
use App\Actions\Events\CreateEventAction;
use App\Models\Event;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class CreateEventActionTest extends TestCase
{
use RefreshDatabase;
private CreateEventAction $action;
protected function setUp(): void
{
parent::setUp();
$this->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
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Enums\EventStatus;
use App\Enums\EventVisibility;
use App\Models\Event;
use App\Models\Location;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Event>
*/
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
<?php
// In UserFactory.php
public function admin(): static
{
return $this->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
<?php
declare(strict_types=1);
namespace Tests;
use App\Models\User;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
/**
* Create and authenticate as admin user.
*/
protected function actingAsAdmin(): User
{
$admin = User::factory()->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
<?php
use App\Models\Event;
use App\Models\User;
beforeEach(function () {
$this->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: '<div />' } }],
})
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: '<div />' })
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)