ADR-004: Contract-Based Testing
Status
Accepted
Context
78 modules follow the same Entity-Repository-Service pattern with consistent behavior:
- All entities serialize via
toJSON() - All repositories support CRUD operations
- All services wrap entities and handle transactions
- Some modules support optional capabilities (soft delete, activation, caching)
Testing Challenges:
- Repetitive test code across modules
- Inconsistent test coverage (some modules well-tested, others minimal)
- No guarantee that new modules meet base contracts
- Changes to base classes not consistently reflected in tests
- Difficult to ensure all modules handle edge cases
Example of Repetitive Tests:
Every module needs similar tests:
// accounts/__tests__/entity.test.js
test('toJSON returns plain object', () => { ... });
test('hides sensitive fields', () => { ... });
// users/__tests__/entity.test.js
test('toJSON returns plain object', () => { ... }); // Duplicate
test('hides sensitive fields', () => { ... }); // Duplicate
// ... repeated 78 times
This violates DRY principle and creates maintenance burden.
Decision
Implement reusable test contracts that validate standard behavior.
Contract Structure
Three test contracts corresponding to SOA layers:
Entity Contract (
src/__tests__/contracts/entity.contract.js)- Validates entity instantiation
- Tests
toJSON()serialization - Verifies hidden fields filtered
- Checks relation materialization
Repository Contract (
src/__tests__/contracts/repository.contract.js)- Validates CRUD operations
- Tests pagination (offset-based)
- Verifies entity wrapping
- Checks transaction support
Service Contract (
src/__tests__/contracts/service.contract.js)- Validates service initialization
- Tests CRUD via service methods
- Checks optional capabilities (soft delete, activation)
- Verifies transaction propagation
Usage
Module tests invoke contracts with module-specific configuration:
// src/accounts/__tests__/service.test.js
const { initAccountService } = require('../index');
const serviceContract = require('../../__tests__/contracts/service.contract');
const AccountEntity = require('../AccountEntity');
const seedOne = () => ({
user_id: 1,
account_type_id: 2,
label: `account-${Math.random().toString(36).slice(2, 8)}`,
balance: 0
});
serviceContract({
name: 'Account',
initService: initAccountService,
Entity: AccountEntity,
seedOne: seedOne,
supportsSoftDelete: false,
supportsActivation: false,
whereForUnique: (data) => ({ user_id: data.user_id, label: data.label })
});
This generates ~30 tests automatically.
Capability Detection
Contracts detect capabilities at runtime:
const service = initService();
const supports = {
softDelete: typeof service.softDelete === 'function',
activate: typeof service.activate === 'function',
cache: typeof service.clearCache === 'function'
};
if (supports.softDelete) {
test('soft delete marks record deleted', async () => { ... });
} else {
test.skip('soft delete not supported');
}
This ensures tests only run for implemented capabilities, avoiding false failures.
Custom Tests
Contracts cover common behavior. Module-specific tests added separately:
// After contract tests
describe('Account-specific tests', () => {
test('calculates balance correctly', async () => { ... });
test('validates account type exists', async () => { ... });
});
Consequences
Positive
Reduced Code Duplication:
- 200+ lines of tests reduced to ~40 lines per module
- Common behavior tested once in contract
- Updates to contract propagate to all modules
Consistency:
- All modules guaranteed to meet base contracts
- New modules follow same testing patterns
- Edge cases handled uniformly
Maintainability:
- Contract changes update all module tests
- Easy to add new common behaviors
- Clear separation: contract tests vs. custom tests
Quality Assurance:
- Guaranteed baseline test coverage
- No modules skip essential tests
- Capability detection prevents invalid tests
AI-Assisted Development:
- Clear pattern for testing new modules
- AI can generate module tests using contract template
- Focused on module-specific logic, not boilerplate
Documentation:
- Contracts document expected behavior
- New developers see what modules must support
- Self-documenting via test names
Negative
Contract Changes Affect All Modules:
- Breaking change in contract breaks all module tests
- Must ensure contract changes are valid for all modules
- Requires careful consideration before modifying
Custom Behavior Requires Custom Tests:
- Contracts don't cover module-specific logic
- Still need to write tests for unique features
- Can't rely on contracts alone
Runtime Capability Detection:
- Can't statically analyze which tests will run
- Must run tests to see which capabilities supported
- TypeScript wouldn't help (runtime detection needed)
Abstraction Overhead:
- Must understand contract implementation
- Debugging failures may require reading contract code
- Learning curve for new developers
Alternatives Considered
Copy-Paste Test Suites
Approach: Copy test file from one module to another, modify as needed.
Rejected Because:
- Updates don't propagate (each module has copy)
- Inconsistent coverage (easy to skip tests)
- Maintenance nightmare (change in 78 places)
- Easy to forget edge cases
Abstract Base Test Classes
Approach: Create EntityTestBase class, extend per module.
Rejected Because:
- Jest doesn't support class-based tests well
- Harder to pass module-specific configuration
- Inheritance issues with test lifecycle (beforeEach, afterEach)
- Less flexible than functional approach
Test Generators
Approach: Write script that generates test files from templates.
Rejected Because:
- Generated files clutter repository
- Harder to customize generated tests
- Still have maintenance burden (regenerate when template changes)
- Contract functions achieve same result without generation step
Implementation Notes
Contract Locations
src/__tests__/contracts/
├── entity.contract.js # Entity layer tests
├── repository.contract.js # Repository layer tests
└── service.contract.js # Service layer tests
Required Module Configuration
Entity Contract:
{
name: 'Account', // Module name
Entity: AccountEntity, // Entity class
model: DB.accounts, // Objection.js model
seedOne: () => ({ ... }), // Factory function
requiredFields: ['user_id'], // Fields that can't be null
relatedEntities: [] // Expected relations
}
Repository Contract:
{
name: 'Account',
Repository: AccountRepository,
Entity: AccountEntity,
seedOne: () => ({ ... }),
whereForUnique: (data) => ({ user_id: data.user_id })
}
Service Contract:
{
name: 'Account',
initService: initAccountService,
Entity: AccountEntity,
seedOne: () => ({ ... }),
supportsSoftDelete: false,
supportsActivation: false,
whereForUnique: (data) => ({ user_id: data.user_id })
}
Seed Functions
Seed functions generate unique test data:
const seedOne = () => ({
user_id: 1,
label: `account-${Math.random().toString(36).slice(2, 8)}`, // Unique
balance: 0
});
Randomized fields prevent test conflicts from parallel execution.
Integration with Real Database
Contracts use real PostgreSQL database (not mocks):
const DB = require('@vectoricons.net/db');
beforeAll(async () => {
await DB.knex.raw('BEGIN'); // Start transaction
});
afterAll(async () => {
await DB.knex.raw('ROLLBACK'); // Rollback all changes
});
This ensures tests validate actual database behavior, including:
- Foreign key constraints
- Unique constraints
- Database triggers
- Query performance
Related Decisions
- ADR-001: Service-Oriented Architecture - Defines layers tested by contracts
- ADR-002: Mixin Pattern - Capability detection tests mixins
- Test Strategy - Integration testing approach
References
- Contract implementations:
src/__tests__/contracts/ - Example usage:
src/accounts/__tests__/service.test.js - Test strategy:
docs/design/TEST-STRATEGY.md - Test contracts guide:
docs/design/TEST-CONTRACTS.md
Date: 2025-10-17 Author: Scott Lewis