Claude-skill-registry DDD Testing Standards
Standards de tests exhaustifs pour les bounded contexts DDD (Domain, Application, Integration). À utiliser lors de l'écriture de tests backend, tests unitaires, tests d'intégration, ou quand l'utilisateur mentionne "test", "TDD", "coverage", "unit test", "integration test", "test domain", "test handler".
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/ddd-testing" ~/.claude/skills/majiayu000-claude-skill-registry-ddd-testing-standards && rm -rf "$T"
skills/data/ddd-testing/SKILL.mdDDD Testing Standards
🎯 Mission
Créer des tests exhaustifs pour les bounded contexts DDD suivant une approche TDD (Test-Driven Development) avec des standards de coverage stricts.
🏆 Philosophie Critique
Dans DDD, les tests sont NON-NÉGOCIABLES.
La logique métier dans le Domain Layer DOIT être testée à 100% avant toute autre implémentation. Les tests sont la documentation vivante de votre logique métier.
Pourquoi TDD en DDD ?
- Logique métier fiable : Le Domain contient les règles critiques de l'application
- Refactoring sécurisé : Tests exhaustifs permettent de refactorer sans casser
- Documentation : Tests décrivent le comportement attendu
- Confidence : Déploiement en production sans peur
- Régression : Prévenir la réintroduction de bugs
📁 Structure des Tests
bounded-context/ ├── tests/ │ ├── unit/ │ │ ├── domain/ │ │ │ ├── entities/ │ │ │ │ ├── subscription.entity.spec.ts │ │ │ │ └── club.entity.spec.ts │ │ │ ├── value-objects/ │ │ │ │ ├── subscription-plan.vo.spec.ts │ │ │ │ └── club-name.vo.spec.ts │ │ │ └── services/ │ │ │ └── subscription-limit.service.spec.ts │ │ └── application/ │ │ ├── commands/ │ │ │ ├── create-club.handler.spec.ts │ │ │ └── subscribe-to-plan.handler.spec.ts │ │ └── queries/ │ │ ├── get-club.handler.spec.ts │ │ └── list-clubs.handler.spec.ts │ └── integration/ │ └── handlers/ │ ├── create-club.integration.spec.ts │ └── subscribe-to-plan.integration.spec.ts
🧪 1. Domain Layer Tests (MANDATORY - 100% Coverage)
Entities Tests
Objectif : Tester TOUTE la logique métier encapsulée dans les entités
Ce qui DOIT être testé :
- ✅ Tous les business methods
- ✅ Toutes les validation rules
- ✅ Toutes les state transitions
- ✅ Tous les edge cases et boundary conditions
- ✅ Tous les invariants
- ✅ Tous les factory methods
Template Entity Test
// tests/unit/domain/entities/subscription.entity.spec.ts import { Subscription } from '../../../../domain/entities/subscription.entity'; import { SubscriptionPlan } from '../../../../domain/value-objects/subscription-plan.vo'; import { SubscriptionStatus } from '../../../../domain/value-objects/subscription-status.vo'; describe('Subscription Entity', () => { describe('Factory Method - create()', () => { it('should create a new subscription with default values', () => { // Arrange const clubId = 'club-123'; const plan = SubscriptionPlan.FREE; // Act const subscription = Subscription.create(clubId, plan); // Assert expect(subscription.getClubId()).toBe(clubId); expect(subscription.getPlan()).toBe(plan); expect(subscription.isActive()).toBe(true); expect(subscription.getCurrentTeamsCount()).toBe(0); }); it('should throw error when clubId is empty', () => { // Arrange const clubId = ''; const plan = SubscriptionPlan.FREE; // Act & Assert expect(() => Subscription.create(clubId, plan)).toThrow('Club ID is required'); }); }); describe('Business Method - canCreateTeam()', () => { it('should return true when subscription is active and limit not reached', () => { // Arrange const subscription = Subscription.create('club-123', SubscriptionPlan.FREE); // Act const result = subscription.canCreateTeam(); // Assert expect(result).toBe(true); }); it('should return false when subscription is inactive', () => { // Arrange const subscription = Subscription.create('club-123', SubscriptionPlan.FREE); subscription.deactivate(); // State transition // Act const result = subscription.canCreateTeam(); // Assert expect(result).toBe(false); }); it('should return false when team limit is reached', () => { // Arrange const subscription = Subscription.create('club-123', SubscriptionPlan.FREE); subscription.incrementTeamsCount(); // 1/1 team for FREE plan // Act const result = subscription.canCreateTeam(); // Assert expect(result).toBe(false); }); it('should return true when plan has unlimited teams', () => { // Arrange const subscription = Subscription.create('club-123', SubscriptionPlan.UNLIMITED); // Simulate many teams for (let i = 0; i < 100; i++) { subscription.incrementTeamsCount(); } // Act const result = subscription.canCreateTeam(); // Assert expect(result).toBe(true); }); it('should handle null teams count gracefully', () => { // Arrange const subscription = Subscription.create('club-123', SubscriptionPlan.FREE); // Act const result = subscription.canCreateTeam(); // Assert expect(result).toBe(true); }); }); describe('Business Method - upgrade()', () => { it('should upgrade from FREE to PRO successfully', () => { // Arrange const subscription = Subscription.create('club-123', SubscriptionPlan.FREE); // Act subscription.upgrade(SubscriptionPlan.PRO); // Assert expect(subscription.getPlan()).toBe(SubscriptionPlan.PRO); }); it('should upgrade from PRO to UNLIMITED successfully', () => { // Arrange const subscription = Subscription.create('club-123', SubscriptionPlan.PRO); // Act subscription.upgrade(SubscriptionPlan.UNLIMITED); // Assert expect(subscription.getPlan()).toBe(SubscriptionPlan.UNLIMITED); }); it('should throw error when trying to downgrade', () => { // Arrange const subscription = Subscription.create('club-123', SubscriptionPlan.PRO); // Act & Assert expect(() => subscription.upgrade(SubscriptionPlan.FREE)) .toThrow('Cannot downgrade subscription'); }); it('should throw error when upgrading to same plan', () => { // Arrange const subscription = Subscription.create('club-123', SubscriptionPlan.PRO); // Act & Assert expect(() => subscription.upgrade(SubscriptionPlan.PRO)) .toThrow('Already on this plan'); }); }); describe('State Transition - incrementTeamsCount()', () => { it('should increment teams count when limit not reached', () => { // Arrange const subscription = Subscription.create('club-123', SubscriptionPlan.PRO); const initialCount = subscription.getCurrentTeamsCount(); // Act subscription.incrementTeamsCount(); // Assert expect(subscription.getCurrentTeamsCount()).toBe(initialCount + 1); }); it('should throw error when limit is reached', () => { // Arrange const subscription = Subscription.create('club-123', SubscriptionPlan.FREE); subscription.incrementTeamsCount(); // Reach limit (1/1) // Act & Assert expect(() => subscription.incrementTeamsCount()) .toThrow('Team limit reached for current plan'); }); it('should allow unlimited increments for UNLIMITED plan', () => { // Arrange const subscription = Subscription.create('club-123', SubscriptionPlan.UNLIMITED); // Act - Increment 1000 times for (let i = 0; i < 1000; i++) { subscription.incrementTeamsCount(); } // Assert expect(subscription.getCurrentTeamsCount()).toBe(1000); }); }); describe('Edge Cases', () => { it('should handle negative teams count validation', () => { // Arrange & Act & Assert expect(() => new Subscription( 'id-123', 'club-123', SubscriptionPlan.FREE, SubscriptionStatus.ACTIVE, new Date(), null, -1, // Negative count )).toThrow('Teams count cannot be negative'); }); it('should handle null plan', () => { // Arrange & Act & Assert expect(() => new Subscription( 'id-123', 'club-123', null as any, SubscriptionStatus.ACTIVE, new Date(), null, 0, )).toThrow('Plan is required'); }); }); });
Value Objects Tests
// tests/unit/domain/value-objects/subscription-plan.vo.spec.ts import { SubscriptionPlan } from '../../../../domain/value-objects/subscription-plan.vo'; describe('SubscriptionPlan Value Object', () => { describe('Creation', () => { it('should create FREE plan', () => { // Act const plan = SubscriptionPlan.FREE; // Assert expect(plan.toString()).toBe('FREE'); expect(plan.getMaxTeams()).toBe(1); }); it('should throw error for invalid plan name', () => { // Act & Assert expect(() => SubscriptionPlan.fromString('INVALID')) .toThrow('Invalid plan: INVALID'); }); }); describe('hasTeamLimit()', () => { it('should return true for FREE plan', () => { expect(SubscriptionPlan.FREE.hasTeamLimit()).toBe(true); }); it('should return false for UNLIMITED plan', () => { expect(SubscriptionPlan.UNLIMITED.hasTeamLimit()).toBe(false); }); }); describe('isUpgradeFrom()', () => { it('should return true when upgrading from FREE to PRO', () => { expect(SubscriptionPlan.PRO.isUpgradeFrom(SubscriptionPlan.FREE)).toBe(true); }); it('should return false when downgrading from PRO to FREE', () => { expect(SubscriptionPlan.FREE.isUpgradeFrom(SubscriptionPlan.PRO)).toBe(false); }); it('should return false for same plan', () => { expect(SubscriptionPlan.PRO.isUpgradeFrom(SubscriptionPlan.PRO)).toBe(false); }); }); describe('Immutability', () => { it('should be immutable (same instance for same value)', () => { const plan1 = SubscriptionPlan.FREE; const plan2 = SubscriptionPlan.FREE; expect(plan1).toBe(plan2); }); }); });
Domain Services Tests
// tests/unit/domain/services/subscription-limit.service.spec.ts import { SubscriptionLimitService } from '../../../../domain/services/subscription-limit.service'; import { Subscription } from '../../../../domain/entities/subscription.entity'; import { SubscriptionPlan } from '../../../../domain/value-objects/subscription-plan.vo'; describe('SubscriptionLimitService', () => { let service: SubscriptionLimitService; beforeEach(() => { service = new SubscriptionLimitService(); }); describe('canAddTeam()', () => { it('should return true when subscription allows new team', () => { // Arrange const subscription = Subscription.create('club-123', SubscriptionPlan.FREE); // Act const result = service.canAddTeam(subscription); // Assert expect(result).toBe(true); }); it('should return false when team limit is reached', () => { // Arrange const subscription = Subscription.create('club-123', SubscriptionPlan.FREE); subscription.incrementTeamsCount(); // Reach limit // Act const result = service.canAddTeam(subscription); // Assert expect(result).toBe(false); }); }); describe('getRemainingSlots()', () => { it('should return correct remaining slots for PRO plan', () => { // Arrange const subscription = Subscription.create('club-123', SubscriptionPlan.PRO); subscription.incrementTeamsCount(); // 1/3 teams // Act const remaining = service.getRemainingSlots(subscription); // Assert expect(remaining).toBe(2); }); it('should return Infinity for UNLIMITED plan', () => { // Arrange const subscription = Subscription.create('club-123', SubscriptionPlan.UNLIMITED); // Act const remaining = service.getRemainingSlots(subscription); // Assert expect(remaining).toBe(Infinity); }); }); });
🔧 2. Application Layer Tests (MANDATORY - 95%+ Coverage)
Command Handler Tests
Objectif : Tester l'orchestration des entités domain par les handlers
Ce qui DOIT être testé :
- ✅ Successful execution path
- ✅ All validation errors
- ✅ Domain exceptions handling
- ✅ Repository methods are called correctly
- ✅ Return values (IDs)
Template Command Handler Test
// tests/unit/application/commands/create-club.handler.spec.ts import { CreateClubHandler } from '../../../../application/commands/create-club/create-club.handler'; import { CreateClubCommand } from '../../../../application/commands/create-club/create-club.command'; import { IClubRepository } from '../../../../domain/repositories/club.repository.interface'; import { Club } from '../../../../domain/entities/club.entity'; describe('CreateClubHandler', () => { let handler: CreateClubHandler; let mockClubRepository: jest.Mocked<IClubRepository>; beforeEach(() => { // Mock repository mockClubRepository = { create: jest.fn(), findById: jest.fn(), findByUserId: jest.fn(), update: jest.fn(), delete: jest.fn(), } as jest.Mocked<IClubRepository>; handler = new CreateClubHandler(mockClubRepository); }); describe('execute()', () => { it('should create club successfully with valid data', async () => { // Arrange const command = new CreateClubCommand( 'Volley Club Paris', 'Best club in Paris', 'user-123', ); const mockClub = Club.create( command.name, command.description, command.userId, ); mockClubRepository.create.mockResolvedValue(mockClub); // Act const result = await handler.execute(command); // Assert expect(result).toBe(mockClub.getId()); expect(mockClubRepository.create).toHaveBeenCalledTimes(1); expect(mockClubRepository.create).toHaveBeenCalledWith( expect.objectContaining({ getName: expect.any(Function), getDescription: expect.any(Function), }), ); }); it('should throw error when name is empty', async () => { // Arrange const command = new CreateClubCommand( '', // Empty name 'Description', 'user-123', ); // Act & Assert await expect(handler.execute(command)) .rejects .toThrow('Club name cannot be empty'); }); it('should throw error when userId is missing', async () => { // Arrange const command = new CreateClubCommand( 'Volley Club', 'Description', '', // Empty userId ); // Act & Assert await expect(handler.execute(command)) .rejects .toThrow('User ID is required'); }); it('should call repository.create() with correct club entity', async () => { // Arrange const command = new CreateClubCommand( 'Volley Club Paris', 'Best club', 'user-123', ); const mockClub = Club.create(command.name, command.description, command.userId); mockClubRepository.create.mockResolvedValue(mockClub); // Act await handler.execute(command); // Assert expect(mockClubRepository.create).toHaveBeenCalledWith( expect.objectContaining({ getName: expect.any(Function), }), ); const callArg = mockClubRepository.create.mock.calls[0][0]; expect(callArg.getName().getValue()).toBe('Volley Club Paris'); }); it('should propagate repository errors', async () => { // Arrange const command = new CreateClubCommand('Club', 'Desc', 'user-123'); mockClubRepository.create.mockRejectedValue(new Error('Database error')); // Act & Assert await expect(handler.execute(command)) .rejects .toThrow('Database error'); }); }); });
Query Handler Tests
// tests/unit/application/queries/get-club.handler.spec.ts import { GetClubHandler } from '../../../../application/queries/get-club/get-club.handler'; import { GetClubQuery } from '../../../../application/queries/get-club/get-club.query'; import { IClubRepository } from '../../../../domain/repositories/club.repository.interface'; import { Club } from '../../../../domain/entities/club.entity'; import { ClubDetailReadModel } from '../../../../application/read-models/club-detail.read-model'; describe('GetClubHandler', () => { let handler: GetClubHandler; let mockClubRepository: jest.Mocked<IClubRepository>; beforeEach(() => { mockClubRepository = { create: jest.fn(), findById: jest.fn(), update: jest.fn(), delete: jest.fn(), } as jest.Mocked<IClubRepository>; handler = new GetClubHandler(mockClubRepository); }); describe('execute()', () => { it('should return club read model when club exists', async () => { // Arrange const query = new GetClubQuery('club-123'); const mockClub = Club.create('Club Paris', 'Description', 'user-123'); mockClubRepository.findById.mockResolvedValue(mockClub); // Act const result = await handler.execute(query); // Assert expect(result).toBeDefined(); expect(result.id).toBe(mockClub.getId()); expect(result.name).toBe('Club Paris'); expect(mockClubRepository.findById).toHaveBeenCalledWith('club-123'); }); it('should throw NotFoundException when club does not exist', async () => { // Arrange const query = new GetClubQuery('non-existent-id'); mockClubRepository.findById.mockResolvedValue(null); // Act & Assert await expect(handler.execute(query)) .rejects .toThrow('Club with ID non-existent-id not found'); }); it('should transform domain entity to read model correctly', async () => { // Arrange const query = new GetClubQuery('club-123'); const mockClub = Club.create('Club Paris', 'Best club', 'user-123'); mockClubRepository.findById.mockResolvedValue(mockClub); // Act const result = await handler.execute(query); // Assert expect(result).toMatchObject({ id: mockClub.getId(), name: 'Club Paris', description: 'Best club', }); }); }); });
🔗 3. Integration Tests (MANDATORY - Critical Workflows)
Handler → Repository → Database Integration
Objectif : Tester le flux complet de bout en bout avec une vraie base de données
Ce qui DOIT être testé :
- ✅ Complete workflows: Handler → Repository → Database
- ✅ Transactions and rollbacks
- ✅ Concurrent operations
- ✅ Real database constraints
Template Integration Test
// tests/integration/handlers/create-club.integration.spec.ts import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import { PrismaService } from '../../../src/prisma/prisma.service'; import { CreateClubHandler } from '../../../src/club-management/application/commands/create-club/create-club.handler'; import { CreateClubCommand } from '../../../src/club-management/application/commands/create-club/create-club.command'; import { ClubRepository } from '../../../src/club-management/infrastructure/persistence/repositories/club.repository'; import { CLUB_REPOSITORY } from '../../../src/club-management/domain/repositories/club.repository.interface'; describe('CreateClubHandler Integration', () => { let app: INestApplication; let prismaService: PrismaService; let handler: CreateClubHandler; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ providers: [ PrismaService, CreateClubHandler, { provide: CLUB_REPOSITORY, useClass: ClubRepository, }, ], }).compile(); app = moduleFixture.createNestApplication(); await app.init(); prismaService = moduleFixture.get<PrismaService>(PrismaService); handler = moduleFixture.get<CreateClubHandler>(CreateClubHandler); }); beforeEach(async () => { // Clean database before each test await prismaService.club.deleteMany({}); }); afterAll(async () => { await app.close(); }); it('should create club in database successfully', async () => { // Arrange const command = new CreateClubCommand( 'Volley Club Integration', 'Integration test club', 'user-integration-123', ); // Act const clubId = await handler.execute(command); // Assert const savedClub = await prismaService.club.findUnique({ where: { id: clubId }, }); expect(savedClub).toBeDefined(); expect(savedClub.name).toBe('Volley Club Integration'); expect(savedClub.description).toBe('Integration test club'); expect(savedClub.ownerId).toBe('user-integration-123'); }); it('should enforce database constraints (unique name per user)', async () => { // Arrange const command1 = new CreateClubCommand('Club Name', 'Desc', 'user-123'); const command2 = new CreateClubCommand('Club Name', 'Desc 2', 'user-123'); // Act await handler.execute(command1); // Assert await expect(handler.execute(command2)) .rejects .toThrow(); // Database unique constraint }); it('should rollback transaction on error', async () => { // Arrange const command = new CreateClubCommand( 'Club Rollback', 'Test rollback', 'invalid-user-id', // Foreign key violation ); // Act & Assert await expect(handler.execute(command)).rejects.toThrow(); // Verify no club was created const clubs = await prismaService.club.findMany({}); expect(clubs).toHaveLength(0); }); });
📊 Coverage Requirements
Exigences STRICTES par couche
- Domain Layer: 100% coverage (TOUS les methods, TOUTES les branches)
- Application Layer: 95%+ coverage
- Integration Tests: TOUS les workflows critiques
Commandes de coverage
# Run tests with coverage cd volley-app-backend yarn test:cov # View coverage report open coverage/lcov-report/index.html
Vérification de la coverage
// jest.config.js - Coverage thresholds { "coverageThreshold": { "global": { "branches": 80, "functions": 80, "lines": 80, "statements": 80 }, "**/domain/**/*.ts": { "branches": 100, "functions": 100, "lines": 100, "statements": 100 } } }
✅ Test Naming Convention
Règle de nommage
describe('[Unit Under Test]', () => { describe('[Method/Feature]', () => { it('should [expected behavior] when [condition]', () => { // Test }); }); });
Exemples
describe('Subscription Entity', () => { describe('canCreateTeam()', () => { it('should return true when subscription is active and limit not reached', () => {}); it('should return false when subscription is inactive', () => {}); it('should return false when team limit is reached', () => {}); }); }); describe('CreateClubHandler', () => { describe('execute()', () => { it('should create club successfully with valid data', () => {}); it('should throw ValidationException when name is missing', () => {}); }); });
🔄 Test Execution Order (TDD Approach)
1. RED Phase (Write failing test)
it('should return true when subscription allows team creation', () => { const subscription = Subscription.create('club-123', SubscriptionPlan.FREE); expect(subscription.canCreateTeam()).toBe(true); // FAILS (method doesn't exist) });
2. GREEN Phase (Implement minimal code to pass)
// domain/entities/subscription.entity.ts canCreateTeam(): boolean { return true; // Minimal implementation }
3. REFACTOR Phase (Improve code while keeping tests green)
canCreateTeam(): boolean { if (!this.isActive()) return false; if (!this.plan.hasTeamLimit()) return true; return this.currentTeamsCount < this.plan.getMaxTeams(); }
Workflow de Développement TDD
- Écrire les tests Domain Layer FIRST (entities, value objects, services)
- Implémenter le Domain Layer pour passer les tests (TDD)
- Écrire les tests Application Layer (handlers)
- Implémenter l'Application Layer
- Écrire les Integration tests
- Implémenter Infrastructure et Presentation layers
🛠️ Outils et Configuration
Jest Configuration
// jest.config.js module.exports = { moduleFileExtensions: ['js', 'json', 'ts'], rootDir: 'src', testRegex: '.*\\.spec\\.ts$', transform: { '^.+\\.(t|j)s$': 'ts-jest', }, collectCoverageFrom: [ '**/*.(t|j)s', '!**/*.spec.ts', '!**/node_modules/**', ], coverageDirectory: '../coverage', testEnvironment: 'node', };
Running Tests
# Run all tests yarn test # Run tests in watch mode yarn test:watch # Run tests with coverage yarn test:cov # Run specific test file yarn test create-club.handler.spec.ts # Run integration tests only yarn test:e2e
🎓 Exemples Concrets du Projet
Bounded Context club-management
club-managementTests existants à consulter :
tests/unit/domain/entities/subscription.entity.spec.tstests/unit/application/commands/create-club.handler.spec.tstests/integration/handlers/subscribe-to-plan.integration.spec.ts
Référence :
volley-app-backend/src/club-management/tests/
🚨 Erreurs Courantes à Éviter
-
❌ Ne pas tester les edge cases
- ✅ FAIRE : Tester null, undefined, limites, valeurs négatives
- ❌ NE PAS FAIRE : Tester uniquement le happy path
-
❌ Tests qui testent l'implémentation au lieu du comportement
- ✅ FAIRE : Tester ce que fait la méthode (behavior)
- ❌ NE PAS FAIRE : Tester comment elle le fait (implementation)
-
❌ Tests qui dépendent d'autres tests
- ✅ FAIRE : Chaque test est indépendant
- ❌ NE PAS FAIRE : Tests qui s'exécutent dans un ordre spécifique
-
❌ Mocks dans les tests Domain Layer
- ✅ FAIRE : Tester les entités pures sans mocks
- ❌ NE PAS FAIRE : Mocker des Value Objects ou Services dans les tests d'entités
-
❌ Ne pas nettoyer la DB dans les tests d'intégration
- ✅ FAIRE :
beforeEach(() => prisma.club.deleteMany()) - ❌ NE PAS FAIRE : Laisser les données s'accumuler
- ✅ FAIRE :
📚 Skills Complémentaires
Pour aller plus loin :
- ddd-bounded-context : Architecture DDD complète
- cqrs-command-query : Patterns CQRS pour Commands/Queries
- testing : Standards généraux de tests (unit, integration, frontend)
Rappel : Les tests sont la documentation vivante de votre logique métier. Un test bien écrit vaut mieux que 100 lignes de commentaires.