| name | DDD Testing Standards |
| description | 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". |
| allowed-tools | Read, Write, Edit, Glob, Grep, Bash |
DDD 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
Tests 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.