PHP Intermédiaire

Maîtriser l'Architecture MVC et les Patterns Avancés en PHP Moderne

Apprenez à structurer vos applications PHP avec des patterns professionnels et des architectures scalables. Ce cours combine la théorie des designs patterns avec des cas d'usage réels pour transformer votre code en solutions enterprise.

Preparetoi.academy 30 min

Architecture MVC : Fondamentaux et Implémentation

Définition
L'architecture MVC (Model-View-Controller) est un pattern architectural qui sépare une application en trois couches distinctes : le Model (données et logique métier), la View (présentation), et le Controller (orchestration). Cette séparation des préoccupations permet une meilleure maintenabilité, testabilité et scalabilité.

Explication détaillée
En PHP moderne, l'architecture MVC n'est pas qu'un concept théorique—c'est une nécessité professionnelle. Le Model encapsule toute la logique métier et l'accès aux données, le Controller gère les requêtes HTTP et coordonne Model et View, tandis que la View se concentre exclusivement sur l'affichage. Cette séparation permet à plusieurs développeurs de travailler en parallèle sur différentes couches sans créer de conflits. Dans un environnement professionnel, cette architecture facilite également la création de tests unitaires pour chaque couche indépendamment. Les frameworks modernes comme Laravel et Symfony l'implémentent avec des conventions strictes qui garantissent une cohérence du projet.

Bloc de code

<?php
// Model - Gestion des données
class UserModel {
    private $pdo;
    
    public function __construct(PDO $pdo) {
        $this->pdo = $pdo;
    }
    
    public function getUserById($id) {
        $stmt = $this->pdo->prepare('SELECT * FROM users WHERE id = ?');
        $stmt->execute([$id]);
        return $stmt->fetch(PDO::FETCH_ASSOC);
    }
    
    public function updateUser($id, $data) {
        $stmt = $this->pdo->prepare(
            'UPDATE users SET name = ?, email = ? WHERE id = ?'
        );
        return $stmt->execute([$data['name'], $data['email'], $id]);
    }
}

// Controller - Orchestration
class UserController {
    private $model;
    
    public function __construct(UserModel $model) {
        $this->model = $model;
    }
    
    public function show($id) {
        $user = $this->model->getUserById($id);
        if (!$user) {
            http_response_code(404);
            return;
        }
        return view('user.profile', ['user' => $user]);
    }
    
    public function update($id) {
        $data = $_POST;
        if ($this->model->updateUser($id, $data)) {
            return redirect('/user/' . $id)->with('success', 'Profil mis à jour');
        }
        return back()->with('error', 'Erreur lors de la mise à jour');
    }
}

// View - Présentation (user.profile.php)
?>
<div class="profile-card">
    <h1><?= htmlspecialchars($user['name']) ?></h1>
    <p>Email: <?= htmlspecialchars($user['email']) ?></p>
</div>

Tableau comparatif

Aspect Avantage Inconvénient
Séparation Code organisé et lisible Nécessite discipline
Testabilité Tests unitaires faciles Dépendances à mocker
Maintenance Modifications isolées Courbe d'apprentissage
Scalabilité Évolution sans refonte Overhead initial
Collaboration Travail en parallèle Conventions strictes

Astuce professionnelle
Utilisez l'injection de dépendances dans vos contrôleurs. Cela facilite les tests et rend votre code plus flexible. Ne créez jamais vos dépendances directement à l'intérieur des méthodes—passez-les en constructeur ou via setter.

⚠️ Attention critique
Ne mettez JAMAIS de logique métier directe dans les vues PHP. Les vues doivent uniquement afficher les données reçues du contrôleur. Une vue complexe devient impossible à tester et à maintenir. De même, ne faites pas d'appels base de données directement dans le contrôleur—utilisez toujours une couche Model dédiée.


Repository Pattern et Abstraction des Données

Définition
Le Repository Pattern est un pattern structurel qui crée une abstraction entre la couche métier et la couche d'accès aux données. Il encapsule la logique de requête et expose des méthodes de haut niveau pour manipuler les données, indépendamment de la source (base de données, API externe, fichiers).

Explication détaillée
En pratique professionnelle, le Repository Pattern devient indispensable quand votre application doit supporter plusieurs sources de données ou quand vous devez changer de technologie de base de données. Par exemple, vous pourriez commencer avec MySQL, puis migrer vers PostgreSQL ou MongoDB. Si votre logique métier dépend directement de requêtes SQL spécifiques, cette migration devient cauchemardesque. Le Repository Pattern élimine ce problème en créant une interface commune. Cela rend aussi les tests beaucoup plus simples : vous pouvez créer un faux repository pour tester votre logique métier sans avoir besoin d'une vraie base de données. Les grandes entreprises utilisent systématiquement ce pattern car il leur permet de maintenir des milliers de classes métier sans être bloquées par des décisions techniques d'infrastructure.

Bloc de code

<?php
// Interface - Contrat
interface UserRepositoryInterface {
    public function findById($id);
    public function findByEmail($email);
    public function save(User $user);
    public function delete($id);
    public function getAll();
}

// Implémentation MySQL
class MySQLUserRepository implements UserRepositoryInterface {
    private $pdo;
    
    public function __construct(PDO $pdo) {
        $this->pdo = $pdo;
    }
    
    public function findById($id) {
        $stmt = $this->pdo->prepare('SELECT * FROM users WHERE id = ?');
        $stmt->execute([$id]);
        return $this->hydrate($stmt->fetch(PDO::FETCH_ASSOC));
    }
    
    public function findByEmail($email) {
        $stmt = $this->pdo->prepare('SELECT * FROM users WHERE email = ?');
        $stmt->execute([$email]);
        return $this->hydrate($stmt->fetch(PDO::FETCH_ASSOC));
    }
    
    public function save(User $user) {
        if ($user->getId()) {
            return $this->update($user);
        }
        return $this->insert($user);
    }
    
    private function insert(User $user) {
        $stmt = $this->pdo->prepare(
            'INSERT INTO users (name, email, password) VALUES (?, ?, ?)'
        );
        return $stmt->execute([
            $user->getName(),
            $user->getEmail(),
            password_hash($user->getPassword(), PASSWORD_BCRYPT)
        ]);
    }
    
    private function update(User $user) {
        $stmt = $this->pdo->prepare(
            'UPDATE users SET name = ?, email = ? WHERE id = ?'
        );
        return $stmt->execute([
            $user->getName(),
            $user->getEmail(),
            $user->getId()
        ]);
    }
    
    public function delete($id) {
        $stmt = $this->pdo->prepare('DELETE FROM users WHERE id = ?');
        return $stmt->execute([$id]);
    }
    
    public function getAll() {
        $stmt = $this->pdo->query('SELECT * FROM users ORDER BY created_at DESC');
        return array_map(
            fn($row) => $this->hydrate($row),
            $stmt->fetchAll(PDO::FETCH_ASSOC)
        );
    }
    
    private function hydrate($data) {
        if (!$data) return null;
        $user = new User();
        $user->setId($data['id']);
        $user->setName($data['name']);
        $user->setEmail($data['email']);
        return $user;
    }
}

// Utilisation dans le service
class UserService {
    private $repository;
    
    public function __construct(UserRepositoryInterface $repository) {
        $this->repository = $repository;
    }
    
    public function registerUser($name, $email, $password) {
        $existingUser = $this->repository->findByEmail($email);
        if ($existingUser) {
            throw new Exception('Email déjà utilisé');
        }
        
        $user = new User();
        $user->setName($name);
        $user->setEmail($email);
        $user->setPassword($password);
        
        return $this->repository->save($user);
    }
}

Tableau des sources de données

Source Implémentation Cas d'usage Complexité
MySQL/PostgreSQL SQL direct Production standard Moyenne
MongoDB Requêtes document Data analytics Haute
Redis Cache en mémoire Sessions, cache Basse
API REST HTTP client Services externes Haute
Mock/Stub Retour hardcodé Tests unitaires Très basse

Astuce professionnelle
Créez toujours une interface pour votre repository, même s'il n'existe qu'une seule implémentation. Cela vous prépare à la croissance future et rend votre code beaucoup plus testable. Utilisez l'injection de dépendances avec l'interface, pas l'implémentation concrète.

⚠️ Attention critique
Ne créez pas un repository pour chaque petite requête. Le Repository Pattern ajoute une couche d'abstraction—si vous l'abusez, vous compliqueriez votre code inutilement. Utilisez-le pour les entités principales de votre domaine métier (User, Product, Order, etc.), pas pour les utilitaires simples.


Dependency Injection et Service Container

Définition
L'injection de dépendances (DI) est un principle où les dépendances d'une classe lui sont fournies de l'extérieur plutôt que créées en interne. Un Service Container est un registre centralisé qui crée et gère les instances de classe, résolvant automatiquement leurs dépendances.

Explication détaillée
C'est probablement le pattern le plus important pour du code professionnel maintenable. Imaginez une classe PaymentProcessor qui dépend d'une DatabaseConnection, d'un Logger, d'une MailService, et d'une NotificationService. Créer toutes ces dépendances à l'intérieur de PaymentProcessor rend impossible le test unitaire (vous êtes forcé d'utiliser la vraie base de données, le vrai système de mail, etc.). Avec l'injection de dépendances, vous pouvez fournir des faux objets pour les tests. Le Service Container automatise ce processus en centralisant la logique de création. Laravel et Symfony possèdent des containers très puissants, mais comprendre les principes en créant le vôtre est invaluable. Le container résout aussi les dépendances circulaires et crée les singletons automatiquement.

Bloc de code

<?php
// Service Container simple mais puissant
class ServiceContainer {
    private $bindings = [];
    private $singletons = [];
    
    // Enregistrement d'une classe
    public function bind($abstract, $concrete = null, $singleton = false) {
        if ($concrete === null) {
            $concrete = $abstract;
        }
        
        $this->bindings[$abstract] = [
            'concrete' => $concrete,
            'singleton' => $singleton
        ];
    }
    
    // Enregistrement d'un singleton
    public function singleton($abstract, $concrete = null) {
        $this->bind($abstract, $concrete, true);
    }
    
    // Résolution des dépendances
    public function make($abstract, $parameters = []) {
        if (isset($this->singletons[$abstract])) {
            return $this->singletons[$abstract];
        }
        
        if (!isset($this->bindings[$abstract])) {
            return $this->resolve($abstract, $parameters);
        }
        
        $binding = $this->bindings[$abstract];
        $instance = $this->resolve($binding['concrete'], $parameters);
        
        if ($binding['singleton']) {
            $this->singletons[$abstract] = $instance;
        }
        
        return $instance;
    }
    
    private function resolve($concrete, $parameters = []) {
        // Si c'est une closure, l'exécuter
        if ($concrete instanceof Closure) {
            return $concrete($this);
        }
        
        // Sinon, refléter la classe
        $reflection = new ReflectionClass($concrete);
        $constructor = $reflection->getConstructor();
        
        if ($constructor === null) {
            return new $concrete();
        }
        
        $dependencies = [];
        foreach ($constructor->getParameters() as $param) {
            $type = $param->getType();
            
            if ($type && !$type->isBuiltin()) {
                $className = $type->getName();
                $dependencies[] = $this->make($className);
            } elseif (isset($parameters[$param->getName()])) {
                $dependencies[] = $parameters[$param->getName()];
            } elseif ($param->isDefaultValueAvailable()) {
                $dependencies[] = $param->getDefaultValue();
            }
        }
        
        return new $concrete(...$dependencies);
    }
}

// Exemples d'utilisation
class Logger {
    public function log($message) {
        echo "[LOG] " . $message . "\n";
    }
}

class DatabaseConnection {
    private $logger;
    
    public function __construct(Logger $logger) {
        $this->logger = $logger;
        $this->logger->log("Connexion établie");
    }
}

class PaymentProcessor {
    private $db;
    private $logger;
    
    public function __construct(DatabaseConnection $db, Logger $logger) {
        $this->db = $db;
        $this->logger = $logger;
    }
    
    public function process($amount) {
        $this->logger->log("Traitement du paiement: $amount");
        return true;
    }
}

// Configuration du container
$container = new ServiceContainer();
$container->singleton(Logger::class);
$container->singleton(DatabaseConnection::class);
$container->bind(PaymentProcessor::class);

// Utilisation
$processor = $container->make(PaymentProcessor::class);
$processor->process(99.99);

// Les singletons partagent la même instance
$logger1 = $container->make(Logger::class);
$logger2 = $container->make(Logger::class);
echo $logger1 === $logger2 ? "Même instance" : "Instances différentes";

Tableau des patterns d'enregistrement

Pattern Syntaxe Cas d'usage Performance
Singleton singleton(Class) Services partagés Excellente
Transient bind(Class) Nouvelle instance chaque fois Bonne
Factory bind(Class, function) Logique custom Variable
Alias bind(Interface, Class) Polymorphisme Excellente
Lazy loading Avec Closure Initialisation tardive Très bonne

Astuce professionnelle
Utilisez toujours des interfaces dans vos constructeurs, pas les implémentations concrètes. Cela décuple la flexibilité de votre code. Les frameworks modernes font cela automatiquement via la réflexion et les type hints PHP 7+.

⚠️ Attention critique
Ne surcharger pas votre container avec trop de configurations. Si vous avez plus de 50 bindings, c'est signe que votre architecture a des problèmes. De plus, les containers peuvent créer des dépendances circulaires—soyez vigilant lors de l'enregistrement. Testez toujours que vos dépendances se résolvent correctement en production.


Gestion des Erreurs et Exceptions Personnalisées

Définition
Un système de gestion d'erreurs robuste utilise des exceptions personnalisées structurées en hiérarchie pour capturer différents types d'erreurs métier et techniques. Les exceptions permettent une gestion centralisée et une sémantique claire du code.

Explication détaillée
En PHP, se contenter du try-catch générique est une recette pour le désastre. Les applications professionnelles nécessitent une hiérarchie d'exceptions bien pensée où chaque type d'erreur—authentification échouée, validation invalide, ressource introuvable, erreur système—a sa propre classe. Cela permet au code client de traiter différemment ces erreurs. Par exemple, une ValidationException peut être relancée au client avec un code HTTP 400, tandis qu'une DatabaseException est loggée en silencio et retourne un 500. Laravel et Symfony fournissent des exemples excellents de hiérarchies d'exceptions. La clé est d'avoir une exception de base ApplicationException dont héritent toutes les autres, permettant un catch global si nécessaire, mais avec la possibilité de capturer spécifiquement chaque type.

Bloc de code

<?php
// Hiérarchie d'exceptions
abstract class ApplicationException extends Exception {
    protected $context = [];
    
    public function __construct($message = "", $code = 0, $context = []) {
        parent::__construct($message, $code);
        $this->context = $context;
    }
    
    public function getContext() {
        return $this->context;
    }
    
    public function getHttpStatusCode() {
        return 500;
    }
}

class ValidationException extends ApplicationException {
    public function __construct($message, $errors = []) {
        parent::__construct($message, code: 422, context: ['errors' => $errors]);
    }
    
    public function getHttpStatusCode() {
        return 422;
    }
}

class ResourceNotFoundException extends ApplicationException {
    public function __construct($resource, $id = null) {
        $msg = "$resource non trouvé";
        if ($id) $msg .= " (ID: $id)";
        parent::__construct($msg, code: 404);
    }
    
    public function getHttpStatusCode() {
        return 404;
    }
}

class AuthenticationException extends ApplicationException {
    public function __construct($message = "Non authentifié") {
        parent::__construct($message, code: 401);
    }
    
    public function getHttpStatusCode() {
        return 401;
    }
}

class AuthorizationException extends ApplicationException {
    public function __construct($message = "Accès refusé") {
        parent::__construct($message, code: 403);
    }
    
    public function getHttpStatusCode() {
        return 403;
    }
}

// Handler global
class ExceptionHandler {
    private $logger;
    
    public function __construct(Logger $logger) {
        $this->logger = $logger;
    }
    
    public function handle(Throwable $exception) {
        // Logger tous les détails
        $this->logger->error($exception->getMessage(), [
            'exception' => get_class($exception),
            'file' => $exception->getFile(),
            'line' => $exception->getLine(),
            'trace' => $exception->getTraceAsString(),
            'context' => $exception instanceof ApplicationException 
                ? $exception->getContext() 
                : []
        ]);
        
        // Retourner une réponse HTTP appropriée
        if ($exception instanceof ApplicationException) {
            return $this->jsonResponse(
                $exception->getMessage(),
                $exception->getHttpStatusCode(),
                $exception->getContext()
            );
        }
        
        // Erreur système non gérée
        return $this->jsonResponse(
            'Erreur serveur interne',
            500,
            $this->isDevelopment() ? ['trace' => $exception->getTraceAsString()] : []
        );
    }
    
    private function jsonResponse($message, $code, $data = []) {
        http_response_code($code);
        header('Content-Type: application/json');
        return json_encode([
            'error' => true,
            'message' => $message,
            'code' => $code,
            'data' => $data
        ]);
    }
    
    private function isDevelopment() {
        return getenv('APP_ENV') === 'development';
    }
}

// Utilisation dans un service
class UserService {
    private $repository;
    
    public function __construct(UserRepositoryInterface $repository) {
        $this->repository = $repository;
    }
    
    public function getUserWithValidation($id) {
        if (!is_numeric($id) || $id < 1) {
            throw new ValidationException('ID invalide', ['id' => 'Doit être un nombre positif']);
        }
        
        $user = $this->repository->findById($id);
        if (!$user) {
            throw new ResourceNotFoundException('Utilisateur', $id);
        }
        
        return $user;
    }
    
    public function deleteUser($userId, $requestingUserId) {
        if ($userId !== $requestingUserId && !$this->isAdmin($requestingUserId)) {
            throw new AuthorizationException('Vous ne pouvez supprimer que votre propre compte');
        }
        
        return $this->repository->delete($userId);
    }
    
    private function isAdmin($userId) {
        // Logique d'administration
        return false;
    }
}

// Middleware global
set_exception_handler(function(Throwable $exception) {
    $container = getServiceContainer();
    $handler = $container->make(ExceptionHandler::class);
    echo $handler->handle($exception);
});

Tableau des exceptions et codes HTTP

Exception Code HTTP Usage Handling
ValidationException 422 Données invalides Client-side retry
ResourceNotFoundException 404 Ressource inexistante Afficher 404 page
AuthenticationException 401 Pas d'authentification Redirection login
AuthorizationException 403 Pas d'autorisation Afficher 403 page
ServerException 500 Erreur système Log et alerte

Astuce professionnelle
Toujours logger le contexte complet de l'exception (not juste le message). Inclure les IDs pertinents, les paramètres fournis, et la trace complète. Cela permet de déboguer 100x plus rapidement. Les équipes d'ops apprécient grandement cette diligence.

⚠️ Attention critique
Ne lancez JAMAIS d'exceptions dans les constructeurs sauf si absolument nécessaire—cela rend l'instanciation imprévisible. Préférez valider et lever des exceptions dans les méthodes métier. De plus, ne cachez jamais les exceptions originales avec throw new Exception($e->getMessage()) sans conserver la trace originale via throw new Exception(..., 0, $e).


Testing Professionnel : Unit Tests et Mocks

Définition
Le testing professionnel combine tests unitaires (tests isolés d'une fonction/classe), tests d'intégration (tests entre plusieurs composants) et tests end-to-end. Les mocks et stubs remplacent les dépendances réelles pour des tests rapides et fiables.

Explication détaillée
Une application sans tests est une bombe à retardement. Les frameworks modernes comme PHPUnit permettent de couvrir votre code systématiquement. Un test unitaire doit être rapide (< 100ms), déterministe (toujours le même résultat), et isolé (sans dépendances externes). Les mocks et stubs sont essentiels : un mock remplace une dépendance réelle (base de données, API) par une version contrôlée. Par exemple, tester un PaymentProcessor sans mock impliquerait des vrais appels au réseau—lent et peu fiable. Avec un mock, vous contrôlez exactement ce que la dépendance retourne. Les grandes équipes exigent une couverture de code de 80%+ avant déploiement. PHPUnit avec Mockery ou prophecy est l'approche standard en PHP.

Bloc de code

<?php
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;

// Classe à tester
class PaymentService {
    private $gateway;
    private $logger;
    private $emailService;
    
    public function __construct(
        PaymentGateway $gateway,
        Logger $logger,
        EmailService $emailService
    ) {
        $this->gateway = $gateway;
        $this->logger = $logger;
        $this->emailService = $emailService;
    }
    
    public function processPayment(Order $order, string $cardToken): bool {
        try {
            $this->logger->info("Début paiement pour commande {$order->getId()}");
            
            $result = $this->gateway->charge(
                $order->getAmount(),
                $cardToken
            );
            
            if (!$result['success']) {
                throw new PaymentFailedException($result['message']);
            }
            
            $order->setPaymentId($result['transaction_id']);
            $order->markAsPaid();
            
            $this->emailService->sendPaymentConfirmation($order);
            $this->logger->info("Paiement réussi pour commande {$order->getId()}");
            
            return true;
        } catch (PaymentFailedException $e) {
            $this->logger->warning("Paiement échoué: " . $e->getMessage());
            throw $e;
        } catch (Throwable $e) {
            $this->logger->error("Erreur paiement inattendue: " . $e->getMessage());
            throw new PaymentException("Erreur serveur lors du paiement", previous: $e);
        }
    }
}

// Tests unitaires
class PaymentServiceTest extends TestCase {
    private PaymentService $service;
    private MockObject $gateway;
    private MockObject $logger;
    private MockObject $emailService;
    
    protected function setUp(): void {
        // Créer les mocks
        $this->gateway = $this->createMock(PaymentGateway::class);
        $this->logger = $this->createMock(Logger::class);
        $this->emailService = $this->createMock(EmailService::class);
        
        // Créer le service avec les mocks
        $this->service = new PaymentService(
            $this->gateway,
            $this->logger,
            $this->emailService
        );
    }
    
    public function testProcessPaymentSuccess() {
        // Arrange
        $order = new Order();
        $order->setId(123);
        $order->setAmount(99.99);
        
        // Configuration du mock pour retourner du succès
        $this->gateway->expects($this->once())
            ->method('charge')
            ->with(99.99, 'token-xyz')
            ->willReturn([
                'success' => true,
                'transaction_id' => 'txn-456'
            ]);
        
        // Vérifier que les logs sont appelés
        $this->logger->expects($this->atLeast(2))
            ->method('info');
        
        // Vérifier l'email de confirmation
        $this->emailService->expects($this->once())
            ->method('sendPaymentConfirmation')
            ->with($this->isInstanceOf(Order::class));
        
        // Act
        $result = $this->service->processPayment($order, 'token-xyz');
        
        // Assert
        $this->assertTrue($result);
        $this->assertEquals('txn-456', $order->getPaymentId());
        $this->assertTrue($order->isPaid());
    }
    
    public function testProcessPaymentFailure() {
        // Arrange
        $order = new Order();
        $order->setId(456);
        $order->setAmount(49.99);
        
        $this->gateway->expects($this->once())
            ->method('charge')
            ->willReturn([
                'success' => false,
                'message' => 'Carte refusée'
            ]);
        
        $this->logger->expects($this->once())
            ->method('warning');
        
        // L'email ne doit pas être envoyé
        $this->emailService->expects($this->never())
            ->method('sendPaymentConfirmation');
        
        // Act & Assert
        $this->expectException(PaymentFailedException::class);
        $this->expectExceptionMessage('Carte refusée');
        
        $this->service->processPayment($order, 'bad-token');
    }
    
    public function testProcessPaymentGatewayException() {
        // Arrange
        $order = new Order();
        
        $this->gateway->expects($this->once())
            ->method('charge')
            ->willThrowException(new Exception('Timeout gateway'));
        
        $this->logger->expects($this->once())
            ->method('error');
        
        // Act & Assert
        $this->expectException(PaymentException::class);
        $this->service->processPayment($order, 'token');
    }
    
    public function testPaymentConfirmationEmail() {
        // Arrange
        $order = new Order();
        $order->setId(789);
        $order->setAmount(199.99);
        $order->setCustomerEmail('client@example.com');
        
        $this->gateway->expects($this->once())
            ->method('charge')
            ->willReturn(['success' => true, 'transaction_id' => 'txn-789']);
        
        // Spy sur l'appel à sendPaymentConfirmation
        $this->emailService->expects($this->once())
            ->method('sendPaymentConfirmation')
            ->willReturnCallback(function($passedOrder) use ($order) {
                $this->assertEquals($order->getId(), $passedOrder->getId());
                $this->assertTrue($passedOrder->isPaid());
                return true;
            });
        
        // Act
        $this->service->processPayment($order, 'token');
    }
}

Tableau des types de tests

Type Portée Vitesse Coût Usage
Unit Fonction/classe Très rapide Bas Couverture complète
Integration Plusieurs composants Rapide Moyen Interactions clés
End-to-End Application complète Lent Haut Scénarios critiques
Contract API/Service Moyen Moyen Interfaces externes
Performance Débit/latence Variable Très haut Benchmarks

Astuce professionnelle
Utilisez la couverture de code (avec --coverage) mais ne soyez pas obsédé par le pourcentage. 80% de

Accédez à des centaines d'examens QCM — Découvrir les offres Premium