Comprehensive testing documentation for the Safe CLI project.
- Overview
- Testing Stack
- Test Organization
- Running Tests
- Writing Tests
- Test Patterns
- Test Fixtures
- Mocking Strategies
- Coverage Guidelines
- Best Practices
- Common Pitfalls
The Safe CLI uses a comprehensive testing strategy with the goal of achieving 85%+ code coverage across all components. Tests are organized into three layers following the test pyramid:
- Unit Tests (70%): Fast, isolated tests for individual functions and classes
- Integration Tests (25%): Tests for component interactions and workflows
- E2E Tests (5%): Full system tests simulating real user scenarios
Overall Project: 6.7% (351 tests)
├─ ValidationService: 94.02% (180 tests) ✅
├─ Utility Layer: 97.83% (171 tests) ✅
├─ Services: 17.39% (pending)
├─ Commands: 0% (pending)
├─ Storage: 0% (pending)
└─ UI: 0% (pending)
Target: 85% overall coverage (1000+ tests)
- Test Runner: Vitest v2.1.9
- Coverage: v8 provider
- Mocking: Vitest's built-in
vimock utilities - Assertions: Vitest expect API (Jest-compatible)
- Test Data: @faker-js/faker for generating realistic test data
See vitest.config.ts:
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/**',
'dist/**',
'src/tests/**',
'**/*.config.ts',
],
thresholds: {
lines: 85,
functions: 85,
branches: 85,
statements: 85,
},
},
},
})src/tests/
├── fixtures/ # Reusable test data
│ ├── addresses.ts # Ethereum addresses and keys
│ ├── chains.ts # Chain configurations
│ ├── abis.ts # Smart contract ABIs
│ ├── transactions.ts # Transaction examples
│ └── index.ts # Barrel exports
├── helpers/ # Test utilities
│ ├── factories.ts # Mock factories
│ └── index.ts
├── unit/ # Unit tests (70%)
│ ├── services/
│ │ └── validation-service.test.ts
│ └── utils/
│ ├── validation.test.ts
│ ├── ethereum.test.ts
│ ├── eip3770.test.ts
│ └── errors.test.ts
├── integration/ # Integration tests (25%)
│ ├── safe-creation.test.ts
│ ├── transaction-flow.test.ts
│ ├── wallet-management.test.ts
│ └── config-management.test.ts
└── e2e/ # E2E tests (5%)
└── (future)
- Test files:
*.test.ts(e.g.,validation-service.test.ts) - Test suites: Match source file name
- Test descriptions: Clear, descriptive, action-oriented
Good:
describe('ValidationService', () => {
describe('validateAddress', () => {
it('should accept valid checksummed addresses', () => {
// ...
})
})
})Bad:
describe('test', () => {
it('works', () => {
// ...
})
})# Run all tests
npm test
# Run specific test file
npm test -- src/tests/unit/services/validation-service.test.ts
# Run tests matching pattern
npm test -- validation
# Run with coverage
npm test -- --coverage
# Run in watch mode
npm test -- --watch
# Run in UI mode
npm test -- --ui# Run all unit tests
npm test -- src/tests/unit
# Run all integration tests
npm test -- src/tests/integration
# Run utility tests only
npm test -- src/tests/unit/utils
# Run service tests only
npm test -- src/tests/unit/services# Generate coverage report
npm test -- --coverage
# Open HTML coverage report
open coverage/index.htmlimport { describe, it, expect, beforeEach } from 'vitest'
import { MyService } from '../../../services/my-service.js'
import { TEST_DATA } from '../../fixtures/index.js'
describe('MyService', () => {
let service: MyService
beforeEach(() => {
service = new MyService()
})
describe('myMethod', () => {
it('should handle valid input', () => {
const result = service.myMethod(TEST_DATA.validInput)
expect(result).toBe(expectedOutput)
})
it('should throw for invalid input', () => {
expect(() => service.myMethod(TEST_DATA.invalidInput))
.toThrow('Expected error message')
})
})
})Organize tests into three categories:
- Valid Cases: Test expected behavior with valid inputs
- Invalid Cases: Test error handling with invalid inputs
- Edge Cases: Test boundary conditions and special cases
describe('myFunction', () => {
describe('valid cases', () => {
it('should handle typical input', () => { /* ... */ })
it('should handle maximum value', () => { /* ... */ })
})
describe('invalid cases', () => {
it('should reject empty input', () => { /* ... */ })
it('should reject malformed input', () => { /* ... */ })
})
describe('edge cases', () => {
it('should handle null input', () => { /* ... */ })
it('should handle undefined input', () => { /* ... */ })
})
})For services that provide both validate*() and assert*() methods:
// validate*() returns error message or undefined
describe('validateAddress', () => {
it('should return undefined for valid address', () => {
const error = service.validateAddress(validAddress)
expect(error).toBeUndefined()
})
it('should return error message for invalid address', () => {
const error = service.validateAddress(invalidAddress)
expect(error).toBe('Invalid Ethereum address')
})
})
// assert*() throws ValidationError
describe('assertAddress', () => {
it('should not throw for valid address', () => {
expect(() => service.assertAddress(validAddress)).not.toThrow()
})
it('should throw ValidationError for invalid address', () => {
expect(() => service.assertAddress(invalidAddress))
.toThrow(ValidationError)
})
it('should include field name in error message', () => {
expect(() => service.assertAddress(invalidAddress, 'Owner Address'))
.toThrow('Owner Address: Invalid Ethereum address')
})
})Test values at and around boundaries:
describe('validateThreshold', () => {
it('should accept value at minimum (1)', () => {
expect(service.validateThreshold('1', 1, 5)).toBeUndefined()
})
it('should accept value at maximum (5)', () => {
expect(service.validateThreshold('5', 1, 5)).toBeUndefined()
})
it('should reject value below minimum (0)', () => {
expect(service.validateThreshold('0', 1, 5)).toBe('Threshold must be at least 1')
})
it('should reject value above maximum (6)', () => {
expect(service.validateThreshold('6', 1, 5)).toBe('Threshold must be at most 5')
})
})Verify that conversions are reversible:
describe('formatEther and parseEther round-trip', () => {
it('should round-trip 1 ETH', () => {
const original = BigInt('1000000000000000000')
const formatted = formatEther(original)
const parsed = parseEther(formatted)
expect(parsed).toBe(original)
})
})Test error class hierarchies:
describe('error inheritance', () => {
it('should maintain correct inheritance for ValidationError', () => {
const error = new ValidationError('Test')
expect(error instanceof ValidationError).toBe(true)
expect(error instanceof SafeCLIError).toBe(true)
expect(error instanceof Error).toBe(true)
})
it('should allow catching SafeCLIError for all custom errors', () => {
try {
throw new ValidationError('Test')
} catch (e) {
expect(e).toBeInstanceOf(SafeCLIError)
}
})
})Test functions that call process.exit() or console methods:
describe('handleError', () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>
let processExitSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called')
})
})
afterEach(() => {
consoleErrorSpy.mockRestore()
processExitSpy.mockRestore()
})
it('should call process.exit(1)', () => {
expect(() => handleError(new Error('Test'))).toThrow('process.exit called')
expect(consoleErrorSpy).toHaveBeenCalledWith('Unexpected error: Test')
expect(processExitSpy).toHaveBeenCalledWith(1)
})
})Always use fixtures instead of hardcoding test data:
Good:
import { TEST_ADDRESSES } from '../../fixtures/index.js'
it('should validate address', () => {
expect(isValidAddress(TEST_ADDRESSES.owner1)).toBe(true)
})Bad:
it('should validate address', () => {
expect(isValidAddress('0x1234...')).toBe(true) // Don't hardcode
})TEST_ADDRESSES = {
owner1: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
owner2: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8',
safe1: '0x1234567890123456789012345678901234567890',
zeroAddress: '0x0000000000000000000000000000000000000000',
invalidShort: '0x123',
noPrefix: 'f39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
}
TEST_PRIVATE_KEYS = {
owner1: '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
noPrefix: 'ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
tooShort: '0xabc123',
invalid: '0xnothexadecimal',
}TEST_CHAINS = {
ethereum: { chainId: '1', name: 'Ethereum Mainnet', shortName: 'eth', ... },
sepolia: { chainId: '11155111', name: 'Sepolia', shortName: 'sep', ... },
polygon: { chainId: '137', name: 'Polygon', shortName: 'matic', ... },
// ... 8 total chain configs
}ERC20_ABI = [ /* Standard ERC20 ABI */ ]
TEST_CONTRACT_ABI = [ /* Test contract ABI */ ]
PROXY_ABI = [ /* Proxy contract ABI */ ]Use factory functions from helpers/factories.ts:
import { createMockPublicClient, createMockSafeSDK } from '../../helpers/factories.js'
describe('MyService', () => {
it('should use mocked client', () => {
const mockClient = createMockPublicClient({
getBalance: vi.fn().mockResolvedValue(BigInt('1000000000000000000'))
})
const service = new MyService(mockClient)
// Test using mocked client
})
})Mocks Viem PublicClient for RPC calls:
const client = createMockPublicClient({
getBalance: vi.fn().mockResolvedValue(BigInt('1000000')),
getCode: vi.fn().mockResolvedValue('0x123456'),
})Mocks Viem WalletClient for transactions:
const wallet = createMockWalletClient({
sendTransaction: vi.fn().mockResolvedValue('0xtxhash'),
signMessage: vi.fn().mockResolvedValue('0xsignature'),
})Mocks Safe Protocol Kit:
const safeSDK = createMockSafeSDK({
getAddress: vi.fn().mockResolvedValue('0xsafe'),
getOwners: vi.fn().mockResolvedValue(['0xowner1', '0xowner2']),
})- Overall Project: 85% minimum
- Critical Components: 100% target (ValidationService, security-critical code)
- Services Layer: 90% minimum
- Utility Layer: 95% minimum
- Commands: 85% minimum
- UI/CLI: 70% minimum (harder to test, focus on business logic)
# Run tests with coverage
npm test -- --coverage
# Check specific file coverage
npm test -- src/services/validation-service.ts --coverage
# Generate HTML report
npm test -- --coverage
open coverage/index.htmlSome code is acceptable to leave uncovered:
- Singleton getters - Factory functions that return instances
- Edge case error handling - Catch blocks for internal library errors
- Process exits - Some
process.exit()paths in CLI code - UI rendering - Complex UI interactions (test business logic instead)
- Type guards that TypeScript enforces - Runtime checks for compile-time safety
// Singleton pattern - difficult to test, low risk
let instance: ValidationService | null = null
export function getValidationService(): ValidationService {
if (!instance) {
instance = new ValidationService()
}
return instance
} // ← Lines 380-385 uncovered (OK)
// Edge case error handling - difficult to trigger
try {
return getAddress(address)
} catch (error) {
throw new Error(
`Invalid address checksum: ${error instanceof Error ? error.message : 'Unknown error'}`
) // ← Lines 25-28 uncovered (OK)
}Good:
it('should format 1 ETH correctly', () => {
const result = formatEther(BigInt('1000000000000000000'))
expect(result).toBe('1.0000')
})Bad:
it('should divide by 1e18 and call toFixed(4)', () => {
const spy = vi.spyOn(Number.prototype, 'toFixed')
formatEther(BigInt('1000000000000000000'))
expect(spy).toHaveBeenCalledWith(4) // Testing implementation details
})Test names should clearly describe what is being tested:
Good:
it('should return checksummed address for lowercase input', () => { /* ... */ })
it('should throw ValidationError for address without 0x prefix', () => { /* ... */ })Bad:
it('works', () => { /* ... */ })
it('test 1', () => { /* ... */ })Focus each test on a single behavior:
Good:
it('should return checksummed address', () => {
const result = validateAndChecksumAddress(lowercase)
expect(result).toBe(checksummed)
})
it('should throw for invalid address', () => {
expect(() => validateAndChecksumAddress(invalid)).toThrow()
})Acceptable:
it('should return checksummed address for valid input', () => {
const result = validateAndChecksumAddress(lowercase)
expect(result).toBe(checksummed)
expect(result.startsWith('0x')).toBe(true) // Related assertion
})Always test edge cases:
describe('edge cases', () => {
it('should handle empty string', () => { /* ... */ })
it('should handle null', () => { /* ... */ })
it('should handle undefined', () => { /* ... */ })
it('should handle very large values', () => { /* ... */ })
it('should handle negative values', () => { /* ... */ })
})Keep tests DRY with setup hooks:
describe('ValidationService', () => {
let service: ValidationService
beforeEach(() => {
service = new ValidationService()
})
it('test 1', () => {
// service is available
})
it('test 2', () => {
// fresh service instance for each test
})
})Always restore mocks and spies:
describe('with mocks', () => {
let spy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
spy = vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
spy.mockRestore() // Important!
})
it('test', () => {
// test using spy
})
})Problem: Hardcoded addresses break if address format changes
// ❌ Bad
expect(isValidAddress('0x123')).toBe(false)Solution: Use fixtures
// ✅ Good
expect(isValidAddress(TEST_ADDRESSES.invalidShort)).toBe(false)Problem: Tests break when refactoring
// ❌ Bad
expect(service['privateMethod']).toHaveBeenCalled()Solution: Test public API
// ✅ Good
expect(service.publicMethod()).toBe(expectedResult)Problem: Tests pass/fail randomly due to timing, randomness, or shared state
Solutions:
- Avoid
setTimeoutandsetInterval - Mock
Date.now()for time-dependent tests - Use
vi.useFakeTimers()for time control - Ensure tests are isolated (no shared state)
Problem: Test completes before async operations finish
// ❌ Bad
it('should fetch data', () => {
service.fetchData() // Missing await
expect(service.data).toBeDefined() // Fails
})Solution: Always await async operations
// ✅ Good
it('should fetch data', async () => {
await service.fetchData()
expect(service.data).toBeDefined()
})Problem: Mocks leak between tests
// ❌ Bad
it('test', () => {
vi.spyOn(process, 'exit').mockImplementation()
// No restore!
})Solution: Always restore in afterEach
// ✅ Good
afterEach(() => {
vi.restoreAllMocks()
})Problem: Uppercase addresses fail validation
// ❌ This will fail!
expect(isValidAddress('0xABCDEF...')).toBe(true) // Uppercase fails EIP-55 checksumSolution: Use checksummed or lowercase addresses
// ✅ Good
expect(isValidAddress('0xabcdef...')).toBe(true) // Lowercase OK
expect(isValidAddress(checksumAddress('0xabcdef...'))).toBe(true) // Checksummed OKReason: Viem strictly validates EIP-55 checksums. Uppercase addresses have invalid checksums.
Problem: Expected parseInt to reject decimals
// ❌ This assumption is wrong
expect(isValidChainId('1.5')).toBe(false) // Actually returns true!Reason: parseInt('1.5', 10) returns 1 (not NaN)
Solution: Document this behavior or add explicit decimal check
// ✅ Document the behavior
it('should accept decimal strings (parseInt truncates)', () => {
expect(isValidChainId('1.5')).toBe(true)
})- Vitest Documentation
- Testing Best Practices
- TESTING_PLAN.md - Comprehensive testing strategy
- TESTING_ROADMAP.md - Implementation roadmap
- Phase 1 Completion Documents - Progress tracking
If you encounter issues or have questions about testing:
- Check this guide first
- Review existing test files for examples
- Check the Vitest documentation
- Create an issue in the repository
Last Updated: 2025-10-26 Phase 1 Status: Complete (351 tests, 95%+ coverage for tested components)