first implementation

This commit is contained in:
Wuesteon 2025-11-27 17:26:18 +01:00
parent 98efa6f6e8
commit 74dc6892ab
61 changed files with 30899 additions and 4934 deletions

View file

@ -0,0 +1,347 @@
# Test Examples
This directory contains comprehensive example test files demonstrating best practices for testing different app types in the Manacore monorepo.
## Directory Structure
```
test-examples/
├── backend/ # NestJS backend examples
│ ├── example.controller.spec.ts
│ └── example.service.spec.ts
├── mobile/ # React Native mobile examples
│ ├── ExampleComponent.test.tsx
│ └── authService.test.ts
├── web/ # SvelteKit web examples
│ ├── Button.test.ts
│ └── page.server.test.ts
├── shared/ # Shared package examples
│ └── format.test.ts
└── README.md
```
## Example Files Overview
### Backend Tests (NestJS)
#### `example.controller.spec.ts`
Demonstrates:
- Controller unit testing with mocked services
- Request/response handling
- Authentication/authorization testing
- Input validation
- Error handling
- CRUD operations
**Key Patterns**:
- Use `@nestjs/testing` TestingModule
- Mock all service dependencies
- Test both success and error paths
- Verify service method calls
#### `example.service.spec.ts`
Demonstrates:
- Service business logic testing
- Database operation mocking
- External API mocking
- Result pattern for error handling
- Data validation and sanitization
- Authorization checks
**Key Patterns**:
- Mock database and external services
- Test error handling thoroughly
- Verify data transformations
- Test edge cases and boundary conditions
### Mobile Tests (React Native)
#### `ExampleComponent.test.tsx`
Demonstrates:
- Component rendering
- User interactions (press, long press)
- State management
- Props validation
- Accessibility testing
- Performance testing
- Snapshot testing
**Key Patterns**:
- Use `@testing-library/react-native`
- Test user behavior, not implementation
- Verify accessibility props
- Test loading and error states
#### `authService.test.ts`
Demonstrates:
- Async service testing
- API call mocking with fetch
- Storage operations (SecureStore)
- Error handling (network, storage)
- Token management
- Integration with other services
**Key Patterns**:
- Mock global fetch
- Mock Expo modules (SecureStore)
- Test timeout scenarios
- Verify storage operations
### Web Tests (SvelteKit)
#### `Button.test.ts`
Demonstrates:
- Svelte 5 component testing
- Reactive state with runes ($state, $derived)
- User events
- Accessibility
- Variants and sizes
- Custom events
- Debouncing
**Key Patterns**:
- Use `@testing-library/svelte`
- Test Svelte 5 reactivity
- Verify accessibility attributes
- Test custom event dispatch
#### `page.server.test.ts`
Demonstrates:
- Server load function testing
- Form action testing
- Database mocking (PocketBase)
- Authentication checks
- Input validation and sanitization
- Authorization enforcement
- File upload handling
**Key Patterns**:
- Mock `locals` object
- Mock database client
- Test redirect behavior
- Verify authorization logic
- Sanitize user input
### Shared Package Tests
#### `format.test.ts`
Demonstrates:
- Pure function testing
- Parameterized tests (it.each)
- Edge case testing
- Boundary testing
- Property-based testing
- Security testing (XSS, SQL injection)
- Unicode and emoji handling
**Key Patterns**:
- Test with multiple inputs using `it.each`
- Cover edge cases thoroughly
- Test security vulnerabilities
- Verify type safety
## How to Use These Examples
### 1. Copy and Adapt
Copy the relevant example to your project and adapt it:
```bash
# Copy backend controller test
cp docs/test-examples/backend/example.controller.spec.ts \
apps/YOUR_PROJECT/apps/backend/src/your-module/__tests__/your.controller.spec.ts
# Update imports and names
```
### 2. Follow the Patterns
Each example demonstrates specific testing patterns:
- **AAA Pattern**: Arrange, Act, Assert
- **Descriptive Names**: Clear test descriptions
- **Mock Management**: Proper setup and cleanup
- **Error Testing**: Both happy and error paths
- **Edge Cases**: Boundary conditions and special cases
### 3. Customize for Your Needs
Adapt the examples to your specific requirements:
```typescript
// Example: Add project-specific mocks
jest.mock('@your-project/custom-service', () => ({
CustomService: {
doSomething: jest.fn(),
},
}));
```
### 4. Reference Best Practices
Each file includes comments explaining:
- Why specific patterns are used
- What to test and what not to test
- Common pitfalls to avoid
- Performance considerations
## Testing Principles Demonstrated
### 1. Test Behavior, Not Implementation
```typescript
// ✅ Good - Testing behavior
it('should display error message when login fails', async () => {
await userEvent.click(loginButton);
expect(screen.getByText('Invalid credentials')).toBeVisible();
});
// ❌ Bad - Testing implementation
it('should set isLoading to false after login', async () => {
await userEvent.click(loginButton);
expect(component.state.isLoading).toBe(false);
});
```
### 2. Isolation
Each test should be independent:
```typescript
beforeEach(() => {
jest.clearAllMocks(); // Clear mock call history
// Reset any state
});
```
### 3. Comprehensive Coverage
Cover all code paths:
```typescript
describe('createItem', () => {
it('should create successfully'); // Happy path
it('should handle validation errors'); // Error path
it('should handle database errors'); // Error path
it('should handle edge cases'); // Edge cases
});
```
### 4. Readable Tests
Make tests self-documenting:
```typescript
describe('User Authentication', () => {
describe('signIn', () => {
it('should sign in successfully with valid credentials', () => {
// Test implementation
});
it('should reject invalid email format', () => {
// Test implementation
});
});
});
```
## Common Test Scenarios
### Authentication Testing
```typescript
it('should require authentication', async () => {
mockEvent.locals = { user: null };
await expect(load(mockEvent)).rejects.toThrow('Redirect');
});
it('should allow access with valid token', async () => {
mockEvent.locals = { user: { id: '123' } };
const result = await load(mockEvent);
expect(result).toBeDefined();
});
```
### Form Validation
```typescript
it('should validate required fields', async () => {
const formData = new FormData();
formData.append('title', ''); // Invalid
const result = await actions.create(mockEvent);
expect(result.success).toBe(false);
expect(result.error).toContain('required');
});
```
### Error Handling
```typescript
it('should handle network errors gracefully', async () => {
(global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
const result = await authService.signIn('test@example.com', 'password');
expect(result.success).toBe(false);
expect(result.error).toContain('Network');
});
```
### Async Operations
```typescript
it('should wait for async operation to complete', async () => {
const promise = service.fetchData();
await waitFor(() => {
expect(service.isLoading).toBe(false);
});
const result = await promise;
expect(result).toBeDefined();
});
```
## Testing Checklist
When writing tests, ensure you cover:
- [ ] Happy path (successful execution)
- [ ] Error paths (validation errors, API errors)
- [ ] Edge cases (empty inputs, null values, boundaries)
- [ ] Authentication/authorization
- [ ] Input sanitization
- [ ] Accessibility (for components)
- [ ] Loading states
- [ ] Error states
- [ ] Network failures (for API calls)
- [ ] Storage failures (for persistence)
## Additional Resources
- [Full Testing Strategy](../TESTING.md)
- [Implementation Guide](../TESTING_IMPLEMENTATION_GUIDE.md)
- [Shared Test Configurations](../../packages/test-config/)
- [Jest Documentation](https://jestjs.io/)
- [Vitest Documentation](https://vitest.dev/)
- [Testing Library](https://testing-library.com/)
- [Playwright](https://playwright.dev/)
## Contributing
When adding new examples:
1. Follow existing naming conventions
2. Add comprehensive comments
3. Demonstrate best practices
4. Cover edge cases
5. Update this README
## Questions?
- Check the [Testing Strategy](../TESTING.md) for overall approach
- Review [Implementation Guide](../TESTING_IMPLEMENTATION_GUIDE.md) for step-by-step instructions
- Look at existing tests in the project for patterns
- Ask in team chat for project-specific guidance

View file

@ -0,0 +1,251 @@
/**
* Example NestJS Controller Test
*
* This demonstrates best practices for testing NestJS controllers:
* - Mock all dependencies
* - Test successful responses
* - Test error handling
* - Test authentication/authorization
* - Test validation
*/
import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException, UnauthorizedException, NotFoundException } from '@nestjs/common';
import { ExampleController } from '../example.controller';
import { ExampleService } from '../example.service';
import { CreateExampleDto } from '../dto/create-example.dto';
import { UpdateExampleDto } from '../dto/update-example.dto';
describe('ExampleController', () => {
let controller: ExampleController;
let service: ExampleService;
// Mock data
const mockUser = { sub: 'user-123', email: 'test@example.com' };
const mockExample = {
id: 'example-123',
title: 'Test Example',
description: 'Test description',
userId: 'user-123',
createdAt: new Date(),
updatedAt: new Date(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ExampleController],
providers: [
{
provide: ExampleService,
useValue: {
create: jest.fn(),
findAll: jest.fn(),
findOne: jest.fn(),
update: jest.fn(),
remove: jest.fn(),
},
},
],
}).compile();
controller = module.get<ExampleController>(ExampleController);
service = module.get<ExampleService>(ExampleService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('create', () => {
const createDto: CreateExampleDto = {
title: 'New Example',
description: 'New description',
};
it('should create an example successfully', async () => {
const expectedResult = {
data: { ...mockExample, ...createDto },
error: null,
};
jest.spyOn(service, 'create').mockResolvedValue(expectedResult);
const result = await controller.create(createDto, { user: mockUser });
expect(result).toEqual(expectedResult.data);
expect(service.create).toHaveBeenCalledWith(createDto, mockUser.sub);
expect(service.create).toHaveBeenCalledTimes(1);
});
it('should throw BadRequestException for invalid data', async () => {
const invalidDto = { title: '', description: 'Test' } as CreateExampleDto;
jest.spyOn(service, 'create').mockResolvedValue({
data: null,
error: new Error('Validation failed'),
});
await expect(controller.create(invalidDto, { user: mockUser })).rejects.toThrow(BadRequestException);
});
it('should throw UnauthorizedException when user is not authenticated', async () => {
await expect(controller.create(createDto, { user: null })).rejects.toThrow(UnauthorizedException);
});
it('should handle service errors gracefully', async () => {
jest.spyOn(service, 'create').mockResolvedValue({
data: null,
error: new Error('Database error'),
});
await expect(controller.create(createDto, { user: mockUser })).rejects.toThrow();
});
});
describe('findAll', () => {
it('should return all examples for the user', async () => {
const expectedResult = {
data: [mockExample],
error: null,
};
jest.spyOn(service, 'findAll').mockResolvedValue(expectedResult);
const result = await controller.findAll({ user: mockUser });
expect(result).toEqual(expectedResult.data);
expect(service.findAll).toHaveBeenCalledWith(mockUser.sub);
expect(service.findAll).toHaveBeenCalledTimes(1);
});
it('should return empty array when user has no examples', async () => {
jest.spyOn(service, 'findAll').mockResolvedValue({
data: [],
error: null,
});
const result = await controller.findAll({ user: mockUser });
expect(result).toEqual([]);
});
it('should require authentication', async () => {
await expect(controller.findAll({ user: null })).rejects.toThrow(UnauthorizedException);
});
});
describe('findOne', () => {
const exampleId = 'example-123';
it('should return a single example', async () => {
jest.spyOn(service, 'findOne').mockResolvedValue({
data: mockExample,
error: null,
});
const result = await controller.findOne(exampleId, { user: mockUser });
expect(result).toEqual(mockExample);
expect(service.findOne).toHaveBeenCalledWith(exampleId, mockUser.sub);
});
it('should throw NotFoundException when example does not exist', async () => {
jest.spyOn(service, 'findOne').mockResolvedValue({
data: null,
error: new Error('Not found'),
});
await expect(controller.findOne('invalid-id', { user: mockUser })).rejects.toThrow(NotFoundException);
});
it('should not allow access to other users examples', async () => {
const otherUserExample = { ...mockExample, userId: 'other-user' };
jest.spyOn(service, 'findOne').mockResolvedValue({
data: otherUserExample,
error: null,
});
await expect(controller.findOne(exampleId, { user: mockUser })).rejects.toThrow(UnauthorizedException);
});
});
describe('update', () => {
const exampleId = 'example-123';
const updateDto: UpdateExampleDto = {
title: 'Updated Title',
};
it('should update an example successfully', async () => {
const updatedExample = { ...mockExample, ...updateDto };
jest.spyOn(service, 'update').mockResolvedValue({
data: updatedExample,
error: null,
});
const result = await controller.update(exampleId, updateDto, { user: mockUser });
expect(result).toEqual(updatedExample);
expect(service.update).toHaveBeenCalledWith(exampleId, updateDto, mockUser.sub);
});
it('should throw NotFoundException when example does not exist', async () => {
jest.spyOn(service, 'update').mockResolvedValue({
data: null,
error: new Error('Not found'),
});
await expect(controller.update('invalid-id', updateDto, { user: mockUser })).rejects.toThrow(
NotFoundException
);
});
it('should validate update data', async () => {
const invalidDto = { title: '' } as UpdateExampleDto;
jest.spyOn(service, 'update').mockResolvedValue({
data: null,
error: new Error('Validation failed'),
});
await expect(controller.update(exampleId, invalidDto, { user: mockUser })).rejects.toThrow(
BadRequestException
);
});
});
describe('remove', () => {
const exampleId = 'example-123';
it('should delete an example successfully', async () => {
jest.spyOn(service, 'remove').mockResolvedValue({
data: { success: true },
error: null,
});
const result = await controller.remove(exampleId, { user: mockUser });
expect(result).toEqual({ success: true });
expect(service.remove).toHaveBeenCalledWith(exampleId, mockUser.sub);
});
it('should throw NotFoundException when example does not exist', async () => {
jest.spyOn(service, 'remove').mockResolvedValue({
data: null,
error: new Error('Not found'),
});
await expect(controller.remove('invalid-id', { user: mockUser })).rejects.toThrow(NotFoundException);
});
it('should not allow deletion of other users examples', async () => {
jest.spyOn(service, 'remove').mockResolvedValue({
data: null,
error: new Error('Unauthorized'),
});
await expect(controller.remove(exampleId, { user: mockUser })).rejects.toThrow(UnauthorizedException);
});
});
});

View file

@ -0,0 +1,379 @@
/**
* Example NestJS Service Test
*
* This demonstrates best practices for testing NestJS services:
* - Mock database/external dependencies
* - Test business logic thoroughly
* - Test error handling
* - Test edge cases
* - Use Result pattern for error handling
*/
import { Test, TestingModule } from '@nestjs/testing';
import { ExampleService } from '../example.service';
import { SupabaseDataService } from '../../core/services/supabase-data.service';
import { ExternalApiService } from '../../core/services/external-api.service';
import { CreateExampleDto } from '../dto/create-example.dto';
describe('ExampleService', () => {
let service: ExampleService;
let supabaseService: jest.Mocked<SupabaseDataService>;
let externalApiService: jest.Mocked<ExternalApiService>;
const mockUser = { sub: 'user-123', email: 'test@example.com' };
const mockExample = {
id: 'example-123',
title: 'Test Example',
description: 'Test description',
userId: 'user-123',
createdAt: new Date(),
updatedAt: new Date(),
};
beforeEach(async () => {
// Create mocked services
const mockSupabaseService = {
insertExample: jest.fn(),
getExample: jest.fn(),
getExamplesByUser: jest.fn(),
updateExample: jest.fn(),
deleteExample: jest.fn(),
};
const mockExternalApiService = {
enrichExample: jest.fn(),
validateExample: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
ExampleService,
{
provide: SupabaseDataService,
useValue: mockSupabaseService,
},
{
provide: ExternalApiService,
useValue: mockExternalApiService,
},
],
}).compile();
service = module.get<ExampleService>(ExampleService);
supabaseService = module.get(SupabaseDataService) as jest.Mocked<SupabaseDataService>;
externalApiService = module.get(ExternalApiService) as jest.Mocked<ExternalApiService>;
});
afterEach(() => {
jest.clearAllMocks();
});
describe('create', () => {
const createDto: CreateExampleDto = {
title: 'New Example',
description: 'New description',
};
it('should create an example successfully', async () => {
// Arrange
const enrichedData = {
...createDto,
metadata: { enhanced: true },
};
externalApiService.enrichExample.mockResolvedValue({
data: enrichedData,
error: null,
});
supabaseService.insertExample.mockResolvedValue({
data: { ...mockExample, ...enrichedData },
error: null,
});
// Act
const result = await service.create(createDto, mockUser.sub);
// Assert
expect(result.error).toBeNull();
expect(result.data).toBeDefined();
expect(result.data.title).toBe(createDto.title);
expect(externalApiService.enrichExample).toHaveBeenCalledWith(createDto);
expect(supabaseService.insertExample).toHaveBeenCalledWith(
expect.objectContaining({
...enrichedData,
userId: mockUser.sub,
})
);
});
it('should handle enrichment failure gracefully', async () => {
// Arrange
externalApiService.enrichExample.mockResolvedValue({
data: null,
error: new Error('API unavailable'),
});
supabaseService.insertExample.mockResolvedValue({
data: { ...mockExample, ...createDto },
error: null,
});
// Act
const result = await service.create(createDto, mockUser.sub);
// Assert - Should still create without enrichment
expect(result.error).toBeNull();
expect(result.data).toBeDefined();
expect(supabaseService.insertExample).toHaveBeenCalledWith(
expect.objectContaining({
...createDto,
userId: mockUser.sub,
})
);
});
it('should return error when database insert fails', async () => {
// Arrange
externalApiService.enrichExample.mockResolvedValue({
data: createDto,
error: null,
});
const dbError = new Error('Database connection failed');
supabaseService.insertExample.mockResolvedValue({
data: null,
error: dbError,
});
// Act
const result = await service.create(createDto, mockUser.sub);
// Assert
expect(result.error).toBeDefined();
expect(result.data).toBeNull();
expect(result.error.message).toContain('Database connection failed');
});
it('should validate title is not empty', async () => {
// Arrange
const invalidDto = { ...createDto, title: '' };
// Act
const result = await service.create(invalidDto, mockUser.sub);
// Assert
expect(result.error).toBeDefined();
expect(result.error.message).toContain('Title cannot be empty');
expect(externalApiService.enrichExample).not.toHaveBeenCalled();
expect(supabaseService.insertExample).not.toHaveBeenCalled();
});
it('should sanitize user input', async () => {
// Arrange
const maliciousDto = {
title: '<script>alert("xss")</script>',
description: 'Normal description',
};
externalApiService.enrichExample.mockResolvedValue({
data: maliciousDto,
error: null,
});
supabaseService.insertExample.mockResolvedValue({
data: { ...mockExample, title: 'alert("xss")' }, // Sanitized
error: null,
});
// Act
const result = await service.create(maliciousDto, mockUser.sub);
// Assert
expect(result.data.title).not.toContain('<script>');
});
});
describe('findAll', () => {
it('should return all examples for a user', async () => {
// Arrange
const examples = [mockExample, { ...mockExample, id: 'example-456' }];
supabaseService.getExamplesByUser.mockResolvedValue({
data: examples,
error: null,
});
// Act
const result = await service.findAll(mockUser.sub);
// Assert
expect(result.error).toBeNull();
expect(result.data).toHaveLength(2);
expect(supabaseService.getExamplesByUser).toHaveBeenCalledWith(mockUser.sub);
});
it('should return empty array when user has no examples', async () => {
// Arrange
supabaseService.getExamplesByUser.mockResolvedValue({
data: [],
error: null,
});
// Act
const result = await service.findAll(mockUser.sub);
// Assert
expect(result.error).toBeNull();
expect(result.data).toEqual([]);
});
it('should handle database errors', async () => {
// Arrange
const dbError = new Error('Query timeout');
supabaseService.getExamplesByUser.mockResolvedValue({
data: null,
error: dbError,
});
// Act
const result = await service.findAll(mockUser.sub);
// Assert
expect(result.error).toBeDefined();
expect(result.data).toBeNull();
});
});
describe('findOne', () => {
it('should return a single example', async () => {
// Arrange
supabaseService.getExample.mockResolvedValue({
data: mockExample,
error: null,
});
// Act
const result = await service.findOne('example-123', mockUser.sub);
// Assert
expect(result.error).toBeNull();
expect(result.data).toEqual(mockExample);
expect(supabaseService.getExample).toHaveBeenCalledWith('example-123');
});
it('should return error when example not found', async () => {
// Arrange
supabaseService.getExample.mockResolvedValue({
data: null,
error: new Error('Not found'),
});
// Act
const result = await service.findOne('invalid-id', mockUser.sub);
// Assert
expect(result.error).toBeDefined();
expect(result.data).toBeNull();
});
it('should verify user owns the example', async () => {
// Arrange
const otherUserExample = { ...mockExample, userId: 'other-user' };
supabaseService.getExample.mockResolvedValue({
data: otherUserExample,
error: null,
});
// Act
const result = await service.findOne('example-123', mockUser.sub);
// Assert
expect(result.error).toBeDefined();
expect(result.error.message).toContain('Unauthorized');
expect(result.data).toBeNull();
});
});
describe('update', () => {
it('should update an example successfully', async () => {
// Arrange
const updateDto = { title: 'Updated Title' };
const updatedExample = { ...mockExample, ...updateDto };
supabaseService.getExample.mockResolvedValue({
data: mockExample,
error: null,
});
supabaseService.updateExample.mockResolvedValue({
data: updatedExample,
error: null,
});
// Act
const result = await service.update('example-123', updateDto, mockUser.sub);
// Assert
expect(result.error).toBeNull();
expect(result.data.title).toBe('Updated Title');
expect(supabaseService.updateExample).toHaveBeenCalledWith('example-123', updateDto);
});
it('should not allow updating other users examples', async () => {
// Arrange
const otherUserExample = { ...mockExample, userId: 'other-user' };
supabaseService.getExample.mockResolvedValue({
data: otherUserExample,
error: null,
});
// Act
const result = await service.update('example-123', { title: 'New' }, mockUser.sub);
// Assert
expect(result.error).toBeDefined();
expect(result.error.message).toContain('Unauthorized');
expect(supabaseService.updateExample).not.toHaveBeenCalled();
});
});
describe('remove', () => {
it('should delete an example successfully', async () => {
// Arrange
supabaseService.getExample.mockResolvedValue({
data: mockExample,
error: null,
});
supabaseService.deleteExample.mockResolvedValue({
data: { success: true },
error: null,
});
// Act
const result = await service.remove('example-123', mockUser.sub);
// Assert
expect(result.error).toBeNull();
expect(result.data).toEqual({ success: true });
expect(supabaseService.deleteExample).toHaveBeenCalledWith('example-123');
});
it('should not allow deleting other users examples', async () => {
// Arrange
const otherUserExample = { ...mockExample, userId: 'other-user' };
supabaseService.getExample.mockResolvedValue({
data: otherUserExample,
error: null,
});
// Act
const result = await service.remove('example-123', mockUser.sub);
// Assert
expect(result.error).toBeDefined();
expect(supabaseService.deleteExample).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,308 @@
/**
* Example React Native Component Test
*
* This demonstrates best practices for testing React Native components:
* - Render testing
* - User interaction testing
* - State changes
* - Props validation
* - Accessibility testing
*/
import React from 'react';
import { render, fireEvent, waitFor, screen } from '@testing-library/react-native';
import { ExampleComponent } from '../ExampleComponent';
describe('ExampleComponent', () => {
// Mock data
const mockOnPress = jest.fn();
const mockOnLongPress = jest.fn();
const defaultProps = {
title: 'Test Title',
description: 'Test Description',
onPress: mockOnPress,
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render with required props', () => {
const { getByText } = render(<ExampleComponent {...defaultProps} />);
expect(getByText('Test Title')).toBeTruthy();
expect(getByText('Test Description')).toBeTruthy();
});
it('should render with testID for automation', () => {
const { getByTestId } = render(<ExampleComponent {...defaultProps} testID="example-component" />);
expect(getByTestId('example-component')).toBeTruthy();
});
it('should render loading state', () => {
const { getByTestId, queryByText } = render(<ExampleComponent {...defaultProps} loading />);
expect(getByTestId('loading-indicator')).toBeTruthy();
expect(queryByText('Test Title')).toBeNull(); // Content hidden when loading
});
it('should render error state', () => {
const errorMessage = 'Something went wrong';
const { getByText } = render(<ExampleComponent {...defaultProps} error={errorMessage} />);
expect(getByText(errorMessage)).toBeTruthy();
});
it('should render optional icon when provided', () => {
const { getByTestId } = render(<ExampleComponent {...defaultProps} icon="star" />);
expect(getByTestId('icon-star')).toBeTruthy();
});
it('should not render description when not provided', () => {
const { queryByText } = render(<ExampleComponent title="Title Only" onPress={mockOnPress} />);
expect(queryByText('Test Description')).toBeNull();
});
});
describe('User Interactions', () => {
it('should call onPress when pressed', () => {
const { getByText } = render(<ExampleComponent {...defaultProps} />);
fireEvent.press(getByText('Test Title'));
expect(mockOnPress).toHaveBeenCalledTimes(1);
});
it('should call onLongPress when long pressed', () => {
const { getByText } = render(<ExampleComponent {...defaultProps} onLongPress={mockOnLongPress} />);
fireEvent(getByText('Test Title'), 'onLongPress');
expect(mockOnLongPress).toHaveBeenCalledTimes(1);
});
it('should not call onPress when disabled', () => {
const { getByText } = render(<ExampleComponent {...defaultProps} disabled />);
fireEvent.press(getByText('Test Title'));
expect(mockOnPress).not.toHaveBeenCalled();
});
it('should not call onPress when loading', () => {
const { getByTestId } = render(
<ExampleComponent {...defaultProps} loading testID="example-component" />
);
fireEvent.press(getByTestId('example-component'));
expect(mockOnPress).not.toHaveBeenCalled();
});
it('should show feedback on press (opacity change)', async () => {
const { getByText } = render(<ExampleComponent {...defaultProps} />);
const touchable = getByText('Test Title').parent;
fireEvent(touchable, 'onPressIn');
await waitFor(() => {
expect(touchable.props.style).toMatchObject({
opacity: 0.6, // Active opacity
});
});
fireEvent(touchable, 'onPressOut');
await waitFor(() => {
expect(touchable.props.style).toMatchObject({
opacity: 1,
});
});
});
});
describe('State Management', () => {
it('should toggle favorite state on icon press', async () => {
const { getByTestId, rerender } = render(<ExampleComponent {...defaultProps} favoritable />);
const favoriteIcon = getByTestId('favorite-icon');
expect(favoriteIcon.props.name).toBe('heart-outline'); // Initial state
fireEvent.press(favoriteIcon);
await waitFor(() => {
expect(favoriteIcon.props.name).toBe('heart'); // Toggled state
});
});
it('should maintain expanded state across re-renders', async () => {
const { getByTestId, rerender } = render(<ExampleComponent {...defaultProps} expandable />);
const expandButton = getByTestId('expand-button');
fireEvent.press(expandButton);
await waitFor(() => {
expect(getByTestId('expanded-content')).toBeTruthy();
});
// Re-render with updated props
rerender(<ExampleComponent {...defaultProps} description="Updated Description" expandable />);
// Expanded state should persist
expect(getByTestId('expanded-content')).toBeTruthy();
});
});
describe('Props Validation', () => {
it('should handle empty title gracefully', () => {
const { queryByText } = render(<ExampleComponent title="" onPress={mockOnPress} />);
expect(queryByText('')).toBeNull();
});
it('should truncate long titles', () => {
const longTitle = 'This is a very long title that should be truncated at some point';
const { getByText } = render(<ExampleComponent title={longTitle} onPress={mockOnPress} />);
const titleElement = getByText(/This is a very long/);
expect(titleElement.props.numberOfLines).toBe(1);
expect(titleElement.props.ellipsizeMode).toBe('tail');
});
it('should apply custom styles', () => {
const customStyle = { backgroundColor: 'red', padding: 20 };
const { getByTestId } = render(
<ExampleComponent {...defaultProps} style={customStyle} testID="example-component" />
);
const component = getByTestId('example-component');
expect(component.props.style).toMatchObject(customStyle);
});
});
describe('Accessibility', () => {
it('should have accessible label', () => {
const { getByLabelText } = render(<ExampleComponent {...defaultProps} />);
expect(getByLabelText('Test Title')).toBeTruthy();
});
it('should have accessible role', () => {
const { getByRole } = render(<ExampleComponent {...defaultProps} />);
expect(getByRole('button')).toBeTruthy();
});
it('should have accessible hint', () => {
const { getByA11yHint } = render(
<ExampleComponent {...defaultProps} accessibilityHint="Double tap to open details" />
);
expect(getByA11yHint('Double tap to open details')).toBeTruthy();
});
it('should be disabled for screen readers when disabled', () => {
const { getByTestId } = render(
<ExampleComponent {...defaultProps} disabled testID="example-component" />
);
const component = getByTestId('example-component');
expect(component.props.accessibilityState).toMatchObject({
disabled: true,
});
});
});
describe('Edge Cases', () => {
it('should handle rapid taps (debouncing)', async () => {
jest.useFakeTimers();
const { getByText } = render(<ExampleComponent {...defaultProps} />);
const button = getByText('Test Title');
// Rapid taps
fireEvent.press(button);
fireEvent.press(button);
fireEvent.press(button);
jest.runAllTimers();
// Should only call once due to debouncing
expect(mockOnPress).toHaveBeenCalledTimes(1);
jest.useRealTimers();
});
it('should handle null children gracefully', () => {
const { container } = render(<ExampleComponent {...defaultProps}>{null}</ExampleComponent>);
expect(container).toBeTruthy();
});
it('should handle undefined props gracefully', () => {
const { getByText } = render(<ExampleComponent title="Test" onPress={mockOnPress} description={undefined} />);
expect(getByText('Test')).toBeTruthy();
});
});
describe('Performance', () => {
it('should not re-render unnecessarily', () => {
const renderSpy = jest.fn();
const ComponentWithSpy = (props) => {
renderSpy();
return <ExampleComponent {...props} />;
};
const { rerender } = render(<ComponentWithSpy {...defaultProps} />);
expect(renderSpy).toHaveBeenCalledTimes(1);
// Re-render with same props
rerender(<ComponentWithSpy {...defaultProps} />);
// Should use memo and not re-render
expect(renderSpy).toHaveBeenCalledTimes(1);
});
it('should only re-render when relevant props change', () => {
const renderSpy = jest.fn();
const ComponentWithSpy = (props) => {
renderSpy();
return <ExampleComponent {...props} />;
};
const { rerender } = render(<ComponentWithSpy {...defaultProps} />);
expect(renderSpy).toHaveBeenCalledTimes(1);
// Re-render with different title
rerender(<ComponentWithSpy {...defaultProps} title="New Title" />);
// Should re-render
expect(renderSpy).toHaveBeenCalledTimes(2);
});
});
describe('Snapshot Testing', () => {
it('should match snapshot for default state', () => {
const { toJSON } = render(<ExampleComponent {...defaultProps} />);
expect(toJSON()).toMatchSnapshot();
});
it('should match snapshot for loading state', () => {
const { toJSON } = render(<ExampleComponent {...defaultProps} loading />);
expect(toJSON()).toMatchSnapshot();
});
it('should match snapshot for error state', () => {
const { toJSON } = render(<ExampleComponent {...defaultProps} error="Error message" />);
expect(toJSON()).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,342 @@
/**
* Example React Native Service Test
*
* This demonstrates best practices for testing services:
* - Mock fetch/API calls
* - Test async operations
* - Test error handling
* - Test storage operations
* - Use MSW for API mocking (optional)
*/
import { authService } from '../authService';
import { tokenManager } from '../tokenManager';
import * as SecureStore from 'expo-secure-store';
// Mock dependencies
jest.mock('expo-secure-store');
jest.mock('../tokenManager');
// Mock data
const mockTokens = {
appToken: 'mock-app-token-12345',
refreshToken: 'mock-refresh-token-12345',
manaToken: 'mock-mana-token-12345',
};
const mockUser = {
id: 'user-123',
email: 'test@example.com',
};
describe('authService', () => {
beforeEach(() => {
jest.clearAllMocks();
global.fetch = jest.fn();
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('signIn', () => {
it('should sign in successfully with valid credentials', async () => {
// Arrange
const mockResponse = {
ok: true,
status: 200,
json: async () => ({
success: true,
...mockTokens,
user: mockUser,
}),
};
(global.fetch as jest.Mock).mockResolvedValue(mockResponse);
(SecureStore.setItemAsync as jest.Mock).mockResolvedValue(undefined);
// Act
const result = await authService.signIn('test@example.com', 'password123');
// Assert
expect(result.success).toBe(true);
expect(result.user).toEqual(mockUser);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/auth/signin'),
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: expect.stringContaining('test@example.com'),
})
);
// Verify tokens were stored
expect(SecureStore.setItemAsync).toHaveBeenCalledWith('@auth/appToken', mockTokens.appToken);
expect(SecureStore.setItemAsync).toHaveBeenCalledWith('@auth/refreshToken', mockTokens.refreshToken);
});
it('should handle invalid credentials error', async () => {
// Arrange
const mockResponse = {
ok: false,
status: 401,
json: async () => ({
success: false,
error: 'INVALID_CREDENTIALS',
}),
};
(global.fetch as jest.Mock).mockResolvedValue(mockResponse);
// Act
const result = await authService.signIn('test@example.com', 'wrongpassword');
// Assert
expect(result.success).toBe(false);
expect(result.error).toBe('INVALID_CREDENTIALS');
expect(SecureStore.setItemAsync).not.toHaveBeenCalled();
});
it('should handle network errors', async () => {
// Arrange
(global.fetch as jest.Mock).mockRejectedValue(new Error('Network request failed'));
// Act
const result = await authService.signIn('test@example.com', 'password123');
// Assert
expect(result.success).toBe(false);
expect(result.error).toContain('Network');
});
it('should handle storage errors', async () => {
// Arrange
const mockResponse = {
ok: true,
json: async () => ({
success: true,
...mockTokens,
user: mockUser,
}),
};
(global.fetch as jest.Mock).mockResolvedValue(mockResponse);
(SecureStore.setItemAsync as jest.Mock).mockRejectedValue(new Error('Storage unavailable'));
// Act
const result = await authService.signIn('test@example.com', 'password123');
// Assert
expect(result.success).toBe(false);
expect(result.error).toContain('Storage');
});
it('should validate email format', async () => {
// Act
const result = await authService.signIn('invalid-email', 'password123');
// Assert
expect(result.success).toBe(false);
expect(result.error).toContain('email');
expect(global.fetch).not.toHaveBeenCalled();
});
it('should validate password is not empty', async () => {
// Act
const result = await authService.signIn('test@example.com', '');
// Assert
expect(result.success).toBe(false);
expect(result.error).toContain('password');
expect(global.fetch).not.toHaveBeenCalled();
});
it('should handle timeout errors', async () => {
jest.useFakeTimers();
// Arrange
(global.fetch as jest.Mock).mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => resolve({ ok: true, json: async () => ({}) }), 60000);
})
);
// Act
const resultPromise = authService.signIn('test@example.com', 'password123');
jest.advanceTimersByTime(30000); // Advance 30s (timeout threshold)
const result = await resultPromise;
// Assert
expect(result.success).toBe(false);
expect(result.error).toContain('timeout');
jest.useRealTimers();
});
});
describe('signOut', () => {
it('should sign out successfully', async () => {
// Arrange
(SecureStore.deleteItemAsync as jest.Mock).mockResolvedValue(undefined);
(tokenManager.clearTokens as jest.Mock).mockResolvedValue(undefined);
// Act
const result = await authService.signOut();
// Assert
expect(result.success).toBe(true);
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('@auth/appToken');
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('@auth/refreshToken');
expect(tokenManager.clearTokens).toHaveBeenCalled();
});
it('should handle storage errors during sign out', async () => {
// Arrange
(SecureStore.deleteItemAsync as jest.Mock).mockRejectedValue(new Error('Storage error'));
// Act
const result = await authService.signOut();
// Assert
// Should succeed even if storage fails (user intent matters)
expect(result.success).toBe(true);
});
});
describe('refreshToken', () => {
it('should refresh token successfully', async () => {
// Arrange
const oldRefreshToken = 'old-refresh-token';
const newTokens = {
appToken: 'new-app-token',
refreshToken: 'new-refresh-token',
};
(SecureStore.getItemAsync as jest.Mock).mockResolvedValue(oldRefreshToken);
const mockResponse = {
ok: true,
json: async () => ({
success: true,
...newTokens,
}),
};
(global.fetch as jest.Mock).mockResolvedValue(mockResponse);
(SecureStore.setItemAsync as jest.Mock).mockResolvedValue(undefined);
// Act
const result = await authService.refreshToken();
// Assert
expect(result.success).toBe(true);
expect(result.appToken).toBe(newTokens.appToken);
expect(SecureStore.setItemAsync).toHaveBeenCalledWith('@auth/appToken', newTokens.appToken);
});
it('should handle missing refresh token', async () => {
// Arrange
(SecureStore.getItemAsync as jest.Mock).mockResolvedValue(null);
// Act
const result = await authService.refreshToken();
// Assert
expect(result.success).toBe(false);
expect(result.error).toContain('No refresh token');
expect(global.fetch).not.toHaveBeenCalled();
});
it('should handle expired refresh token', async () => {
// Arrange
(SecureStore.getItemAsync as jest.Mock).mockResolvedValue('expired-refresh-token');
const mockResponse = {
ok: false,
status: 401,
json: async () => ({
success: false,
error: 'REFRESH_TOKEN_EXPIRED',
}),
};
(global.fetch as jest.Mock).mockResolvedValue(mockResponse);
// Act
const result = await authService.refreshToken();
// Assert
expect(result.success).toBe(false);
expect(result.error).toBe('REFRESH_TOKEN_EXPIRED');
});
});
describe('checkAuthStatus', () => {
it('should return true when valid token exists', async () => {
// Arrange
(tokenManager.getValidToken as jest.Mock).mockResolvedValue('valid-token');
// Act
const result = await authService.checkAuthStatus();
// Assert
expect(result).toBe(true);
});
it('should return false when no token exists', async () => {
// Arrange
(tokenManager.getValidToken as jest.Mock).mockResolvedValue(null);
// Act
const result = await authService.checkAuthStatus();
// Assert
expect(result).toBe(false);
});
it('should refresh expired token automatically', async () => {
// Arrange
(tokenManager.getValidToken as jest.Mock)
.mockResolvedValueOnce(null) // First call: no valid token
.mockResolvedValueOnce('new-valid-token'); // After refresh
(authService.refreshToken as jest.Mock) = jest.fn().mockResolvedValue({
success: true,
appToken: 'new-valid-token',
});
// Act
const result = await authService.checkAuthStatus();
// Assert
expect(result).toBe(true);
expect(authService.refreshToken).toHaveBeenCalled();
});
});
describe('Integration with TokenManager', () => {
it('should notify TokenManager of new tokens', async () => {
// Arrange
const mockResponse = {
ok: true,
json: async () => ({
success: true,
...mockTokens,
user: mockUser,
}),
};
(global.fetch as jest.Mock).mockResolvedValue(mockResponse);
(SecureStore.setItemAsync as jest.Mock).mockResolvedValue(undefined);
(tokenManager.setTokens as jest.Mock).mockResolvedValue(undefined);
// Act
await authService.signIn('test@example.com', 'password123');
// Assert
expect(tokenManager.setTokens).toHaveBeenCalledWith(mockTokens);
});
});
});

View file

@ -0,0 +1,352 @@
/**
* Example Shared Package Utility Test
*
* This demonstrates best practices for testing utility functions:
* - Test pure functions
* - Test edge cases
* - Test error handling
* - Parameterized tests
* - Property-based testing (optional)
*/
import { describe, it, expect } from 'vitest';
import { formatDate, truncate, slugify, capitalize, debounce } from '../format';
describe('formatDate', () => {
it('should format date with default format', () => {
const date = new Date('2024-01-15T12:00:00Z');
const result = formatDate(date);
expect(result).toBe('2024-01-15');
});
it('should format date with custom format', () => {
const date = new Date('2024-01-15T12:00:00Z');
const result = formatDate(date, 'MM/dd/yyyy');
expect(result).toBe('01/15/2024');
});
it('should handle different locales', () => {
const date = new Date('2024-01-15T12:00:00Z');
const result = formatDate(date, 'PPP', { locale: 'de' });
expect(result).toContain('Januar');
});
it('should handle invalid dates', () => {
expect(() => formatDate(new Date('invalid'))).toThrow('Invalid date');
});
it('should handle null or undefined', () => {
expect(() => formatDate(null as any)).toThrow('Invalid date');
expect(() => formatDate(undefined as any)).toThrow('Invalid date');
});
it('should handle dates at boundaries', () => {
// Min safe date
const minDate = new Date(-8640000000000000);
expect(() => formatDate(minDate)).not.toThrow();
// Max safe date
const maxDate = new Date(8640000000000000);
expect(() => formatDate(maxDate)).not.toThrow();
});
it('should handle timezone differences', () => {
const date = new Date('2024-01-15T00:00:00Z');
const resultUTC = formatDate(date, 'yyyy-MM-dd HH:mm', { timeZone: 'UTC' });
const resultEST = formatDate(date, 'yyyy-MM-dd HH:mm', { timeZone: 'America/New_York' });
expect(resultUTC).not.toBe(resultEST);
});
});
describe('truncate', () => {
it('should truncate long strings', () => {
const text = 'This is a very long string that should be truncated';
const result = truncate(text, 20);
expect(result).toBe('This is a very long…');
expect(result.length).toBeLessThanOrEqual(21); // 20 chars + ellipsis
});
it('should not truncate short strings', () => {
const text = 'Short';
const result = truncate(text, 20);
expect(result).toBe('Short');
});
it('should use custom ellipsis', () => {
const text = 'This is a very long string';
const result = truncate(text, 10, '...');
expect(result).toBe('This is...');
});
it('should handle exact length match', () => {
const text = 'Exactly20Characters!';
const result = truncate(text, 20);
expect(result).toBe('Exactly20Characters!');
});
it('should handle empty strings', () => {
const result = truncate('', 10);
expect(result).toBe('');
});
it('should handle length of 0', () => {
const text = 'Some text';
const result = truncate(text, 0);
expect(result).toBe('…');
});
it('should handle negative length', () => {
expect(() => truncate('text', -1)).toThrow('Length must be non-negative');
});
it('should preserve word boundaries (optional feature)', () => {
const text = 'This is a very long string';
const result = truncate(text, 15, '…', { preserveWords: true });
expect(result).toBe('This is a very…');
expect(result).not.toContain('very l'); // Should not break mid-word
});
});
describe('slugify', () => {
it('should convert to lowercase', () => {
expect(slugify('Hello World')).toBe('hello-world');
});
it('should replace spaces with hyphens', () => {
expect(slugify('multiple spaces')).toBe('multiple-spaces');
});
it('should remove special characters', () => {
expect(slugify('Hello & World!')).toBe('hello-world');
expect(slugify('React@TypeScript#2024')).toBe('react-typescript-2024');
});
it('should handle unicode characters', () => {
expect(slugify('Café résumé')).toBe('cafe-resume');
expect(slugify('Zürich naïve')).toBe('zurich-naive');
});
it('should remove leading and trailing hyphens', () => {
expect(slugify(' hello world ')).toBe('hello-world');
expect(slugify('!!!hello world!!!')).toBe('hello-world');
});
it('should handle already slugified strings', () => {
expect(slugify('already-a-slug')).toBe('already-a-slug');
});
it('should handle empty strings', () => {
expect(slugify('')).toBe('');
});
it('should handle strings with only special characters', () => {
expect(slugify('!@#$%^&*()')).toBe('');
});
it('should handle very long strings', () => {
const longString = 'a'.repeat(1000);
const result = slugify(longString);
expect(result.length).toBeLessThanOrEqual(200); // Max slug length
});
// Parameterized tests
it.each([
['Hello World', 'hello-world'],
['React & TypeScript', 'react-typescript'],
['2024 年', '2024'],
[' Multiple Spaces ', 'multiple-spaces'],
['CamelCaseText', 'camelcasetext'],
])('slugify("%s") should return "%s"', (input, expected) => {
expect(slugify(input)).toBe(expected);
});
});
describe('capitalize', () => {
it('should capitalize first letter', () => {
expect(capitalize('hello')).toBe('Hello');
});
it('should handle already capitalized strings', () => {
expect(capitalize('Hello')).toBe('Hello');
});
it('should handle single characters', () => {
expect(capitalize('a')).toBe('A');
});
it('should handle empty strings', () => {
expect(capitalize('')).toBe('');
});
it('should not affect rest of string', () => {
expect(capitalize('hELLO wORLD')).toBe('HELLO wORLD');
});
it('should handle strings starting with numbers', () => {
expect(capitalize('123abc')).toBe('123abc');
});
it('should handle strings with leading whitespace', () => {
expect(capitalize(' hello')).toBe(' Hello');
});
});
describe('debounce', () => {
it('should delay function execution', async () => {
vi.useFakeTimers();
const mockFn = vi.fn();
const debouncedFn = debounce(mockFn, 500);
debouncedFn();
expect(mockFn).not.toHaveBeenCalled();
vi.advanceTimersByTime(500);
expect(mockFn).toHaveBeenCalledOnce();
vi.useRealTimers();
});
it('should cancel previous calls', async () => {
vi.useFakeTimers();
const mockFn = vi.fn();
const debouncedFn = debounce(mockFn, 500);
debouncedFn('call1');
vi.advanceTimersByTime(200);
debouncedFn('call2');
vi.advanceTimersByTime(200);
debouncedFn('call3');
vi.advanceTimersByTime(500);
// Should only call once with last argument
expect(mockFn).toHaveBeenCalledOnce();
expect(mockFn).toHaveBeenCalledWith('call3');
vi.useRealTimers();
});
it('should preserve this context', async () => {
vi.useFakeTimers();
const obj = {
value: 42,
method: function () {
return this.value;
},
};
const debouncedMethod = debounce(obj.method, 100);
const result = debouncedMethod.call(obj);
vi.advanceTimersByTime(100);
expect(result).toBe(42);
vi.useRealTimers();
});
it('should handle immediate option', () => {
vi.useFakeTimers();
const mockFn = vi.fn();
const debouncedFn = debounce(mockFn, 500, { immediate: true });
debouncedFn();
expect(mockFn).toHaveBeenCalledOnce(); // Called immediately
debouncedFn();
expect(mockFn).toHaveBeenCalledOnce(); // Still once (debounced)
vi.advanceTimersByTime(500);
debouncedFn();
expect(mockFn).toHaveBeenCalledTimes(2); // Called again after wait
vi.useRealTimers();
});
});
// Property-based testing example (requires fast-check)
describe('Property-based tests', () => {
it('slugify should always return lowercase', () => {
// Using property-based testing to generate random inputs
for (let i = 0; i < 100; i++) {
const randomString = Math.random().toString(36) + Math.random().toString(36);
const result = slugify(randomString);
expect(result).toBe(result.toLowerCase());
}
});
it('truncate should never exceed max length', () => {
const testCases = [
'short',
'exactly twenty chars',
'this is a very long string that needs truncation',
'a'.repeat(1000),
];
testCases.forEach((text) => {
const maxLength = 20;
const result = truncate(text, maxLength);
// Result should be <= maxLength + ellipsis length
expect(result.length).toBeLessThanOrEqual(maxLength + 1);
});
});
});
// Edge cases and boundary testing
describe('Edge Cases', () => {
describe('Unicode and Emoji handling', () => {
it('should handle emoji in truncate', () => {
const text = 'Hello 👋 World 🌍';
const result = truncate(text, 10);
expect(result.length).toBeLessThanOrEqual(11);
});
it('should handle emoji in slugify', () => {
const result = slugify('Hello 👋 World');
expect(result).toBe('hello-world');
expect(result).not.toContain('👋');
});
});
describe('Security considerations', () => {
it('should sanitize XSS in slugify', () => {
const malicious = '<script>alert("xss")</script>';
const result = slugify(malicious);
expect(result).not.toContain('<');
expect(result).not.toContain('>');
expect(result).not.toContain('script');
});
it('should handle SQL injection patterns', () => {
const sqlInjection = "'; DROP TABLE users; --";
const result = slugify(sqlInjection);
expect(result).not.toContain("'");
expect(result).not.toContain(';');
expect(result).not.toContain('--');
});
});
});

View file

@ -0,0 +1,355 @@
/**
* Example Svelte 5 Component Test
*
* This demonstrates best practices for testing Svelte 5 components:
* - Test component rendering with runes
* - Test user interactions
* - Test reactive state ($state, $derived, $effect)
* - Test events
* - Test props
*/
import { render, screen, fireEvent } from '@testing-library/svelte';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import Button from '../Button.svelte';
import userEvent from '@testing-library/user-event';
describe('Button (Svelte 5)', () => {
const user = userEvent.setup();
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('should render with text content', () => {
render(Button, { props: { children: 'Click Me' } });
expect(screen.getByText('Click Me')).toBeTruthy();
});
it('should render with variant classes', () => {
const { container } = render(Button, {
props: {
variant: 'primary',
children: 'Primary Button',
},
});
const button = container.querySelector('button');
expect(button?.className).toContain('btn-primary');
});
it('should render with custom class', () => {
const { container } = render(Button, {
props: {
class: 'custom-class',
children: 'Button',
},
});
const button = container.querySelector('button');
expect(button?.className).toContain('custom-class');
});
it('should render loading state', () => {
render(Button, {
props: {
loading: true,
children: 'Submit',
},
});
expect(screen.getByTestId('loading-spinner')).toBeTruthy();
});
it('should render disabled state', () => {
const { container } = render(Button, {
props: {
disabled: true,
children: 'Disabled',
},
});
const button = container.querySelector('button');
expect(button?.disabled).toBe(true);
});
});
describe('User Interactions', () => {
it('should call onclick when clicked', async () => {
const onclick = vi.fn();
render(Button, {
props: {
onclick,
children: 'Click Me',
},
});
await user.click(screen.getByText('Click Me'));
expect(onclick).toHaveBeenCalledOnce();
});
it('should not call onclick when disabled', async () => {
const onclick = vi.fn();
render(Button, {
props: {
onclick,
disabled: true,
children: 'Disabled',
},
});
await user.click(screen.getByText('Disabled'));
expect(onclick).not.toHaveBeenCalled();
});
it('should not call onclick when loading', async () => {
const onclick = vi.fn();
render(Button, {
props: {
onclick,
loading: true,
children: 'Loading',
},
});
const button = screen.getByRole('button');
await user.click(button);
expect(onclick).not.toHaveBeenCalled();
});
it('should handle keyboard events', async () => {
const onclick = vi.fn();
render(Button, {
props: {
onclick,
children: 'Press Enter',
},
});
const button = screen.getByRole('button');
button.focus();
await user.keyboard('{Enter}');
expect(onclick).toHaveBeenCalled();
});
});
describe('Reactive State (Svelte 5 Runes)', () => {
it('should react to prop changes', async () => {
const { component, rerender } = render(Button, {
props: {
loading: false,
children: 'Submit',
},
});
expect(screen.queryByTestId('loading-spinner')).toBeNull();
// Update props
await rerender({ loading: true });
expect(screen.getByTestId('loading-spinner')).toBeTruthy();
});
it('should derive styles based on variant', () => {
const { container, rerender } = render(Button, {
props: {
variant: 'primary',
children: 'Button',
},
});
let button = container.querySelector('button');
expect(button?.className).toContain('btn-primary');
rerender({ variant: 'secondary' });
button = container.querySelector('button');
expect(button?.className).toContain('btn-secondary');
expect(button?.className).not.toContain('btn-primary');
});
});
describe('Accessibility', () => {
it('should have button role', () => {
render(Button, { props: { children: 'Button' } });
expect(screen.getByRole('button')).toBeTruthy();
});
it('should support aria-label', () => {
render(Button, {
props: {
'aria-label': 'Close dialog',
children: 'X',
},
});
expect(screen.getByLabelText('Close dialog')).toBeTruthy();
});
it('should indicate disabled state to screen readers', () => {
render(Button, {
props: {
disabled: true,
children: 'Disabled',
},
});
const button = screen.getByRole('button');
expect(button.getAttribute('aria-disabled')).toBe('true');
});
it('should indicate loading state to screen readers', () => {
render(Button, {
props: {
loading: true,
children: 'Loading',
},
});
const button = screen.getByRole('button');
expect(button.getAttribute('aria-busy')).toBe('true');
});
});
describe('Variants', () => {
it.each([
['primary', 'btn-primary'],
['secondary', 'btn-secondary'],
['danger', 'btn-danger'],
['ghost', 'btn-ghost'],
])('should render %s variant with %s class', (variant, expectedClass) => {
const { container } = render(Button, {
props: {
variant,
children: 'Button',
},
});
const button = container.querySelector('button');
expect(button?.className).toContain(expectedClass);
});
});
describe('Sizes', () => {
it.each([
['sm', 'btn-sm'],
['md', 'btn-md'],
['lg', 'btn-lg'],
])('should render %s size with %s class', (size, expectedClass) => {
const { container } = render(Button, {
props: {
size,
children: 'Button',
},
});
const button = container.querySelector('button');
expect(button?.className).toContain(expectedClass);
});
});
describe('Edge Cases', () => {
it('should handle rapid clicks (debouncing)', async () => {
vi.useFakeTimers();
const onclick = vi.fn();
render(Button, {
props: {
onclick,
debounce: 500,
children: 'Click',
},
});
const button = screen.getByRole('button');
// Rapid clicks
await user.click(button);
await user.click(button);
await user.click(button);
// Should only call once
expect(onclick).toHaveBeenCalledTimes(1);
// Wait for debounce
vi.advanceTimersByTime(500);
// Click again
await user.click(button);
expect(onclick).toHaveBeenCalledTimes(2);
vi.useRealTimers();
});
it('should handle async onclick handlers', async () => {
const asyncOnclick = vi.fn(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});
render(Button, {
props: {
onclick: asyncOnclick,
children: 'Async Click',
},
});
await user.click(screen.getByText('Async Click'));
expect(asyncOnclick).toHaveBeenCalled();
// Wait for async handler to complete
await vi.waitFor(() => {
expect(asyncOnclick).toHaveReturnedWith(expect.any(Promise));
});
});
it('should handle null children gracefully', () => {
render(Button, { props: {} });
expect(screen.getByRole('button')).toBeTruthy();
});
});
describe('Slots', () => {
it('should render icon slot', () => {
render(Button, {
props: {
children: 'With Icon',
},
// Note: Testing slots in Vitest requires different approach
// This is a simplified example
});
expect(screen.getByText('With Icon')).toBeTruthy();
});
});
describe('Events', () => {
it('should dispatch custom event on click', async () => {
const { component } = render(Button, {
props: {
children: 'Custom Event',
},
});
const customEventHandler = vi.fn();
component.$on('customClick', customEventHandler);
await user.click(screen.getByText('Custom Event'));
expect(customEventHandler).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,422 @@
/**
* Example SvelteKit Server Load Function Test
*
* This demonstrates best practices for testing SvelteKit server functions:
* - Test load functions
* - Test form actions
* - Mock database/API calls
* - Test error handling
* - Test redirects
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { RequestEvent } from '@sveltejs/kit';
import { load, actions } from '../+page.server';
import { redirect } from '@sveltejs/kit';
// Mock dependencies
vi.mock('$lib/server/db', () => ({
db: {
query: {
users: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
},
},
}));
vi.mock('@sveltejs/kit', async () => {
const actual = await vi.importActual('@sveltejs/kit');
return {
...actual,
redirect: vi.fn((status, location) => {
throw new Error(`Redirect: ${status} ${location}`);
}),
};
});
describe('Dashboard Server Load Function', () => {
let mockLocals: any;
let mockEvent: Partial<RequestEvent>;
beforeEach(() => {
vi.clearAllMocks();
mockLocals = {
user: {
id: 'user-123',
email: 'test@example.com',
},
pb: {
collection: vi.fn(() => ({
getList: vi.fn(),
getOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
})),
},
};
mockEvent = {
locals: mockLocals,
params: {},
url: new URL('http://localhost:5173/dashboard'),
};
});
describe('load function', () => {
it('should load user data successfully', async () => {
// Arrange
const mockItems = [
{ id: '1', title: 'Item 1', createdAt: new Date() },
{ id: '2', title: 'Item 2', createdAt: new Date() },
];
mockLocals.pb.collection().getList.mockResolvedValue({
items: mockItems,
totalItems: 2,
page: 1,
totalPages: 1,
});
// Act
const result = await load(mockEvent as RequestEvent);
// Assert
expect(result.items).toHaveLength(2);
expect(result.items).toEqual(mockItems);
expect(mockLocals.pb.collection).toHaveBeenCalledWith('items');
});
it('should handle empty results', async () => {
// Arrange
mockLocals.pb.collection().getList.mockResolvedValue({
items: [],
totalItems: 0,
page: 1,
totalPages: 0,
});
// Act
const result = await load(mockEvent as RequestEvent);
// Assert
expect(result.items).toEqual([]);
});
it('should redirect when user is not authenticated', async () => {
// Arrange
mockEvent.locals = { user: null };
// Act & Assert
await expect(load(mockEvent as RequestEvent)).rejects.toThrow('Redirect: 302 /signin');
});
it('should handle database errors', async () => {
// Arrange
mockLocals.pb.collection().getList.mockRejectedValue(new Error('Database connection failed'));
// Act & Assert
await expect(load(mockEvent as RequestEvent)).rejects.toThrow('Database connection failed');
});
it('should filter items by user', async () => {
// Arrange
const mockItems = [{ id: '1', title: 'Item 1', userId: 'user-123' }];
mockLocals.pb.collection().getList.mockResolvedValue({
items: mockItems,
});
// Act
await load(mockEvent as RequestEvent);
// Assert
expect(mockLocals.pb.collection().getList).toHaveBeenCalledWith(
1,
20,
expect.objectContaining({
filter: expect.stringContaining('user-123'),
})
);
});
it('should handle pagination parameters', async () => {
// Arrange
mockEvent.url = new URL('http://localhost:5173/dashboard?page=2');
mockLocals.pb.collection().getList.mockResolvedValue({
items: [],
page: 2,
});
// Act
await load(mockEvent as RequestEvent);
// Assert
expect(mockLocals.pb.collection().getList).toHaveBeenCalledWith(
2, // page
20, // perPage
expect.any(Object)
);
});
it('should load related data efficiently', async () => {
// Arrange
const mockItems = [{ id: '1', categoryId: 'cat-1' }];
const mockCategories = [{ id: 'cat-1', name: 'Category 1' }];
mockLocals.pb.collection('items').getList.mockResolvedValue({ items: mockItems });
mockLocals.pb.collection('categories').getList.mockResolvedValue({ items: mockCategories });
// Act
const result = await load(mockEvent as RequestEvent);
// Assert
expect(result.items).toBeDefined();
expect(result.categories).toBeDefined();
// Should only make 2 DB calls (not N+1)
expect(mockLocals.pb.collection).toHaveBeenCalledTimes(2);
});
});
describe('form actions', () => {
describe('create', () => {
it('should create item successfully', async () => {
// Arrange
const formData = new FormData();
formData.append('title', 'New Item');
formData.append('description', 'Description');
mockEvent.request = {
formData: async () => formData,
} as Request;
const mockCreatedItem = {
id: 'item-123',
title: 'New Item',
description: 'Description',
};
mockLocals.pb.collection().create.mockResolvedValue(mockCreatedItem);
// Act
const result = await actions.create(mockEvent as RequestEvent);
// Assert
expect(result).toMatchObject({
success: true,
item: mockCreatedItem,
});
expect(mockLocals.pb.collection().create).toHaveBeenCalledWith(
expect.objectContaining({
title: 'New Item',
userId: 'user-123',
})
);
});
it('should validate required fields', async () => {
// Arrange
const formData = new FormData();
formData.append('title', ''); // Empty title
mockEvent.request = {
formData: async () => formData,
} as Request;
// Act
const result = await actions.create(mockEvent as RequestEvent);
// Assert
expect(result).toMatchObject({
success: false,
error: expect.stringContaining('Title is required'),
});
expect(mockLocals.pb.collection().create).not.toHaveBeenCalled();
});
it('should sanitize input data', async () => {
// Arrange
const formData = new FormData();
formData.append('title', '<script>alert("xss")</script>');
mockEvent.request = {
formData: async () => formData,
} as Request;
mockLocals.pb.collection().create.mockResolvedValue({
id: '1',
title: 'alert("xss")', // Sanitized
});
// Act
await actions.create(mockEvent as RequestEvent);
// Assert
expect(mockLocals.pb.collection().create).toHaveBeenCalledWith(
expect.objectContaining({
title: expect.not.stringContaining('<script>'),
})
);
});
it('should handle database errors', async () => {
// Arrange
const formData = new FormData();
formData.append('title', 'Test');
mockEvent.request = {
formData: async () => formData,
} as Request;
mockLocals.pb.collection().create.mockRejectedValue(new Error('Database error'));
// Act
const result = await actions.create(mockEvent as RequestEvent);
// Assert
expect(result).toMatchObject({
success: false,
error: expect.any(String),
});
});
it('should handle file uploads', async () => {
// Arrange
const file = new File(['content'], 'test.jpg', { type: 'image/jpeg' });
const formData = new FormData();
formData.append('title', 'Image Post');
formData.append('image', file);
mockEvent.request = {
formData: async () => formData,
} as Request;
mockLocals.pb.collection().create.mockResolvedValue({
id: '1',
title: 'Image Post',
image: 'uploads/test.jpg',
});
// Act
const result = await actions.create(mockEvent as RequestEvent);
// Assert
expect(result.success).toBe(true);
expect(mockLocals.pb.collection().create).toHaveBeenCalledWith(
expect.objectContaining({
image: expect.any(File),
})
);
});
});
describe('update', () => {
it('should update item successfully', async () => {
// Arrange
const formData = new FormData();
formData.append('id', 'item-123');
formData.append('title', 'Updated Title');
mockEvent.request = {
formData: async () => formData,
} as Request;
mockLocals.pb.collection().getOne.mockResolvedValue({
id: 'item-123',
userId: 'user-123',
});
mockLocals.pb.collection().update.mockResolvedValue({
id: 'item-123',
title: 'Updated Title',
});
// Act
const result = await actions.update(mockEvent as RequestEvent);
// Assert
expect(result.success).toBe(true);
expect(mockLocals.pb.collection().update).toHaveBeenCalled();
});
it('should not allow updating other users items', async () => {
// Arrange
const formData = new FormData();
formData.append('id', 'item-123');
formData.append('title', 'Hacked');
mockEvent.request = {
formData: async () => formData,
} as Request;
mockLocals.pb.collection().getOne.mockResolvedValue({
id: 'item-123',
userId: 'other-user', // Different user
});
// Act
const result = await actions.update(mockEvent as RequestEvent);
// Assert
expect(result.success).toBe(false);
expect(result.error).toContain('Unauthorized');
expect(mockLocals.pb.collection().update).not.toHaveBeenCalled();
});
});
describe('delete', () => {
it('should delete item successfully', async () => {
// Arrange
const formData = new FormData();
formData.append('id', 'item-123');
mockEvent.request = {
formData: async () => formData,
} as Request;
mockLocals.pb.collection().getOne.mockResolvedValue({
id: 'item-123',
userId: 'user-123',
});
mockLocals.pb.collection().delete.mockResolvedValue(true);
// Act
const result = await actions.delete(mockEvent as RequestEvent);
// Assert
expect(result.success).toBe(true);
expect(mockLocals.pb.collection().delete).toHaveBeenCalledWith('item-123');
});
it('should not allow deleting other users items', async () => {
// Arrange
const formData = new FormData();
formData.append('id', 'item-123');
mockEvent.request = {
formData: async () => formData,
} as Request;
mockLocals.pb.collection().getOne.mockResolvedValue({
id: 'item-123',
userId: 'other-user',
});
// Act
const result = await actions.delete(mockEvent as RequestEvent);
// Assert
expect(result.success).toBe(false);
expect(mockLocals.pb.collection().delete).not.toHaveBeenCalled();
});
});
});
});