Comment écrire du code vérifiable et maintenable en PHP

Les cadres fournissent un outil pour le développement rapide d'applications, mais génèrent souvent une dette technique aussi rapidement qu'ils vous permettent de créer des fonctionnalités. La dette technique est créée lorsque la maintenabilité n’est pas une priorité du développeur. Les modifications et le débogage futurs deviennent coûteux, en raison du manque de tests unitaires et de structure..

Voici comment commencer à structurer votre code pour atteindre la testabilité et la maintenabilité - et vous faire gagner du temps..


Nous couvrirons (vaguement)

  1. SEC
  2. Injection de dépendance
  3. Des interfaces
  4. Les conteneurs
  5. Tests unitaires avec PHPUnit

Commençons par un code artificiel, mais typique. Cela pourrait être une classe modèle dans n'importe quel framework.

 class User fonction publique getCurrentUser () $ user_id = $ _SESSION ['user_id']; $ user = App :: db-> select ('id, nom d'utilisateur') -> où ('id', $ user_id) -> limit (1) -> get (); if ($ user-> num_results ()> 0) return $ user-> row ();  return false; 

Ce code fonctionnera, mais doit être amélioré:

  1. Ce n'est pas testable.
    • Nous comptons sur le $ _SESSION variable globale. Les frameworks de tests unitaires, tels que PHPUnit, reposent sur la ligne de commande, où $ _SESSION et beaucoup d'autres variables globales ne sont pas disponibles.
    • Nous nous appuyons sur la connexion à la base de données. Idéalement, les connexions de base de données réelles devraient être évitées lors d'un test unitaire. Le test concerne le code et non les données.
  2. Ce code n'est pas aussi maintenable qu'il pourrait l'être. Par exemple, si nous changeons la source de données, nous devrons changer le code de la base de données dans chaque instance de App :: db utilisé dans notre application. Aussi, qu'en est-il des cas où nous ne voulons pas que les informations de l'utilisateur actuel?

Une tentative de test unitaire

Voici une tentative pour créer un test unitaire pour la fonctionnalité ci-dessus.

 La classe UserModelTest étend PHPUnit_Framework_TestCase fonction publique testGetUser () $ user = new User (); $ currentUser = $ user-> getCurrentUser (); $ this-> assertEquals (1, $ currentUser-> id); 

Examinons cela. Tout d'abord, le test échouera. le $ _SESSION variable utilisée dans le Utilisateur L'objet n'existe pas dans un test unitaire, car il exécute PHP en ligne de commande..

Deuxièmement, il n'y a pas de configuration de connexion de base de données. Cela signifie que, pour que cela fonctionne, nous devrons initialiser notre application afin d’obtenir la App objet et son db objet. Nous aurons également besoin d’une connexion à la base de données pour pouvoir tester.

Pour que ce test unitaire fonctionne, il faudrait:

  1. Configurer une configuration pour un CLI (PHPUnit) exécuté dans notre application
  2. Comptez sur une connexion à une base de données. Cela implique de s’appuyer sur une source de données distincte de notre test unitaire. Que se passe-t-il si notre base de test n'a pas les données attendues? Et si notre connexion à la base de données est lente?
  3. Le fait de s’appuyer sur une application en cours d’amorçage augmente la charge des tests, ce qui ralentit considérablement les tests unitaires. Idéalement, la plupart de notre code peut être testé indépendamment du framework utilisé.

Alors, voyons comment nous pouvons améliorer cela.


Gardez le code au sec

La fonction de récupération de l'utilisateur actuel est inutile dans ce contexte simple. Ceci est un exemple artificiel, mais dans l’esprit des principes de DRY, la première optimisation que j’ai choisie est de généraliser cette méthode..

 class User fonction publique getUser ($ user_id) $ user = App :: db-> select ('utilisateur') -> où ('id', $ utilisateur_id) -> limit (1) -> get (); if ($ user-> num_results ()> 0) return $ user-> row ();  return false; 

Ceci fournit une méthode que nous pouvons utiliser dans l’ensemble de notre application. Nous pouvons transmettre l'utilisateur actuel au moment de l'appel, plutôt que de transmettre cette fonctionnalité au modèle. Le code est plus modulaire et maintenable lorsqu'il ne s'appuie pas sur d'autres fonctionnalités (telles que la variable globale de session).

Cependant, cela n’est toujours pas testable et maintenable comme cela pourrait être. Nous comptons toujours sur la connexion à la base de données.


Injection de dépendance

Aidons à améliorer la situation en ajoutant des injections de dépendance. Voici à quoi notre modèle pourrait ressembler, lorsque nous passons la connexion de base de données à la classe.

 utilisateur de classe protected $ _db; fonction publique __construct ($ db_connection) $ this -> _ db = $ db_connection;  public function getUser ($ user_id) $ user = $ this -> _ db-> select ('user') -> où ('id', $ user_id) -> limit (1) -> get (); if ($ user-> num_results ()> 0) return $ user-> row ();  return false; 

Maintenant, les dépendances de nos Utilisateur modèle sont fournis pour. Notre classe n'assume plus une certaine connexion à la base de données, ni ne repose sur des objets globaux.

À ce stade, notre classe est fondamentalement testable. Nous pouvons passer une source de données de notre choix (la plupart du temps) et un identifiant d'utilisateur, et tester les résultats de cet appel. Nous pouvons également déconnecter des connexions de base de données distinctes (en supposant que les deux méthodes implémentent les mêmes méthodes de récupération des données). Cool.

Regardons à quoi pourrait ressembler un test unitaire pour cela.

 _mockDb (); $ utilisateur = nouvel utilisateur ($ db_connection); $ resultat = $ user-> getUser (1); $ attendus = new StdClass (); $ attendu-> id = 1; $ attendue-> nom d'utilisateur = 'fideloper'; $ this-> assertEquals ($ result-> id, $ attendu-> id, 'ID utilisateur correctement défini'); $ this-> assertEquals ($ result-> nom d'utilisateur, $ attendu-> nom d'utilisateur, 'Nom d'utilisateur correctement défini');  protected function _mockDb () // "Mock" (stub) objet de résultat de ligne de base de données $ returnResult = new StdClass (); $ returnResult-> id = 1; $ returnResult-> username = 'fideloper'; // Objet de résultat de base de données simulé $ result = m :: mock ('DbResult'); $ result-> shouldReceive ('num_results') -> once () -> andReturn (1); $ result-> shouldReceive ('row') -> once () -> andReturn ($ returnResult); // Objet de connexion à la base de données simulée $ db = m :: mock ('DbConnection'); $ db-> shouldReceive ('select') -> once () -> andReturn ($ db); $ db-> shouldReceive ('Where') -> once () -> andReturn ($ db); $ db-> shouldReceive ('limit') -> once () -> andReturn ($ db); $ db-> shouldReceive ('get') -> once () -> andReturn ($ result); return $ db; 

J'ai ajouté quelque chose de nouveau à ce test unitaire: Mockery. Mockery vous permet de "simuler" (de faux) objets PHP. Dans ce cas, nous nous moquons de la connexion à la base de données. Avec notre maquette, nous pouvons ignorer le test d'une connexion à une base de données et simplement tester notre modèle.

Envie d'en savoir plus sur Mockery?

Dans ce cas, nous nous moquons d'une connexion SQL. Nous disons à l'objet fictif de s'attendre à avoir le sélectionner, , limite et obtenir méthodes appelées dessus. Je retourne le Mock, lui-même, pour refléter le retour de l'objet connexion SQL ($ this), rendant ainsi sa méthode appelée "chainable". Notez que pour le obtenir méthode, je retourne le résultat de l'appel de base de données - un stdClass objet avec les données d'utilisateur remplies.

Cela résout quelques problèmes:

  1. Nous testons uniquement notre classe de modèles. Nous ne testons pas également une connexion de base de données.
  2. Nous sommes en mesure de contrôler les entrées et les sorties de la connexion de base de données fictive et, par conséquent, de tester de manière fiable le résultat de l'appel de la base de données. Je sais que je vais obtenir un ID utilisateur "1" à la suite de l'appel de base de données simulé.
  3. Nous n'avons pas besoin de booter notre application ou d'avoir une configuration ou une base de données à tester.

Nous pouvons encore faire beaucoup mieux. Voici où ça devient intéressant.


Des interfaces

Pour améliorer cela, nous pourrions définir et implémenter une interface. Considérons le code suivant.

 interface UserRepositoryInterface fonction publique getUser ($ id_utilisateur);  La classe MysqlUserRepository implémente UserRepositoryInterface protected $ _db; fonction publique __construct ($ db_conn) $ this -> _ db = $ db_conn;  public function getUser ($ user_id) $ user = $ this -> _ db-> select ('user') -> où ('id', $ user_id) -> limit (1) -> get (); if ($ user-> num_results ()> 0) return $ user-> row ();  return false;  utilisateur de classe protected $ userStore; fonction publique __construct (UserRepositoryInterface $ user) $ this-> userStore = $ user;  fonction publique getUser ($ user_id) return $ this-> userStore-> getUser ($ user_id); 

Il y a quelques choses qui se passent ici.

  1. Tout d'abord, nous définissons une interface pour notre utilisateur la source de données. Ceci définit le addUser () méthode.
  2. Ensuite, nous implémentons cette interface. Dans ce cas, nous créons une implémentation MySQL. Nous acceptons un objet de connexion à la base de données et l'utilisons pour extraire un utilisateur de la base de données.
  3. Enfin, nous imposons l’utilisation d’une classe implémentant le Interface utilisateur dans notre Utilisateur modèle. Cela garantit que la source de données aura toujours une getUser () méthode disponible, quelle que soit la source de données utilisée pour la mise en œuvre Interface utilisateur.

Notez que notre Utilisateur indications de type d'objet Interface utilisateur dans son constructeur. Cela signifie qu'une classe implémentant Interface utilisateur DOIT être passé dans le Utilisateur objet. C’est une garantie sur laquelle nous comptons - nous avons besoin de la getUser méthode pour toujours être disponible.

Quel est le résultat de cette?

  • Notre code est maintenant pleinement testable. Pour le Utilisateur classe, nous pouvons facilement nous moquer de la source de données. (Tester les implémentations de la source de données serait le travail d'un test unitaire séparé).
  • Notre code est beaucoup plus maintenable. Nous pouvons permuter différentes sources de données sans avoir à changer de code dans notre application..
  • Nous pouvons créer TOUT la source de données. ArrayUser, MongoDbUser, CouchDbUser, MemoryUser, etc..
  • Nous pouvons facilement transmettre toute source de données à notre Utilisateur objet si nous avons besoin de. Si vous décidez d’abandonner SQL, vous pouvez simplement créer une autre implémentation (par exemple, MongoDbUser) et passez cela dans votre Utilisateur modèle.

Nous avons également simplifié notre test unitaire!

 _mockUserRepo (); $ user = nouvel utilisateur ($ userRepo); $ resultat = $ user-> getUser (1); $ attendus = new StdClass (); $ attendu-> id = 1; $ attendue-> nom d'utilisateur = 'fideloper'; $ this-> assertEquals ($ result-> id, $ attendu-> id, 'ID utilisateur correctement défini'); $ this-> assertEquals ($ result-> nom d'utilisateur, $ attendu-> nom d'utilisateur, 'Nom d'utilisateur correctement défini');  fonction protégée _mockUserRepo () // résultat fictif attendu $ result = new StdClass (); $ résultat-> id = 1; $ result-> username = 'fideloper'; // Mock n'importe quel référentiel d'utilisateurs $ userRepo = m :: mock ('Fideloper \ Third \ Repository \ UserRepositoryInterface'); $ userRepo-> shouldReceive ('getUser') -> once () -> andReturn ($ result); return $ userRepo; 

Nous avons complètement mis en place une simulation de connexion à une base de données. Au lieu de cela, nous nous moquons simplement de la source de données et lui disons quoi faire quand getUser est appelé.

Mais on peut encore faire mieux!


Les conteneurs

Considérez l'utilisation de notre code actuel:

 // Dans certains contrôleurs $ user = new User (new MysqlUser (App: db-> getConnection ("mysql"))); $ user-> id = App :: session ("utilisateur-> id"); $ currentUser = $ user-> getUser ($ user_id);

Notre dernière étape sera de présenter des conteneurs. Dans le code ci-dessus, nous devons créer et utiliser un ensemble d'objets uniquement pour obtenir notre utilisateur actuel. Ce code peut être encombré dans votre application. Si vous devez passer de MySQL à MongoDB, vous aurez encore besoin de modifier chaque endroit où le code ci-dessus apparaît. C'est à peine sec. Les conteneurs peuvent résoudre ce problème.

Un conteneur "contient" simplement un objet ou une fonctionnalité. Cela ressemble à un registre dans votre application. Nous pouvons utiliser un conteneur pour instancier automatiquement un nouveau Utilisateur objet avec toutes les dépendances nécessaires. Ci-dessous, j'utilise Pimple, une classe de conteneur populaire.

 // Quelque part dans un fichier de configuration $ container = new Pimple (); $ conteneur ["utilisateur"] = fonction () retourne nouvel utilisateur (nouveau MysqlUser (App: db-> getConnection ('mysql')));  // Maintenant, dans tous nos contrôleurs, nous pouvons simplement écrire: $ currentUser = $ container ['user'] -> getUser (App :: session ('user_id'));

J'ai déménagé la création du Utilisateur modéliser en un seul emplacement dans la configuration de l'application. Par conséquent:

  1. Nous avons gardé notre code SEC. le Utilisateur objet et le magasin de données de choix est défini dans un emplacement dans notre application.
  2. Nous pouvons changer notre Utilisateur modèle d’utilisation de MySQL vers n’importe quelle autre source de données UN emplacement. C'est beaucoup plus maintenable.

Dernières pensées

Au cours de ce tutoriel, nous avons réalisé les tâches suivantes:

  1. Gardé notre code SEC et réutilisable
  2. Code maintenable créé - Si nécessaire, nous pouvons commuter les sources de données de nos objets dans un emplacement unique pour l'ensemble de l'application.
  3. A rendu notre code testable - Nous pouvons simuler des objets facilement sans avoir à démarrer notre application ou à créer une base de données de test
  4. Apprentissage de l'utilisation de Dependency Injection et des interfaces, afin de permettre la création de code testable et maintenable
  5. Découvrez comment les conteneurs peuvent aider à rendre notre application plus facile à gérer

Je suis sûr que vous avez remarqué que nous avons ajouté beaucoup plus de code sous le nom de maintenabilité et de testabilité. Un argument fort peut être avancé contre cette implémentation: nous augmentons la complexité. En effet, cela nécessite une connaissance approfondie du code, à la fois pour l'auteur principal et pour les collaborateurs d'un projet..

Cependant, le coût de l'explication et de la compréhension est largement dépassé par le coût global supplémentaire diminution en dette technique.

  • Le code est beaucoup plus facile à gérer, rendant les changements possibles dans un seul endroit, plutôt que plusieurs..
  • Le fait de pouvoir effectuer des tests unitaires (rapidement) réduira considérablement les bogues dans le code, en particulier dans les projets à long terme ou axés sur la communauté (open source)..
  • Faire le travail supplémentaire à l'avant volonté gagnez du temps et des maux de tête plus tard.

Ressources

Vous pouvez inclure Moquerie et PHPUnit dans votre application facilement en utilisant Composer. Ajoutez-les à votre section "require-dev" dans votre composer.json fichier:

 "require-dev": "moquerie / moquerie": "0.8. *", "phpunit / phpunit": "3.7. *"

Vous pouvez ensuite installer vos dépendances Composer avec les exigences "dev":

 $ php composer.phar installer --dev

En savoir plus sur Mockery, Composer et PHPUnit ici sur Nettuts+.

  • La moquerie: un meilleur moyen
  • Gestion de paquetage facile avec Composer
  • Test dirigé par PHP

Pour PHP, pensez à utiliser Laravel 4, car il utilise exceptionnellement les conteneurs et autres concepts décrits ici..

Merci d'avoir lu!