Overview
The service layer contains the core business logic of your application, making it critical to test thoroughly. Service tests focus on:- Business Logic Validation - Ensure operations work correctly
- Authorization Testing - Verify RBAC permissions are enforced
- Integration Testing - Test service dependencies and interactions
- Error Handling - Validate proper error responses
Service Layer Focus: Test business rules, not implementation details. Services should work correctly regardless of how the underlying database or external APIs function.
Testing Architecture
Service tests follow a 3-layer mocking strategy that isolates business logic:Layer Isolation Pattern:Benefits:
// 1. Mock Repository Layer (Data Access)
vi.mock('@/db/repositories/user-repository', () => ({
getUserByIdDao: vi.fn(),
createUserSettingsDao: vi.fn(),
updateUserSettingsDao: vi.fn(),
}))
// 2. Mock External Services
vi.mock('@/services/facades/subscription-service-facade', () => ({
getActivePlansForBetterAuthService: vi.fn(),
}))
// 3. Mock Authentication
import { setupAuthUserMocked } from './helper-service-test'- Fast Execution - No database or network calls
- Predictable Results - Controlled test data
- Focused Testing - Only tests business logic
- Independent Tests - No external dependencies
Core Testing Patterns
Essential patterns used across all service tests:Test Setup Pattern:Mock Helper Utility:
// src/services/__tests__/user-service.test.ts
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { setupAuthUserMocked } from './helper-service-test'
import * as userRepository from '@/db/repositories/user-repository'
import { getUserByIdService } from '../user-service'
const userTest = {
id: 'ae760f8e-4aa6-4d71-a4c8-344429b7ae21',
name: 'Test User',
email: 'test@example.com',
role: RoleConst.USER,
emailVerified: true,
visibility: 'private',
createdAt: new Date(),
updatedAt: new Date(),
} satisfies User
describe('User Service Tests', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(userRepository.getUserByIdDao).mockResolvedValue(userTest)
})
})// src/services/__tests__/helper-service-test.ts
export const setupAuthUserMocked = async (user?: User | undefined) => {
return vi.mocked(getAuthUser).mockImplementation(async () => {
if (!user) return
return user
})
}CASL Authorization Testing
Testing role-based access control with CASL abilities:Basic Permission Tests:Guest User Testing:
// src/services/authorization/__tests__/casl-authorization-service.test.ts
import { userCan, userCannot, defineAbilitiesFor } from '../authorization-service'
import { ActionsConst, SubjectsConst } from '../casl-abilities'
describe('CASL Abilities', () => {
const regularUser = {
id: 'user-123',
role: RoleConst.USER,
// ... other properties
}
const adminUser = {
id: 'admin-123',
role: RoleConst.ADMIN,
// ... other properties
}
describe('User Permissions', () => {
it('regular user can read subscriptions', () => {
expect(
userCan(regularUser, ActionsConst.READ, SubjectsConst.SUBSCRIPTION)
).toBe(true)
})
it('regular user cannot delete users', () => {
expect(
userCan(regularUser, ActionsConst.DELETE, SubjectsConst.USER)
).toBe(false)
expect(
userCannot(regularUser, ActionsConst.DELETE, SubjectsConst.USER)
).toBe(true)
})
})
describe('Admin Permissions', () => {
it('admin can manage all users', () => {
expect(
userCan(adminUser, ActionsConst.MANAGE, SubjectsConst.USER)
).toBe(true)
})
it('admin can perform all actions on subscriptions', () => {
expect(userCan(adminUser, ActionsConst.CREATE, SubjectsConst.SUBSCRIPTION)).toBe(true)
expect(userCan(adminUser, ActionsConst.READ, SubjectsConst.SUBSCRIPTION)).toBe(true)
expect(userCan(adminUser, ActionsConst.UPDATE, SubjectsConst.SUBSCRIPTION)).toBe(true)
expect(userCan(adminUser, ActionsConst.DELETE, SubjectsConst.SUBSCRIPTION)).toBe(true)
})
})
})describe('Guest (Unauthenticated) Permissions', () => {
const guestUser = undefined
it('can read public user profiles', () => {
const publicProfile = { id: 'user-123', visibility: 'public' }
expect(
userCanOnResource(guestUser, ActionsConst.READ, SubjectsConst.USER, publicProfile)
).toBe(true)
})
it('cannot read private user profiles', () => {
const privateProfile = { id: 'user-123', visibility: 'private' }
expect(
userCanOnResource(guestUser, ActionsConst.READ, SubjectsConst.USER, privateProfile)
).toBe(false)
})
it('cannot create users', () => {
expect(userCan(guestUser, ActionsConst.CREATE, SubjectsConst.USER)).toBe(false)
})
})Integration Testing Patterns
Testing service interactions and external dependencies:Service-to-Service Testing:Facade Integration Testing:
describe('Service Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should integrate user and subscription services', async () => {
// Mock subscription service facade
vi.mocked(getActivePlansForBetterAuthService).mockResolvedValue([
{ id: 'plan-1', name: 'Basic Plan', price: 999 }
])
// Test user service calling subscription service
setupAuthUserMocked(userTest)
const userWithSubscriptions = await getUserWithSubscriptionsService(userTest.id)
expect(userWithSubscriptions.subscriptions).toHaveLength(1)
expect(getActivePlansForBetterAuthService).toHaveBeenCalledWith(userTest.id)
})
it('should handle service dependency failures', async () => {
// Mock service failure
vi.mocked(getActivePlansForBetterAuthService).mockRejectedValue(
new Error('Subscription service unavailable')
)
setupAuthUserMocked(userTest)
// Should handle gracefully or propagate appropriately
await expect(
getUserWithSubscriptionsService(userTest.id)
).rejects.toThrow('Subscription service unavailable')
})
})// Test service facades work correctly
describe('Service Facades', () => {
it('should expose simplified interface to app layer', async () => {
setupAuthUserMocked(userTest)
vi.mocked(userRepository.getUserByIdDao).mockResolvedValue(userTest)
// Import facade function
const { getUserById } = await import('@/services/facades/user-service-facade')
const result = await getUserById(userTest.id)
expect(result).toEqual(userTest)
})
it('should handle facade errors consistently', async () => {
setupAuthUserMocked(userTest)
vi.mocked(userRepository.getUserByIdDao).mockRejectedValue(
new Error('Database error')
)
const { getUserById } = await import('@/services/facades/user-service-facade')
await expect(getUserById(userTest.id)).rejects.toThrow('Database error')
})
})Running Service Tests
Essential commands for service testing workflow:Daily Testing Workflow:Interactive Development:
# Run all service tests
pnpm test services/
pnpm test src/services/__tests__/
# Run specific service tests
pnpm test user-service
pnpm test authorization
pnpm test src/services/__tests__/user-service.test.ts
# Watch mode for active development
pnpm test:watch services/
pnpm test:watch src/services/__tests__/user-service.test.ts
# Run with coverage
pnpm test:coverage services/# Open Vitest UI for service tests
pnpm test:ui --root src/services
# Run tests matching pattern
pnpm test --grep="Authorization"
pnpm test --grep="CRUD Operations"
pnpm test --grep="\\[ADMIN\\]" # Test admin role specificallyBest Practices
Service testing guidelines for maintainable, reliable tests:Service Testing Philosophy: Test business logic behavior, not implementation details. Services should work correctly regardless of underlying technology changes.
Key Principles
1. Isolation & Mocking- Mock all external dependencies (repositories, APIs, services)
- Use consistent mocking patterns across tests
- Clear mocks between tests to prevent pollution
- Test authorization before business logic
- Cover all role combinations and permission scenarios
- Use realistic test data that matches production constraints
- Test both success and failure scenarios
- Verify error types and messages are appropriate
- Ensure graceful degradation when external services fail
- Test domain rules and constraints
- Validate input/output transformations
- Cover edge cases and boundary conditions
- Use test data factories for consistency
- Match production data patterns and constraints
- Include edge cases in test data scenarios