Overview
The SG Cars Trends API uses a comprehensive testing approach with Vitest as the primary testing framework. This guide covers testing strategies, best practices, and practical examples.Testing Framework
Vitest Configuration
The project uses Vitest for fast, modern testing:Copy
import {defineConfig} from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/**',
'dist/**',
'coverage/**',
'**/*.d.ts',
'**/*.test.{ts,js}',
'**/__tests__/**'
]
},
setupFiles: ['./src/test/setup.ts']
}
});
Test Structure
Tests are organized in__tests__
directories alongside source code:
Copy
src/
├── utils/
│ ├── format-percentage.ts
│ └── __tests__/
│ └── format-percentage.test.ts
├── lib/
│ ├── getLatestMonth.ts
│ └── __tests__/
│ └── getLatestMonth.test.ts
└── v1/
├── routes/
│ ├── cars.ts
│ └── __tests__/
│ └── cars.test.ts
Running Tests
Basic Commands
Copy
pnpm test
Test Options
Option | Description | Example |
---|---|---|
--watch | Watch mode for development | pnpm test:watch |
--coverage | Generate coverage report | pnpm test:coverage |
--reporter | Specify test reporter | pnpm test -- --reporter=json |
--grep | Filter tests by pattern | pnpm test -- --grep "API" |
--bail | Stop on first failure | pnpm test -- --bail |
Test Categories
1. Unit Tests
Test individual functions and utilities:Copy
import {describe, it, expect} from 'vitest';
import {formatPercentage} from '../format-percentage';
describe('formatPercentage', () => {
it('should format decimal to percentage', () => {
expect(formatPercentage(0.1234)).toBe('12.34%');
});
it('should handle zero', () => {
expect(formatPercentage(0)).toBe('0.00%');
});
it('should handle one', () => {
expect(formatPercentage(1)).toBe('100.00%');
});
it('should handle small numbers', () => {
expect(formatPercentage(0.001)).toBe('0.10%');
});
it('should round to two decimal places', () => {
expect(formatPercentage(0.12345)).toBe('12.35%');
});
});
2. Integration Tests
Test API endpoints and database interactions:Copy
import {describe, it, expect, beforeEach, afterEach} from 'vitest';
import {testClient} from '../../../test/client';
import {setupTestDatabase, cleanupTestDatabase} from '../../../test/database';
describe('Cars API', () => {
beforeEach(async () => {
await setupTestDatabase();
});
afterEach(async () => {
await cleanupTestDatabase();
});
describe('GET /v1/cars', () => {
it('should return cars data', async () => {
const response = await testClient.get('/v1/cars');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
success: true,
data: expect.any(Array),
total: expect.any(Number),
page: 1,
limit: 50
});
});
it('should filter by month', async () => {
const response = await testClient.get('/v1/cars?month=2024-01');
expect(response.status).toBe(200);
expect(response.body.data).toEqual(
expect.arrayContaining([
expect.objectContaining({
month: '2024-01'
})
])
);
});
it('should return 401 without authentication', async () => {
const response = await testClient
.get('/v1/cars')
.unset('Authorization');
expect(response.status).toBe(401);
expect(response.body.error).toContain('authentication');
});
});
describe('GET /v1/cars/compare', () => {
it('should return comparison data', async () => {
const response = await testClient.get('/v1/cars/compare?month=2024-01');
expect(response.status).toBe(200);
expect(response.body.data).toMatchObject({
current_month: expect.any(Object),
previous_month: expect.any(Object),
previous_year: expect.any(Object),
comparison: expect.any(Object)
});
});
it('should require month parameter', async () => {
const response = await testClient.get('/v1/cars/compare');
expect(response.status).toBe(400);
expect(response.body.error).toContain('month');
});
});
});
3. Database Tests
Test database operations and queries:Copy
import {describe, it, expect, beforeEach, afterEach} from 'vitest';
import {db} from '../../config/db';
import {cars} from '../../schema';
import {getCarsByMonth, getCarComparison} from '../cars';
import {setupTestDatabase, cleanupTestDatabase, insertTestData} from '../../test/database';
describe('Car Queries', () => {
beforeEach(async () => {
await setupTestDatabase();
await insertTestData();
});
afterEach(async () => {
await cleanupTestDatabase();
});
describe('getCarsByMonth', () => {
it('should return cars for specific month', async () => {
const result = await getCarsByMonth('2024-01');
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
month: '2024-01',
make: expect.any(String),
fuel_type: expect.any(String),
vehicle_type: expect.any(String),
number: expect.any(Number)
})
])
);
});
it('should return empty array for non-existent month', async () => {
const result = await getCarsByMonth('2030-01');
expect(result).toEqual([]);
});
});
describe('getCarComparison', () => {
it('should return comparison data', async () => {
const result = await getCarComparison('2024-01');
expect(result).toMatchObject({
current_month: expect.any(Object),
previous_month: expect.any(Object),
previous_year: expect.any(Object),
comparison: expect.any(Object)
});
});
});
});
Test Utilities
Test Database Setup
Copy
import {drizzle} from 'drizzle-orm/node-postgres';
import {migrate} from 'drizzle-orm/node-postgres/migrator';
import {Client} from 'pg';
import {cars, coe, months} from '../schema';
const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL || 'postgresql://localhost:5432/sgcarstrends_test';
export async function setupTestDatabase() {
const client = new Client({connectionString: TEST_DATABASE_URL});
await client.connect();
const db = drizzle(client);
// Run migrations
await migrate(db, {migrationsFolder: './migrations'});
await client.end();
}
export async function cleanupTestDatabase() {
const client = new Client({connectionString: TEST_DATABASE_URL});
await client.connect();
const db = drizzle(client);
// Clean up test data
await db.delete(cars);
await db.delete(coe);
await db.delete(months);
await client.end();
}
export async function insertTestData() {
const client = new Client({connectionString: TEST_DATABASE_URL});
await client.connect();
const db = drizzle(client);
// Insert test car data
await db.insert(cars).values([
{
month: '2024-01',
make: 'Toyota',
fuel_type: 'Petrol',
vehicle_type: 'Passenger Cars',
number: 150
},
{
month: '2024-01',
make: 'Honda',
fuel_type: 'Hybrid',
vehicle_type: 'Passenger Cars',
number: 100
},
{
month: '2023-12',
make: 'Toyota',
fuel_type: 'Petrol',
vehicle_type: 'Passenger Cars',
number: 120
}
]);
await client.end();
}
Test Client Setup
Copy
import {Hono} from 'hono';
import {testClient as createTestClient} from 'hono/testing';
import {app} from '../index';
// Create test client with authentication
export const testClient = createTestClient(app, {
headers: {
'Authorization': 'Bearer test-token',
'Content-Type': 'application/json'
}
});
// Test client without authentication
export const unauthenticatedClient = createTestClient(app);
Mock Data
Copy
export const mockCarData = [
{
month: '2024-01',
make: 'Toyota',
fuel_type: 'Petrol',
vehicle_type: 'Passenger Cars',
number: 150
},
{
month: '2024-01',
make: 'Honda',
fuel_type: 'Hybrid',
vehicle_type: 'Passenger Cars',
number: 100
}
];
export const mockCoeData = [
{
month: '2024-01',
bidding_no: 1,
vehicle_class: 'Category A',
quota: 1000,
bids_success: 950,
bids_received: 1200,
premium: 85000
}
];
export const mockApiResponse = {
success: true,
data: mockCarData,
total: 2,
page: 1,
limit: 50
};
Testing Strategies
1. Test-Driven Development (TDD)
Write tests before implementing features:Copy
// 1. Write the test first
describe('calculateGrowthRate', () => {
it('should calculate percentage growth', () => {
expect(calculateGrowthRate(120, 100)).toBe(20);
});
it('should handle zero previous value', () => {
expect(calculateGrowthRate(100, 0)).toBe(null);
});
});
// 2. Implement the function
export function calculateGrowthRate(current: number, previous: number): number | null {
if (previous === 0) return null;
return ((current - previous) / previous) * 100;
}
// 3. Run tests to verify implementation
2. Behavior-Driven Development (BDD)
Write tests in a descriptive, behavioral format:Copy
describe('Car registration API', () => {
describe('when user requests cars data', () => {
describe('with valid authentication', () => {
it('should return success response with car data', async () => {
// Given
const validToken = 'valid-api-token';
// When
const response = await testClient
.get('/v1/cars')
.set('Authorization', `Bearer ${validToken}`);
// Then
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toBeInstanceOf(Array);
});
});
describe('without authentication', () => {
it('should return unauthorized error', async () => {
// Given
const noAuthRequest = testClient.get('/v1/cars');
// When
const response = await noAuthRequest.unset('Authorization');
// Then
expect(response.status).toBe(401);
expect(response.body.success).toBe(false);
});
});
});
});
3. Snapshot Testing
Test complex objects and responses:Copy
import {expect, it} from 'vitest';
it('should match API response structure', async () => {
const response = await testClient.get('/v1/cars?month=2024-01');
expect(response.body).toMatchSnapshot();
});
// Updates snapshots with --update-snapshots flag
// pnpm test -- --update-snapshots
Mocking and Fixtures
External API Mocking
Copy
import {vi} from 'vitest';
export const mockLtaApi = {
downloadFile: vi.fn(),
getCarRegistrations: vi.fn(),
getCoeResults: vi.fn()
};
// Mock implementation
mockLtaApi.downloadFile.mockResolvedValue({
data: 'mocked,csv,data',
checksum: 'mock-checksum'
});
mockLtaApi.getCarRegistrations.mockResolvedValue([
{
month: '2024-01',
make: 'Toyota',
fuel_type: 'Petrol',
vehicle_type: 'Passenger Cars',
number: 150
}
]);
Database Mocking
Copy
import {vi} from 'vitest';
export const mockDb = {
select: vi.fn(),
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn()
};
// Mock implementations
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([])
})
});
mockDb.insert.mockReturnValue({
values: vi.fn().mockResolvedValue({insertedId: 1})
});
Performance Testing
Load Testing
Copy
import {describe, it, expect} from 'vitest';
import {testClient} from '../client';
describe('Load Testing', () => {
it('should handle concurrent requests', async () => {
const concurrentRequests = 50;
const startTime = Date.now();
const promises = Array.from({length: concurrentRequests}, () =>
testClient.get('/v1/cars')
);
const responses = await Promise.all(promises);
const endTime = Date.now();
// All requests should succeed
responses.forEach(response => {
expect(response.status).toBe(200);
});
// Should complete within reasonable time
const duration = endTime - startTime;
expect(duration).toBeLessThan(5000); // 5 seconds
});
});
Memory Usage Testing
Copy
import {describe, it, expect} from 'vitest';
import {testClient} from '../client';
describe('Memory Usage', () => {
it('should not leak memory on repeated requests', async () => {
const initialMemory = process.memoryUsage().heapUsed;
// Make many requests
for (let i = 0; i < 100; i++) {
await testClient.get('/v1/cars');
}
// Force garbage collection
if (global.gc) {
global.gc();
}
const finalMemory = process.memoryUsage().heapUsed;
const memoryIncrease = finalMemory - initialMemory;
// Memory increase should be reasonable
expect(memoryIncrease).toBeLessThan(50 * 1024 * 1024); // 50MB
});
});
Error Testing
Error Handling
Copy
import {describe, it, expect} from 'vitest';
import {testClient} from './client';
describe('Error Handling', () => {
it('should handle database connection errors', async () => {
// Mock database failure
vi.mocked(db.select).mockRejectedValue(new Error('Database connection failed'));
const response = await testClient.get('/v1/cars');
expect(response.status).toBe(500);
expect(response.body.error).toContain('Internal server error');
});
it('should handle invalid query parameters', async () => {
const response = await testClient.get('/v1/cars?month=invalid-date');
expect(response.status).toBe(400);
expect(response.body.error).toContain('Invalid month format');
});
it('should handle rate limiting', async () => {
// Make many requests quickly
const promises = Array.from({length: 200}, () =>
testClient.get('/v1/cars')
);
const responses = await Promise.all(promises);
// Some requests should be rate limited
const rateLimitedResponses = responses.filter(r => r.status === 429);
expect(rateLimitedResponses.length).toBeGreaterThan(0);
});
});
Test Coverage
Coverage Configuration
Copy
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
thresholds: {
statements: 80,
branches: 70,
functions: 80,
lines: 80
},
exclude: [
'node_modules/**',
'dist/**',
'coverage/**',
'**/*.d.ts',
'**/*.test.{ts,js}',
'**/__tests__/**',
'**/test/**'
]
}
}
});
Coverage Reports
Copy
pnpm test:coverage
Coverage Targets
File Type | Target Coverage |
---|---|
Utilities | 90%+ |
API Routes | 85%+ |
Database Queries | 80%+ |
Workflow Logic | 75%+ |
Configuration | 60%+ |
Continuous Integration
GitHub Actions
Copy
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:14
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: sgcarstrends_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install
- name: Run tests
run: pnpm test:coverage
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/sgcarstrends_test
REDIS_URL: redis://localhost:6379
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
file: ./coverage/coverage-final.json
Best Practices
1. Test Organization
Descriptive Names
Use clear, descriptive test names that explain what’s being tested
Single Responsibility
Each test should focus on one specific behavior or scenario
Arrange-Act-Assert
Structure tests with clear setup, execution, and verification phases
Independent Tests
Tests should not depend on each other and can run in any order
2. Test Data Management
Copy
// Use factory functions for consistent test data
export function createTestCar(overrides: Partial<Car> = {}): Car {
return {
month: '2024-01',
make: 'Toyota',
fuel_type: 'Petrol',
vehicle_type: 'Passenger Cars',
number: 100,
...overrides
};
}
// Use in tests
const testCar = createTestCar({make: 'Honda', number: 150});
3. Async Testing
Copy
// Use async/await for promises
it('should fetch data asynchronously', async () => {
const data = await fetchCarData('2024-01');
expect(data).toBeDefined();
});
// Handle rejections
it('should handle errors gracefully', async () => {
await expect(fetchCarData('invalid')).rejects.toThrow('Invalid month');
});
// Timeout for long operations
it('should complete within timeout', async () => {
await expect(longRunningOperation()).resolves.toBeDefined();
}, 10000); // 10 second timeout
Debugging Tests
Debug Configuration
Copy
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Tests",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/node_modules/vitest/vitest.mjs",
"args": ["run", "--reporter=verbose"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
}
]
}
Debug Utilities
Copy
import {vi} from 'vitest';
// Log function calls
const mockFn = vi.fn();
mockFn.mockImplementation((...args) => {
console.log('Mock called with:', args);
return 'result';
});
// Debug test data
it('should debug test data', () => {
const data = getTestData();
console.log('Test data:', JSON.stringify(data, null, 2));
expect(data).toBeDefined();
});