Testing Real Services vs. Mocked Services: The Right Way
The Service Testing Dilemma
When testing services in Next.js applications, developers often face a critical decision: should I test the real service or mock it entirely? The answer isn't always obvious, and making the wrong choice can lead to tests that pass but don't actually verify your application works correctly.
Let's explore when to test real services, when to mock them, and how to do it properly.
When to Test Real Services
1. Business Logic Services
Services that contain complex business logic should be tested with their real implementation:
// ✅ DO: Test real business logic service
import UserService from '@/services/UserService';
describe('UserService', () => {
let userService: UserService;
beforeEach(() => {
// Mock the database dependency
jest.mock('@/lib/database', () => ({
query: jest.fn()
}));
userService = new UserService();
});
it('should calculate user permissions correctly', async () => {
// Mock database response
const mockUser = {
id: 1,
role: 'admin',
permissions: ['read', 'write', 'delete']
};
(database.query as jest.Mock).mockResolvedValue([mockUser]);
// Test REAL business logic
const permissions = await userService.getUserPermissions(1);
// Assert the actual business logic works
expect(permissions.canDelete).toBe(true);
expect(permissions.canRead).toBe(true);
expect(permissions.canWrite).toBe(true);
});
});
2. Data Transformation Services
Services that transform or process data should be tested with real logic:
// ✅ DO: Test real data transformation
import DataProcessor from '@/services/DataProcessor';
describe('DataProcessor', () => {
let processor: DataProcessor;
beforeEach(() => {
processor = new DataProcessor();
});
it('should transform API response correctly', () => {
const rawData = {
user_id: 123,
user_name: 'john_doe',
user_email: 'john@example.com',
created_at: '2024-01-01T00:00:00Z'
};
// Test REAL transformation logic
const transformed = processor.transformUserData(rawData);
expect(transformed).toEqual({
id: 123,
name: 'John Doe',
email: 'john@example.com',
createdAt: new Date('2024-01-01T00:00:00Z'),
displayName: 'John Doe'
});
});
});
3. Validation Services
Services that validate data should be tested with real validation logic:
// ✅ DO: Test real validation logic
import ValidationService from '@/services/ValidationService';
describe('ValidationService', () => {
let validator: ValidationService;
beforeEach(() => {
validator = new ValidationService();
});
it('should validate email format correctly', () => {
// Test REAL validation logic
expect(validator.isValidEmail('test@example.com')).toBe(true);
expect(validator.isValidEmail('invalid-email')).toBe(false);
expect(validator.isValidEmail('test@')).toBe(false);
expect(validator.isValidEmail('@example.com')).toBe(false);
});
it('should validate password strength', () => {
// Test REAL password validation
expect(validator.isStrongPassword('Password123!')).toBe(true);
expect(validator.isStrongPassword('weak')).toBe(false);
expect(validator.isStrongPassword('12345678')).toBe(false);
});
});
When to Mock Services
1. External API Services
Services that make HTTP calls to external APIs should be mocked:
// ✅ DO: Mock external API service
import GitLabService from '@/services/GitLabService';
describe('GitLabService', () => {
let gitlabService: GitLabService;
beforeEach(() => {
// Mock fetch globally
global.fetch = jest.fn();
gitlabService = new GitLabService({
baseUrl: 'https://gitlab.com',
token: 'mock-token'
});
});
it('should fetch repository files', async () => {
// Mock the external API response
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: () => Promise.resolve([
{ name: 'test.sql', path: 'sql/test.sql' }
])
});
// Test the service logic with mocked external dependency
const files = await gitlabService.getVersionFiles('MySql', '2024.1');
expect(files).toHaveLength(1);
expect(files[0].name).toBe('test.sql');
});
});
2. Database Services
Services that interact with databases should be mocked:
// ✅ DO: Mock database service
import UserRepository from '@/repositories/UserRepository';
describe('UserRepository', () => {
let userRepo: UserRepository;
let mockDb: jest.Mocked<Database>;
beforeEach(() => {
// Mock the database
mockDb = {
query: jest.fn(),
connect: jest.fn(),
disconnect: jest.fn()
} as any;
userRepo = new UserRepository(mockDb);
});
it('should find user by id', async () => {
// Mock database response
mockDb.query.mockResolvedValue([{ id: 1, name: 'John' }]);
// Test repository logic with mocked database
const user = await userRepo.findById(1);
expect(user).toEqual({ id: 1, name: 'John' });
expect(mockDb.query).toHaveBeenCalledWith(
'SELECT * FROM users WHERE id = ?',
[1]
);
});
});
3. File System Services
Services that interact with the file system should be mocked:
// ✅ DO: Mock file system service
import FileService from '@/services/FileService';
describe('FileService', () => {
let fileService: FileService;
beforeEach(() => {
// Mock fs module
jest.mock('fs/promises', () => ({
readFile: jest.fn(),
writeFile: jest.fn(),
mkdir: jest.fn()
}));
fileService = new FileService();
});
it('should read configuration file', async () => {
// Mock file system response
const mockConfig = { apiKey: 'test-key', baseUrl: 'https://api.example.com' };
(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify(mockConfig));
// Test service logic with mocked file system
const config = await fileService.readConfig('config.json');
expect(config).toEqual(mockConfig);
expect(fs.readFile).toHaveBeenCalledWith('config.json', 'utf8');
});
});
The Hybrid Approach: Partial Mocking
Sometimes you need to test real service logic while mocking specific dependencies:
// ✅ DO: Test real service with mocked dependencies
import EmailService from '@/services/EmailService';
describe('EmailService', () => {
let emailService: EmailService;
let mockTransporter: jest.Mocked<Transporter>;
beforeEach(() => {
// Mock the email transporter
mockTransporter = {
sendMail: jest.fn(),
verify: jest.fn()
} as any;
// Create real service with mocked dependency
emailService = new EmailService(mockTransporter);
});
it('should format email correctly', async () => {
// Mock transporter response
mockTransporter.sendMail.mockResolvedValue({ messageId: 'test-id' });
// Test REAL email formatting logic
const result = await emailService.sendWelcomeEmail({
to: 'user@example.com',
name: 'John Doe'
});
// Verify the real formatting logic worked
expect(mockTransporter.sendMail).toHaveBeenCalledWith({
to: 'user@example.com',
subject: 'Welcome to Our Platform!',
html: expect.stringContaining('Hello John Doe'),
text: expect.stringContaining('Hello John Doe')
});
expect(result.success).toBe(true);
expect(result.messageId).toBe('test-id');
});
});
Testing Service Integration
For complex services that depend on multiple other services, use integration testing:
// ✅ DO: Integration testing with selective mocking
import OrderService from '@/services/OrderService';
import PaymentService from '@/services/PaymentService';
import InventoryService from '@/services/InventoryService';
describe('OrderService Integration', () => {
let orderService: OrderService;
let mockPaymentService: jest.Mocked<PaymentService>;
let mockInventoryService: jest.Mocked<InventoryService>;
beforeEach(() => {
// Mock external services
mockPaymentService = {
processPayment: jest.fn(),
refundPayment: jest.fn()
} as any;
mockInventoryService = {
checkStock: jest.fn(),
updateStock: jest.fn()
} as any;
// Create real order service with mocked dependencies
orderService = new OrderService(mockPaymentService, mockInventoryService);
});
it('should process order successfully', async () => {
// Setup mocks
mockInventoryService.checkStock.mockResolvedValue(true);
mockPaymentService.processPayment.mockResolvedValue({ success: true, transactionId: 'tx-123' });
mockInventoryService.updateStock.mockResolvedValue(true);
// Test REAL order processing logic
const order = {
items: [{ id: 1, quantity: 2 }],
customerId: 123,
total: 100.00
};
const result = await orderService.processOrder(order);
// Verify the real business logic worked
expect(result.success).toBe(true);
expect(result.orderId).toBeDefined();
expect(mockInventoryService.checkStock).toHaveBeenCalledWith(1, 2);
expect(mockPaymentService.processPayment).toHaveBeenCalledWith(100.00);
expect(mockInventoryService.updateStock).toHaveBeenCalledWith(1, -2);
});
});
Common Anti-Patterns to Avoid
❌ DON'T: Mock the Service You're Testing
// ❌ WRONG: Testing the mock, not the real service
jest.mock('@/services/UserService', () => ({
default: jest.fn().mockImplementation(() => ({
getUserPermissions: jest.fn().mockResolvedValue({
canRead: true,
canWrite: false
})
}))
}));
// This tests the mock, not the real UserService
const userService = new UserService();
const permissions = await userService.getUserPermissions(1);
expect(permissions.canRead).toBe(true); // Always passes, but meaningless
❌ DON'T: Mock Internal Business Logic
// ❌ WRONG: Mocking internal logic
jest.mock('@/services/DataProcessor', () => ({
default: jest.fn().mockImplementation(() => ({
transformUserData: jest.fn().mockReturnValue({
id: 123,
name: 'John Doe',
email: 'john@example.com'
})
}))
}));
// This doesn't test the actual transformation logic
const processor = new DataProcessor();
const result = processor.transformUserData(rawData);
expect(result.name).toBe('John Doe'); // Always passes, but tests nothing
❌ DON'T: Over-Mocking Dependencies
// ❌ WRONG: Mocking everything
jest.mock('@/lib/database');
jest.mock('@/lib/logger');
jest.mock('@/lib/cache');
jest.mock('@/lib/validator');
// This creates a service that does nothing real
const service = new UserService();
// All dependencies are mocked, so the service is essentially a mock itself
Best Practices Summary
1. Test Real Logic, Mock Dependencies
// ✅ DO: Test real service with mocked dependencies
const realService = new RealService(mockedDependency);
const result = await realService.doSomething();
expect(result).toBe(expectedValue);
2. Mock at Boundaries
// ✅ DO: Mock external APIs, databases, file systems
global.fetch = jest.fn(); // Mock network
jest.mock('fs/promises'); // Mock file system
jest.mock('@/lib/database'); // Mock database
3. Use Integration Tests for Complex Services
// ✅ DO: Test service interactions
const serviceA = new ServiceA(mockedDependencyA);
const serviceB = new ServiceB(mockedDependencyB);
const result = await serviceA.processWithServiceB(data);
4. Clear Mocks Between Tests
beforeEach(() => {
jest.clearAllMocks();
(global.fetch as jest.Mock).mockClear();
});
The Journey Continues
Understanding when to test real services vs. mocked services is crucial for writing meaningful tests. In the next posts, we'll explore:
- Post 4: Component Testing Patterns for Next.js
- Post 5: Advanced Mocking Strategies and Best Practices
Quick Decision Guide
Test Real Service When:
- [ ] It contains business logic
- [ ] It transforms or processes data
- [ ] It validates data
- [ ] You want to test the actual implementation
Mock Service When:
- [ ] It makes external API calls
- [ ] It interacts with databases
- [ ] It accesses the file system
- [ ] It's a dependency you don't control
Use Hybrid Approach When:
- [ ] Service has real logic but external dependencies
- [ ] You need to test integration between services
- [ ] You want to isolate specific parts of the system
Remember: Test what you own, mock what you don't control.
Next up: We'll dive into component testing patterns specifically designed for Next.js applications.