Instruction file imported from jtrimm007/schedupay-mono (
.github/instructions/backend-testing.instructions.md). Copyright stays with the author.
Backend API Testing Best Practices (TypeScript/Node.js)
Testing Framework Setup
Jest Configuration
- Use Jest as the primary testing framework
- Configure TypeScript support with ts-jest
- Set up proper test environments for unit and integration tests
- Use separate Jest configs for different test types
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.test.ts', '**/*.spec.ts'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/index.ts',
'!src/config/**'
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
},
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts']
};
Unit Testing Patterns
Service Layer Testing
- Test business logic in isolation
- Mock all external dependencies (Plaid, database, Redis)
- Use dependency injection for testability
- Test both success and error scenarios
// Good: Service unit test
describe('SpendingService', () => {
let spendingService: SpendingService;
let mockPlaidService: jest.Mocked<PlaidService>;
let mockSpendingRepo: jest.Mocked<SpendingRepository>;
let mockCacheService: jest.Mocked<CacheService>;
beforeEach(() => {
mockPlaidService = {
getTransactions: jest.fn(),
getAccounts: jest.fn(),
handleWebhook: jest.fn(),
};
mockSpendingRepo = {
findByUserAndMonth: jest.fn(),
create: jest.fn(),
update: jest.fn(),
};
mockCacheService = {
get: jest.fn(),
set: jest.fn(),
del: jest.fn(),
};
spendingService = new SpendingService(
mockSpendingRepo,
mockPlaidService,
mockCacheService
);
});
describe('calculateMonthlySpending', () => {
it('should calculate spending correctly excluding internal transfers', async () => {
// Arrange
const userId = 'user-123';
const month = new Date('2025-08-01');
const mockTransactions = [
TransactionFactory.createSpending({ amount: 50.00 }),
TransactionFactory.createSpending({ amount: 25.50 }),
TransactionFactory.createTransfer({ amount: 100.00 }), // Should be excluded
];
mockPlaidService.getTransactions.mockResolvedValue({
success: true,
data: mockTransactions
});
// Act
const result = await spendingService.calculateMonthlySpending(userId, month);
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.totalSpent).toEqual(new Decimal('75.50'));
expect(result.data.transactionCount).toBe(2);
}
expect(mockPlaidService.getTransactions).toHaveBeenCalledWith(userId, month);
});
it('should handle Plaid service errors gracefully', async () => {
// Arrange
const userId = 'user-123';
const month = new Date('2025-08-01');
const plaidError = new PlaidError('ITEM_LOGIN_REQUIRED', 'item-123');
mockPlaidService.getTransactions.mockRejectedValue(plaidError);
// Act
const result = await spendingService.calculateMonthlySpending(userId, month);
// Assert
expect(result.success).toBe(false);
expect(result.error).toBeInstanceOf(PlaidError);
expect(result.error?.code).toBe('ITEM_LOGIN_REQUIRED');
});
it('should cache calculated results', async () => {
// Test caching behavior
const userId = 'user-123';
const month = new Date('2025-08-01');
const cacheKey = `spending:${userId}:2025-08`;
mockCacheService.get.mockResolvedValue(null);
mockPlaidService.getTransactions.mockResolvedValue({
success: true,
data: [TransactionFactory.createSpending({ amount: 100 })]
});
await spendingService.calculateMonthlySpending(userId, month);
expect(mockCacheService.set).toHaveBeenCalledWith(
cacheKey,
expect.any(Object),
300 // 5 minutes
);
});
});
});
Repository Layer Testing
- Test database operations with Prisma
- Use test database for isolation
- Test constraints and relationships
- Verify data encryption/decryption
// Good: Repository testing with test database
describe('UserRepository', () => {
let userRepo: UserRepository;
let prisma: PrismaClient;
beforeAll(async () => {
prisma = new PrismaClient({
datasources: { db: { url: process.env.TEST_DATABASE_URL } }
});
userRepo = new UserRepository(prisma);
});
beforeEach(async () => {
await prisma.user.deleteMany();
await prisma.plaidItem.deleteMany();
});
afterAll(async () => {
await prisma.$disconnect();
});
it('should create user with encrypted Plaid token', async () => {
// Arrange
const userData = {
email: 'test@example.com',
plaidAccessToken: 'access-production-token'
};
// Act
const user = await userRepo.createWithPlaidItem(userData);
// Assert
expect(user.email).toBe(userData.email);
expect(user.plaidItems[0].accessToken).not.toBe(userData.plaidAccessToken);
expect(user.plaidItems[0].accessToken).toMatch(/^encrypted:/);
});
it('should enforce email uniqueness constraint', async () => {
// Arrange
const email = 'duplicate@example.com';
await userRepo.create({ email });
// Act & Assert
await expect(
userRepo.create({ email })
).rejects.toThrow('Unique constraint failed');
});
});
Integration Testing with Supertest
HTTP Endpoint Testing
- Test complete request/response cycles
- Verify authentication and authorization
- Test input validation and error handling
- Use realistic test data
// Good: API integration testing
describe('POST /api/v1/spending/calculate', () => {
let app: Express.Application;
let testDb: TestDatabase;
let testUser: User;
let authToken: string;
beforeAll(async () => {
app = await createTestApp();
testDb = new TestDatabase();
await testDb.initialize();
});
beforeEach(async () => {
await testDb.reset();
testUser = await testDb.createUser({
email: 'test@example.com'
});
authToken = generateTestJWT(testUser.id);
});
afterAll(async () => {
await testDb.cleanup();
});
it('should calculate and return monthly spending for authenticated user', async () => {
// Arrange
await testDb.createPlaidItem(testUser.id, {
accessToken: 'encrypted:test-token',
institutionName: 'Test Bank'
});
// Mock Plaid service for this test
jest.spyOn(PlaidService.prototype, 'getTransactions').mockResolvedValue({
success: true,
data: [
TransactionFactory.createSpending({ amount: 150.75 }),
TransactionFactory.createSpending({ amount: 45.25, pending: true })
]
});
// Act
const response = await request(app)
.post('/api/v1/spending/calculate')
.set('Authorization', `Bearer ${authToken}`)
.send({
month: '2025-08-01',
includesPending: true
})
.expect(200);
// Assert
expect(response.body).toMatchObject({
totalSpent: '196.00',
postedAmount: '150.75',
pendingAmount: '45.25',
lastCalculated: expect.any(String),
transactionCount: 2
});
// Verify database was updated
const storedSpending = await testDb.findMonthlySpending(testUser.id, new Date('2025-08-01'));
expect(storedSpending?.totalSpent.toString()).toBe('196.00');
});
it('should return 401 for requests without valid token', async () => {
const response = await request(app)
.post('/api/v1/spending/calculate')
.send({ month: '2025-08-01' })
.expect(401);
expect(response.body.error).toBe('Authentication required');
});
it('should validate request body and return 400 for invalid input', async () => {
const testCases = [
{ body: {}, expectedError: 'month is required' },
{ body: { month: 'invalid-date' }, expectedError: 'Invalid date format' },
{ body: { month: '2025-08-01', includesPending: 'not-boolean' }, expectedError: 'includesPending must be boolean' }
];
for (const testCase of testCases) {
const response = await request(app)
.post('/api/v1/spending/calculate')
.set('Authorization', `Bearer ${authToken}`)
.send(testCase.body)
.expect(400);
expect(response.body.error).toContain(testCase.expectedError);
}
});
it('should handle Plaid errors and return appropriate HTTP status', async () => {
// Arrange - Mock Plaid service to return an error
jest.spyOn(PlaidService.prototype, 'getTransactions').mockRejectedValue(
new PlaidError('ITEM_LOGIN_REQUIRED', 'item-123')
);
await testDb.createPlaidItem(testUser.id);
// Act
const response = await request(app)
.post('/api/v1/spending/calculate')
.set('Authorization', `Bearer ${authToken}`)
.send({ month: '2025-08-01' })
.expect(422); // Unprocessable Entity for Plaid errors
// Assert
expect(response.body).toMatchObject({
error: 'Bank connection requires re-authentication',
code: 'ITEM_LOGIN_REQUIRED',
itemId: 'item-123'
});
});
});
Webhook Testing
- Test webhook signature validation
- Test webhook payload processing
- Test idempotency
- Test error handling and retry logic
// Good: Webhook testing
describe('POST /webhooks/plaid', () => {
let app: Express.Application;
let testDb: TestDatabase;
beforeAll(async () => {
app = await createTestApp();
testDb = new TestDatabase();
await testDb.initialize();
});
it('should process transaction webhook with valid signature', async () => {
// Arrange
const webhookPayload = {
webhook_type: 'TRANSACTIONS',
webhook_code: 'DEFAULT_UPDATE',
item_id: 'item-123',
new_transactions: 5
};
const signature = generatePlaidWebhookSignature(webhookPayload);
// Act
const response = await request(app)
.post('/webhooks/plaid')
.set('Plaid-Verification', signature)
.send(webhookPayload)
.expect(200);
// Assert
expect(response.body.status).toBe('processed');
// Verify webhook was processed (check database, queue, etc.)
const processedWebhook = await testDb.findWebhookLog(webhookPayload.item_id);
expect(processedWebhook).toBeTruthy();
});
it('should reject webhook with invalid signature', async () => {
const webhookPayload = { webhook_type: 'TRANSACTIONS' };
await request(app)
.post('/webhooks/plaid')
.set('Plaid-Verification', 'invalid-signature')
.send(webhookPayload)
.expect(401);
});
});
Test Data Management
Test Factories
- Create consistent, realistic test data
- Support different scenarios and edge cases
- Use libraries like Faker.js for realistic data generation
// Good: Comprehensive test factories
export class UserFactory {
static create(overrides: Partial<User> = {}): User {
return {
id: faker.string.uuid(),
email: faker.internet.email().toLowerCase(),
createdAt: new Date(),
updatedAt: new Date(),
lastLogin: faker.datatype.boolean() ? faker.date.recent() : null,
...overrides
};
}
static createWithPlaidItem(
userOverrides: Partial<User> = {},
plaidOverrides: Partial<PlaidItem> = {}
): User & { plaidItems: PlaidItem[] } {
const user = this.create(userOverrides);
return {
...user,
plaidItems: [PlaidItemFactory.create({ userId: user.id, ...plaidOverrides })]
};
}
}
export class TransactionFactory {
static createSpending(overrides: Partial<Transaction> = {}): Transaction {
return {
id: faker.string.uuid(),
plaidTransactionId: `tx_${faker.string.alphanumeric(10)}`,
amount: parseFloat(faker.finance.amount({ min: 1, max: 500, dec: 2 })),
name: faker.company.name(),
date: faker.date.recent({ days: 30 }),
category: ['Food and Drink', 'Restaurants'],
isSpending: true,
isRefund: false,
isInternalTransfer: false,
pending: faker.datatype.boolean({ probability: 0.2 }),
...overrides
};
}
static createTransfer(overrides: Partial<Transaction> = {}): Transaction {
return this.createSpending({
isSpending: false,
isInternalTransfer: true,
category: ['Transfer', 'Internal'],
...overrides
});
}
static createRefund(overrides: Partial<Transaction> = {}): Transaction {
return this.createSpending({
isRefund: true,
amount: -Math.abs(overrides.amount || 50),
name: `Refund: ${faker.company.name()}`,
...overrides
});
}
}
Database Test Utilities
- Provide utilities for database setup and cleanup
- Implement test data seeding
- Support test isolation
// Good: Database test utilities
export class TestDatabase {
private prisma: PrismaClient;
constructor() {
this.prisma = new PrismaClient({
datasources: { db: { url: process.env.TEST_DATABASE_URL } }
});
}
async initialize(): Promise<void> {
// Run migrations, seed initial data if needed
await this.prisma.$executeRaw`TRUNCATE TABLE users CASCADE`;
}
async reset(): Promise<void> {
// Reset all tables while preserving schema
const tablenames = await this.prisma.$queryRaw<Array<{ tablename: string }>>`
SELECT tablename FROM pg_tables WHERE schemaname='public'
`;
for (const { tablename } of tablenames) {
if (tablename !== '_prisma_migrations') {
await this.prisma.$executeRawUnsafe(`TRUNCATE TABLE "${tablename}" CASCADE;`);
}
}
}
async cleanup(): Promise<void> {
await this.prisma.$disconnect();
}
// Helper methods for creating test data
async createUser(data: Partial<User> = {}): Promise<User> {
return this.prisma.user.create({
data: UserFactory.create(data)
});
}
async createPlaidItem(userId: string, data: Partial<PlaidItem> = {}): Promise<PlaidItem> {
return this.prisma.plaidItem.create({
data: PlaidItemFactory.create({ userId, ...data })
});
}
}
Mocking Strategies
External Service Mocking
- Mock Plaid API responses consistently
- Use MSW for HTTP-level mocking when needed
- Create realistic mock responses
// Good: Plaid service mocking
export const createMockPlaidService = (): jest.Mocked<PlaidService> => ({
getTransactions: jest.fn().mockResolvedValue({
success: true,
data: [TransactionFactory.createSpending()]
}),
createLinkToken: jest.fn().mockResolvedValue({
success: true,
data: { link_token: 'link-token-123' }
}),
exchangePublicToken: jest.fn().mockResolvedValue({
success: true,
data: {
access_token: 'access-token-123',
item_id: 'item-123'
}
}),
getAccounts: jest.fn(),
handleWebhook: jest.fn(),
});
// Realistic Plaid API responses
export const mockPlaidResponses = {
transactions: {
transactions: [
{
transaction_id: 'tx_123',
amount: 25.50,
date: '2025-08-15',
name: 'Starbucks Coffee',
merchant_name: 'Starbucks',
category: ['Food and Drink', 'Restaurants', 'Coffee'],
category_id: '13005043',
pending: false,
account_id: 'acc_123'
}
],
accounts: [
{
account_id: 'acc_123',
name: 'Plaid Checking',
type: 'depository',
subtype: 'checking'
}
],
total_transactions: 1
}
};
Performance and Load Testing
API Performance Testing
- Test response times under load
- Verify database query performance
- Test concurrent request handling
// Good: Performance testing patterns
describe('Performance Tests', () => {
it('should handle concurrent spending calculations', async () => {
const userId = 'user-123';
const month = new Date('2025-08-01');
// Create multiple concurrent requests
const promises = Array.from({ length: 10 }, () =>
spendingService.calculateMonthlySpending(userId, month)
);
const startTime = Date.now();
const results = await Promise.all(promises);
const endTime = Date.now();
// Verify all requests succeeded
results.forEach(result => {
expect(result.success).toBe(true);
});
// Verify reasonable response time
expect(endTime - startTime).toBeLessThan(5000); // 5 seconds
});
});
Security Testing
Authentication Testing
- Test JWT token validation
- Test token expiration
- Test role-based access control
// Good: Security testing
describe('Authentication Middleware', () => {
it('should reject expired tokens', async () => {
const expiredToken = generateExpiredJWT('user-123');
const response = await request(app)
.get('/api/v1/user/profile')
.set('Authorization', `Bearer ${expiredToken}`)
.expect(401);
expect(response.body.error).toBe('Token expired');
});
it('should reject malformed tokens', async () => {
await request(app)
.get('/api/v1/user/profile')
.set('Authorization', 'Bearer invalid.token.here')
.expect(401);
});
});