Gas Station Feature
This commit is contained in:
857
backend/src/features/stations/docs/TESTING.md
Normal file
857
backend/src/features/stations/docs/TESTING.md
Normal file
@@ -0,0 +1,857 @@
|
||||
# Gas Stations Feature - Testing Guide
|
||||
|
||||
## Overview
|
||||
|
||||
Comprehensive testing guide for the Gas Stations feature. This feature includes unit tests, integration tests, and guidance for writing new tests. All tests follow MotoVaultPro's container-first development approach.
|
||||
|
||||
## Test Structure
|
||||
|
||||
```
|
||||
backend/src/features/stations/tests/
|
||||
├── fixtures/ # Mock data and helpers
|
||||
│ ├── mock-stations.ts # Sample station data
|
||||
│ └── mock-google-response.ts # Google API response mocks
|
||||
├── unit/ # Unit tests
|
||||
│ ├── stations.service.test.ts # Service layer tests
|
||||
│ └── google-maps.client.test.ts # External API client tests
|
||||
└── integration/ # Integration tests
|
||||
└── stations.api.test.ts # Full API workflow tests
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Container-Based Testing (Recommended)
|
||||
|
||||
All tests should be run inside Docker containers to match production environment.
|
||||
|
||||
**Run all stations tests**:
|
||||
```bash
|
||||
docker compose exec mvp-backend npm test -- features/stations
|
||||
```
|
||||
|
||||
**Run specific test file**:
|
||||
```bash
|
||||
docker compose exec mvp-backend npm test -- features/stations/tests/unit/stations.service.test.ts
|
||||
```
|
||||
|
||||
**Run tests in watch mode**:
|
||||
```bash
|
||||
docker compose exec mvp-backend npm test -- --watch features/stations
|
||||
```
|
||||
|
||||
**Run tests with coverage**:
|
||||
```bash
|
||||
docker compose exec mvp-backend npm test -- --coverage features/stations
|
||||
```
|
||||
|
||||
### Local Development (Optional)
|
||||
|
||||
For rapid iteration during test development:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm test -- features/stations
|
||||
```
|
||||
|
||||
**Note**: Always validate passing tests in containers before committing.
|
||||
|
||||
## Test Database Setup
|
||||
|
||||
### Test Database Configuration
|
||||
|
||||
Tests use a separate test database to avoid polluting development data.
|
||||
|
||||
**Environment Variables** (set in docker-compose.yml):
|
||||
```yaml
|
||||
NODE_ENV: test
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/motovaultpro_test
|
||||
```
|
||||
|
||||
### Before Running Tests
|
||||
|
||||
**Ensure test database exists**:
|
||||
```bash
|
||||
# Create test database (one-time setup)
|
||||
docker compose exec postgres psql -U postgres -c "CREATE DATABASE motovaultpro_test;"
|
||||
|
||||
# Run migrations on test database
|
||||
docker compose exec mvp-backend npm run migrate:test
|
||||
```
|
||||
|
||||
### Test Data Isolation
|
||||
|
||||
Each test should:
|
||||
1. Create its own test data
|
||||
2. Use unique user IDs
|
||||
3. Clean up after execution (via beforeEach/afterEach)
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
describe('StationsService', () => {
|
||||
beforeEach(async () => {
|
||||
// Clear test data
|
||||
await pool.query('DELETE FROM saved_stations WHERE user_id LIKE $1', ['test-%']);
|
||||
await pool.query('DELETE FROM station_cache WHERE created_at < NOW()');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Additional cleanup if needed
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Writing Unit Tests
|
||||
|
||||
### Service Layer Tests
|
||||
|
||||
**Location**: `tests/unit/stations.service.test.ts`
|
||||
|
||||
**Purpose**: Test business logic in isolation
|
||||
|
||||
**Pattern**: Mock external dependencies (repository, Google Maps client)
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
import { StationsService } from '../../domain/stations.service';
|
||||
import { StationsRepository } from '../../data/stations.repository';
|
||||
import { googleMapsClient } from '../../external/google-maps/google-maps.client';
|
||||
|
||||
jest.mock('../../data/stations.repository');
|
||||
jest.mock('../../external/google-maps/google-maps.client');
|
||||
|
||||
describe('StationsService', () => {
|
||||
let service: StationsService;
|
||||
let mockRepository: jest.Mocked<StationsRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepository = {
|
||||
cacheStation: jest.fn().mockResolvedValue(undefined),
|
||||
getCachedStation: jest.fn(),
|
||||
saveStation: jest.fn(),
|
||||
getUserSavedStations: jest.fn(),
|
||||
deleteSavedStation: jest.fn()
|
||||
} as unknown as jest.Mocked<StationsRepository>;
|
||||
|
||||
service = new StationsService(mockRepository);
|
||||
});
|
||||
|
||||
it('should search nearby stations and cache results', async () => {
|
||||
const mockStations = [
|
||||
{ placeId: 'station-1', name: 'Shell', latitude: 37.7749, longitude: -122.4194 }
|
||||
];
|
||||
|
||||
(googleMapsClient.searchNearbyStations as jest.Mock).mockResolvedValue(mockStations);
|
||||
|
||||
const result = await service.searchNearbyStations({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
radius: 5000
|
||||
}, 'user-123');
|
||||
|
||||
expect(result.stations).toHaveLength(1);
|
||||
expect(mockRepository.cacheStation).toHaveBeenCalledWith(mockStations[0]);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Repository Layer Tests
|
||||
|
||||
**Location**: `tests/unit/stations.repository.test.ts` (create if needed)
|
||||
|
||||
**Purpose**: Test SQL query construction and database interaction
|
||||
|
||||
**Pattern**: Use in-memory database or transaction rollback
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
import { StationsRepository } from '../../data/stations.repository';
|
||||
import { pool } from '../../../../core/config/database';
|
||||
|
||||
describe('StationsRepository', () => {
|
||||
let repository: StationsRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = new StationsRepository(pool);
|
||||
});
|
||||
|
||||
it('should save station with user isolation', async () => {
|
||||
const userId = 'test-user-123';
|
||||
const placeId = 'test-place-456';
|
||||
|
||||
const saved = await repository.saveStation(userId, placeId, {
|
||||
nickname: 'Test Station'
|
||||
});
|
||||
|
||||
expect(saved.userId).toBe(userId);
|
||||
expect(saved.stationId).toBe(placeId);
|
||||
expect(saved.nickname).toBe('Test Station');
|
||||
});
|
||||
|
||||
it('should enforce unique constraint per user', async () => {
|
||||
const userId = 'test-user-123';
|
||||
const placeId = 'test-place-456';
|
||||
|
||||
await repository.saveStation(userId, placeId, {});
|
||||
|
||||
// Attempt duplicate save
|
||||
await expect(
|
||||
repository.saveStation(userId, placeId, {})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### External Client Tests
|
||||
|
||||
**Location**: `tests/unit/google-maps.client.test.ts`
|
||||
|
||||
**Purpose**: Test API call construction and response parsing
|
||||
|
||||
**Pattern**: Mock axios/fetch, test request format and error handling
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
import { googleMapsClient } from '../../external/google-maps/google-maps.client';
|
||||
import axios from 'axios';
|
||||
|
||||
jest.mock('axios');
|
||||
|
||||
describe('GoogleMapsClient', () => {
|
||||
it('should construct correct API request', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
place_id: 'station-1',
|
||||
name: 'Shell',
|
||||
geometry: { location: { lat: 37.7749, lng: -122.4194 } }
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
(axios.get as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
await googleMapsClient.searchNearbyStations(37.7749, -122.4194, 5000);
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith(
|
||||
expect.stringContaining('https://maps.googleapis.com/maps/api/place/nearbysearch'),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
location: '37.7749,-122.4194',
|
||||
radius: 5000,
|
||||
type: 'gas_station'
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
(axios.get as jest.Mock).mockRejectedValue(new Error('API Error'));
|
||||
|
||||
await expect(
|
||||
googleMapsClient.searchNearbyStations(37.7749, -122.4194, 5000)
|
||||
).rejects.toThrow('API Error');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Writing Integration Tests
|
||||
|
||||
### API Workflow Tests
|
||||
|
||||
**Location**: `tests/integration/stations.api.test.ts`
|
||||
|
||||
**Purpose**: Test complete request/response flows with real database
|
||||
|
||||
**Pattern**: Use Fastify app instance, real JWT, test database
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
import { buildApp } from '../../../../app';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { pool } from '../../../../core/config/database';
|
||||
|
||||
describe('Stations API Integration', () => {
|
||||
let app: FastifyInstance;
|
||||
let authToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
// Generate test JWT token
|
||||
authToken = await generateTestToken({ sub: 'test-user-123' });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clear test data
|
||||
await pool.query('DELETE FROM saved_stations WHERE user_id = $1', ['test-user-123']);
|
||||
await pool.query('DELETE FROM station_cache');
|
||||
});
|
||||
|
||||
describe('POST /api/stations/search', () => {
|
||||
it('should search for nearby stations', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/stations/search',
|
||||
headers: {
|
||||
authorization: `Bearer ${authToken}`
|
||||
},
|
||||
payload: {
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
radius: 5000
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body).toHaveProperty('stations');
|
||||
expect(body.stations).toBeInstanceOf(Array);
|
||||
expect(body).toHaveProperty('searchLocation');
|
||||
expect(body).toHaveProperty('searchRadius');
|
||||
});
|
||||
|
||||
it('should return 400 for invalid coordinates', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/stations/search',
|
||||
headers: {
|
||||
authorization: `Bearer ${authToken}`
|
||||
},
|
||||
payload: {
|
||||
latitude: 999,
|
||||
longitude: -122.4194
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('should return 401 without auth token', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/stations/search',
|
||||
payload: {
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/stations/save', () => {
|
||||
it('should save a station', async () => {
|
||||
// First, search to populate cache
|
||||
const searchResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/stations/search',
|
||||
headers: {
|
||||
authorization: `Bearer ${authToken}`
|
||||
},
|
||||
payload: {
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
radius: 5000
|
||||
}
|
||||
});
|
||||
|
||||
const searchBody = JSON.parse(searchResponse.body);
|
||||
const placeId = searchBody.stations[0].placeId;
|
||||
|
||||
// Then save station
|
||||
const saveResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/stations/save',
|
||||
headers: {
|
||||
authorization: `Bearer ${authToken}`
|
||||
},
|
||||
payload: {
|
||||
placeId,
|
||||
nickname: 'My Favorite Station',
|
||||
isFavorite: true
|
||||
}
|
||||
});
|
||||
|
||||
expect(saveResponse.statusCode).toBe(201);
|
||||
const saveBody = JSON.parse(saveResponse.body);
|
||||
expect(saveBody.nickname).toBe('My Favorite Station');
|
||||
expect(saveBody.isFavorite).toBe(true);
|
||||
});
|
||||
|
||||
it('should return 404 if station not in cache', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/stations/save',
|
||||
headers: {
|
||||
authorization: `Bearer ${authToken}`
|
||||
},
|
||||
payload: {
|
||||
placeId: 'non-existent-place-id'
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Isolation', () => {
|
||||
it('should isolate saved stations by user', async () => {
|
||||
const user1Token = await generateTestToken({ sub: 'user-1' });
|
||||
const user2Token = await generateTestToken({ sub: 'user-2' });
|
||||
|
||||
// User 1 saves a station
|
||||
const searchResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/stations/search',
|
||||
headers: { authorization: `Bearer ${user1Token}` },
|
||||
payload: { latitude: 37.7749, longitude: -122.4194 }
|
||||
});
|
||||
|
||||
const placeId = JSON.parse(searchResponse.body).stations[0].placeId;
|
||||
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/stations/save',
|
||||
headers: { authorization: `Bearer ${user1Token}` },
|
||||
payload: { placeId }
|
||||
});
|
||||
|
||||
// User 2 cannot see User 1's saved station
|
||||
const user2Response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/stations/saved',
|
||||
headers: { authorization: `Bearer ${user2Token}` }
|
||||
});
|
||||
|
||||
const user2Body = JSON.parse(user2Response.body);
|
||||
expect(user2Body).toEqual([]);
|
||||
|
||||
// User 1 can see their saved station
|
||||
const user1Response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/stations/saved',
|
||||
headers: { authorization: `Bearer ${user1Token}` }
|
||||
});
|
||||
|
||||
const user1Body = JSON.parse(user1Response.body);
|
||||
expect(user1Body).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Mock Data and Fixtures
|
||||
|
||||
### Creating Test Fixtures
|
||||
|
||||
**Location**: `tests/fixtures/mock-stations.ts`
|
||||
|
||||
**Purpose**: Reusable test data for all tests
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
export const mockUserId = 'test-user-123';
|
||||
|
||||
export const searchCoordinates = {
|
||||
sanFrancisco: { latitude: 37.7749, longitude: -122.4194 },
|
||||
newYork: { latitude: 40.7128, longitude: -74.0060 }
|
||||
};
|
||||
|
||||
export const mockStations = [
|
||||
{
|
||||
placeId: 'ChIJN1t_tDeuEmsRUsoyG83frY4',
|
||||
name: 'Shell Gas Station - Downtown',
|
||||
address: '123 Main St, San Francisco, CA 94102',
|
||||
latitude: 37.7750,
|
||||
longitude: -122.4195,
|
||||
rating: 4.2,
|
||||
photoUrl: 'https://example.com/photo1.jpg',
|
||||
distance: 150
|
||||
},
|
||||
{
|
||||
placeId: 'ChIJN1t_tDeuEmsRUsoyG83frY5',
|
||||
name: 'Chevron - Market Street',
|
||||
address: '456 Market St, San Francisco, CA 94103',
|
||||
latitude: 37.7755,
|
||||
longitude: -122.4190,
|
||||
rating: 4.0,
|
||||
photoUrl: null,
|
||||
distance: 300
|
||||
}
|
||||
];
|
||||
|
||||
export const mockSavedStations = [
|
||||
{
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
userId: mockUserId,
|
||||
stationId: mockStations[0].placeId,
|
||||
nickname: 'Work Gas Station',
|
||||
notes: 'Close to office',
|
||||
isFavorite: true,
|
||||
createdAt: new Date('2025-01-15T10:00:00Z'),
|
||||
updatedAt: new Date('2025-01-15T10:00:00Z'),
|
||||
deletedAt: null
|
||||
}
|
||||
];
|
||||
```
|
||||
|
||||
### Mocking External APIs
|
||||
|
||||
**Location**: `tests/fixtures/mock-google-response.ts`
|
||||
|
||||
**Purpose**: Simulate Google Maps API responses
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
export const mockGooglePlacesResponse = {
|
||||
results: [
|
||||
{
|
||||
place_id: 'ChIJN1t_tDeuEmsRUsoyG83frY4',
|
||||
name: 'Shell Gas Station',
|
||||
vicinity: '123 Main St, San Francisco',
|
||||
geometry: {
|
||||
location: { lat: 37.7750, lng: -122.4195 }
|
||||
},
|
||||
rating: 4.2,
|
||||
photos: [
|
||||
{
|
||||
photo_reference: 'CmRaAAAA...',
|
||||
height: 400,
|
||||
width: 300
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
status: 'OK'
|
||||
};
|
||||
|
||||
export const mockGoogleErrorResponse = {
|
||||
results: [],
|
||||
status: 'ZERO_RESULTS',
|
||||
error_message: 'No results found'
|
||||
};
|
||||
```
|
||||
|
||||
## Coverage Goals
|
||||
|
||||
### Target Coverage
|
||||
|
||||
- **Overall Feature Coverage**: >80%
|
||||
- **Service Layer**: >90% (critical business logic)
|
||||
- **Repository Layer**: >80% (database operations)
|
||||
- **Controller Layer**: >70% (error handling)
|
||||
- **External Client**: >70% (API integration)
|
||||
|
||||
### Checking Coverage
|
||||
|
||||
```bash
|
||||
# Generate coverage report
|
||||
docker compose exec mvp-backend npm test -- --coverage features/stations
|
||||
|
||||
# View HTML report (generated in backend/coverage/)
|
||||
open backend/coverage/lcov-report/index.html
|
||||
```
|
||||
|
||||
### Coverage Exemptions
|
||||
|
||||
Lines exempt from coverage requirements:
|
||||
- Logger statements
|
||||
- Type guards (if rarely hit)
|
||||
- Unreachable error handlers
|
||||
|
||||
**Mark with comment**:
|
||||
```typescript
|
||||
/* istanbul ignore next */
|
||||
logger.debug('This log is exempt from coverage');
|
||||
```
|
||||
|
||||
## Testing Best Practices
|
||||
|
||||
### Test Naming Convention
|
||||
|
||||
Use descriptive test names that explain what is being tested:
|
||||
|
||||
**Good**:
|
||||
```typescript
|
||||
it('should return 404 if station not found in cache')
|
||||
it('should isolate saved stations by user_id')
|
||||
it('should sort stations by distance ascending')
|
||||
```
|
||||
|
||||
**Bad**:
|
||||
```typescript
|
||||
it('works')
|
||||
it('test save')
|
||||
it('error case')
|
||||
```
|
||||
|
||||
### Arrange-Act-Assert Pattern
|
||||
|
||||
Structure tests with clear sections:
|
||||
|
||||
```typescript
|
||||
it('should save station with metadata', async () => {
|
||||
// Arrange
|
||||
const userId = 'test-user-123';
|
||||
const placeId = 'station-1';
|
||||
mockRepository.getCachedStation.mockResolvedValue(mockStations[0]);
|
||||
mockRepository.saveStation.mockResolvedValue(mockSavedStations[0]);
|
||||
|
||||
// Act
|
||||
const result = await service.saveStation(placeId, userId, {
|
||||
nickname: 'Test Station'
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.nickname).toBe('Test Station');
|
||||
expect(mockRepository.saveStation).toHaveBeenCalledWith(
|
||||
userId,
|
||||
placeId,
|
||||
{ nickname: 'Test Station' }
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Test Data Cleanup
|
||||
|
||||
Always clean up test data to avoid interference:
|
||||
|
||||
```typescript
|
||||
afterEach(async () => {
|
||||
await pool.query('DELETE FROM saved_stations WHERE user_id LIKE $1', ['test-%']);
|
||||
await pool.query('DELETE FROM station_cache WHERE created_at < NOW()');
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Error Scenarios
|
||||
|
||||
Test both happy path and error cases:
|
||||
|
||||
```typescript
|
||||
describe('saveStation', () => {
|
||||
it('should save station successfully', async () => {
|
||||
// Happy path test
|
||||
});
|
||||
|
||||
it('should throw error if station not in cache', async () => {
|
||||
mockRepository.getCachedStation.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.saveStation('unknown-id', 'user-123')
|
||||
).rejects.toThrow('Station not found');
|
||||
});
|
||||
|
||||
it('should throw error if database fails', async () => {
|
||||
mockRepository.getCachedStation.mockResolvedValue(mockStations[0]);
|
||||
mockRepository.saveStation.mockRejectedValue(new Error('DB Error'));
|
||||
|
||||
await expect(
|
||||
service.saveStation('station-1', 'user-123')
|
||||
).rejects.toThrow('DB Error');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Testing User Isolation
|
||||
|
||||
Always verify user data isolation:
|
||||
|
||||
```typescript
|
||||
it('should only return stations for authenticated user', async () => {
|
||||
const user1 = 'user-1';
|
||||
const user2 = 'user-2';
|
||||
|
||||
await repository.saveStation(user1, 'station-1', {});
|
||||
await repository.saveStation(user2, 'station-2', {});
|
||||
|
||||
const user1Stations = await repository.getUserSavedStations(user1);
|
||||
const user2Stations = await repository.getUserSavedStations(user2);
|
||||
|
||||
expect(user1Stations).toHaveLength(1);
|
||||
expect(user1Stations[0].stationId).toBe('station-1');
|
||||
expect(user2Stations).toHaveLength(1);
|
||||
expect(user2Stations[0].stationId).toBe('station-2');
|
||||
});
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
### Running Tests in CI Pipeline
|
||||
|
||||
**GitHub Actions Example** (`.github/workflows/test.yml`):
|
||||
```yaml
|
||||
name: Test Gas Stations Feature
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Set up Docker
|
||||
run: docker compose up -d postgres redis
|
||||
|
||||
- name: Run migrations
|
||||
run: docker compose exec mvp-backend npm run migrate:test
|
||||
|
||||
- name: Run tests
|
||||
run: docker compose exec mvp-backend npm test -- features/stations
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v2
|
||||
with:
|
||||
files: ./backend/coverage/lcov.info
|
||||
```
|
||||
|
||||
### Pre-Commit Hook
|
||||
|
||||
Add to `.git/hooks/pre-commit`:
|
||||
```bash
|
||||
#!/bin/sh
|
||||
echo "Running stations feature tests..."
|
||||
docker compose exec mvp-backend npm test -- features/stations
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Tests failed. Commit aborted."
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
## Debugging Tests
|
||||
|
||||
### Enable Verbose Logging
|
||||
|
||||
```bash
|
||||
docker compose exec mvp-backend npm test -- --verbose features/stations
|
||||
```
|
||||
|
||||
### Debug Single Test
|
||||
|
||||
```typescript
|
||||
it.only('should search nearby stations', async () => {
|
||||
// This test runs in isolation
|
||||
});
|
||||
```
|
||||
|
||||
### Inspect Test Database
|
||||
|
||||
```bash
|
||||
# Connect to test database
|
||||
docker compose exec postgres psql -U postgres -d motovaultpro_test
|
||||
|
||||
# Query test data
|
||||
SELECT * FROM saved_stations WHERE user_id LIKE 'test-%';
|
||||
SELECT * FROM station_cache;
|
||||
```
|
||||
|
||||
### View Test Logs
|
||||
|
||||
```bash
|
||||
docker compose logs mvp-backend | grep -i "test"
|
||||
```
|
||||
|
||||
## Common Test Failures
|
||||
|
||||
### "Station not found" Error
|
||||
|
||||
**Cause**: Station not in cache before save attempt
|
||||
|
||||
**Fix**: Ensure search populates cache first:
|
||||
```typescript
|
||||
await service.searchNearbyStations({ latitude, longitude }, userId);
|
||||
await service.saveStation(placeId, userId);
|
||||
```
|
||||
|
||||
### "Unique constraint violation"
|
||||
|
||||
**Cause**: Test data not cleaned up between tests
|
||||
|
||||
**Fix**: Add proper cleanup in beforeEach:
|
||||
```typescript
|
||||
beforeEach(async () => {
|
||||
await pool.query('DELETE FROM saved_stations WHERE user_id = $1', ['test-user']);
|
||||
});
|
||||
```
|
||||
|
||||
### "JWT token invalid"
|
||||
|
||||
**Cause**: Test token expired or malformed
|
||||
|
||||
**Fix**: Use proper test token generation:
|
||||
```typescript
|
||||
const token = await generateTestToken({ sub: 'test-user', exp: Date.now() + 3600 });
|
||||
```
|
||||
|
||||
### "Circuit breaker open"
|
||||
|
||||
**Cause**: Too many failed Google API calls in tests
|
||||
|
||||
**Fix**: Mock Google client to avoid real API calls:
|
||||
```typescript
|
||||
jest.mock('../../external/google-maps/google-maps.client');
|
||||
```
|
||||
|
||||
## Adding New Tests
|
||||
|
||||
### Checklist for New Test Files
|
||||
|
||||
- [ ] Create in appropriate directory (unit/ or integration/)
|
||||
- [ ] Import necessary fixtures from tests/fixtures/
|
||||
- [ ] Mock external dependencies (repository, Google client)
|
||||
- [ ] Add beforeEach/afterEach cleanup
|
||||
- [ ] Follow naming conventions
|
||||
- [ ] Test happy path
|
||||
- [ ] Test error scenarios
|
||||
- [ ] Test user isolation
|
||||
- [ ] Run in container to verify
|
||||
- [ ] Check coverage impact
|
||||
|
||||
### Template for New Unit Test
|
||||
|
||||
```typescript
|
||||
import { /* imports */ } from '../../domain/stations.service';
|
||||
|
||||
jest.mock('../../data/stations.repository');
|
||||
jest.mock('../../external/google-maps/google-maps.client');
|
||||
|
||||
describe('FeatureName', () => {
|
||||
let service: YourService;
|
||||
let mockDependency: jest.Mocked<Dependency>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Setup mocks
|
||||
service = new YourService(mockDependency);
|
||||
});
|
||||
|
||||
describe('methodName', () => {
|
||||
it('should handle happy path', async () => {
|
||||
// Arrange
|
||||
// Act
|
||||
// Assert
|
||||
});
|
||||
|
||||
it('should handle error case', async () => {
|
||||
// Arrange
|
||||
// Act
|
||||
// Assert
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- Jest Documentation: https://jestjs.io/docs/getting-started
|
||||
- Fastify Testing: https://www.fastify.io/docs/latest/Guides/Testing/
|
||||
- Feature README: `/backend/src/features/stations/README.md`
|
||||
- Architecture: `/backend/src/features/stations/docs/ARCHITECTURE.md`
|
||||
- API Documentation: `/backend/src/features/stations/docs/API.md`
|
||||
Reference in New Issue
Block a user