À quoi ressemblerait une nouvelle langue si elle était conçue à partir de zéro pour être facile à TDD?

9

Avec les langages les plus courants (Java, C #, Java, etc.), il semble parfois que vous travaillez en désaccord avec le langage lorsque vous souhaitez entièrement TDD votre code.

Par exemple, en Java et en C #, vous voudrez simuler toutes les dépendances de vos classes et la plupart des frameworks simulateurs recommanderont de simuler les interfaces et non les classes. Cela signifie souvent que vous disposez de nombreuses interfaces avec une seule implémentation (cet effet est encore plus notable car TDD vous obligera à écrire un plus grand nombre de classes plus petites). Les solutions qui vous permettent de vous moquer correctement des classes concrètes font des choses comme modifier le compilateur ou remplacer les chargeurs de classe, etc., ce qui est assez méchant.

Alors, à quoi ressemblerait un langage s'il était conçu à partir de zéro pour être parfait avec TDD? Peut-être un moyen au niveau du langage de décrire les dépendances (plutôt que de passer des interfaces à un constructeur) et de pouvoir séparer l'interface d'une classe sans le faire explicitement?

Geoff
la source
Que diriez-vous d'une langue qui n'a pas besoin de TDD? blog.8thlight.com/uncle-bob/2011/10/20/Simple-Hickey.html
Job
2
Aucune langue n'a besoin de TDD. TDD est une pratique utile , et l'un des points de Hickey est que ce n'est pas parce que vous testez que vous pouvez arrêter de penser .
Frank Shearar
Le développement piloté par les tests consiste à faire en sorte que vos API internes et externes soient correctes, et faites-le dès le départ. Ainsi , en Java , il est tout au sujet des interfaces - les classes réelles sont des sous - produits.

Réponses:

6

Il y a plusieurs années, j'ai créé un prototype qui répondait à une question similaire; voici une capture d'écran:

Test du bouton zéro

L'idée était que les assertions sont en ligne avec le code lui-même, et tous les tests s'exécutent essentiellement à chaque frappe. Donc, dès que vous réussissez le test, vous voyez la méthode devenir verte.

Carl Manaster
la source
2
Haha, c'est incroyable! En fait, j'aime bien l'idée de mettre des tests avec le code. C'est assez fastidieux (bien qu'il y ait de très bonnes raisons) dans .NET d'avoir des assemblys séparés avec des espaces de noms parallèles pour les tests unitaires. Cela facilite également le refactoring car le déplacement de code déplace automatiquement les tests: P
Geoff
Mais voulez-vous laisser les tests là-dedans? Les laisseriez-vous activés pour le code de production? Peut-être qu'ils pourraient être # ifdef'd pour C, sinon nous examinons les hits de taille de code / d'exécution.
Mawg dit de réintégrer Monica
C'est purement un prototype. Si cela devait devenir réel, nous devions alors prendre en compte des choses comme les performances et la taille, mais il est trop tôt pour s'en inquiéter, et si nous arrivions à ce point, il ne serait pas difficile de choisir quoi laisser de côté ou, si vous le souhaitez, pour laisser les assertions hors du code compilé. Merci de votre intérêt.
Carl Manaster
5

Il serait typé dynamiquement plutôt que statiquement. Le typage canard ferait alors le même travail que les interfaces dans les langages typés statiquement. De plus, ses classes seraient modifiables au moment de l'exécution afin qu'un framework de test puisse facilement stub ou simuler des méthodes sur des classes existantes. Le rubis est une de ces langues; rspec est son premier framework de test pour TDD.

Comment la dactylographie dynamique facilite les tests

Avec la saisie dynamique, vous pouvez créer des objets fantômes en créant simplement une classe qui a la même interface (signatures de méthode) que l'objet collaborateur dont vous avez besoin de se moquer. Par exemple, supposons que vous ayez eu une classe qui a envoyé des messages:

class MessageSender
  def send
    # Do something with a side effect
  end
end

Disons que nous avons un MessageSenderUser qui utilise une instance de MessageSender:

class MessageSenderUser

  def initialize(message_sender)
    @message_sender = message_sender
  end

  def do_stuff
    ...
    @message_sender.send
    ...
    @message_sender.send
    ...
  end

end

Notez l'utilisation ici de l' injection de dépendances , un aliment de base des tests unitaires. Nous y reviendrons.

Vous souhaitez tester que les MessageSenderUser#do_stuffappels sont envoyés deux fois. Tout comme vous le feriez dans un langage tapé statiquement, vous pouvez créer un faux MessageSender qui compte le nombre de fois sendappelé. Mais contrairement à un langage typé statiquement, vous n'avez besoin d'aucune classe d'interface. Allez-y et créez-le:

class MockMessageSender

  attr_accessor :send_count

  def initialize
    @send_count = 0
  end

  def send
    @send_count += 1
  end

end

Et utilisez-le dans votre test:

mock_sender = MockMessageSender.new
MessageSenderUser.new(mock_sender).do_stuff
assert_equal(mock_sender.send_count, 2)

En soi, le "typage canard" d'une langue typée dynamiquement n'ajoute pas grand-chose aux tests par rapport à une langue typée statiquement. Mais que se passe-t-il si les classes ne sont pas fermées, mais peuvent être modifiées au moment de l'exécution? Cela change la donne. Voyons comment.

Et si vous n'aviez pas à utiliser l'injection de dépendance pour rendre une classe testable?

Supposons que MessageSenderUser n'utilise que MessageSender pour envoyer des messages et que vous n'avez pas besoin d'autoriser la substitution de MessageSender par une autre classe. Au sein d'un même programme, c'est souvent le cas. Réécrivons MessageSenderUser pour qu'il crée et utilise simplement un MessageSender, sans injection de dépendance.

class MessageSenderUser

  def initialize
    @message_sender = MessageSender.new
  end

  def do_stuff
    ...
    @message_sender.send
    ...
    @message_sender.send
    ...
  end

end

MessageSenderUser est désormais plus simple à utiliser: personne qui le crée n'a besoin de créer un MessageSender pour l'utiliser. Cela ne ressemble pas à une grande amélioration dans cet exemple simple, mais imaginez maintenant que MessageSenderUser est créé à plusieurs reprises, ou qu'il a trois dépendances. Maintenant, le système a beaucoup d'instances de passage juste pour rendre les tests unitaires heureux, pas parce qu'il améliore nécessairement la conception du tout.

Les classes ouvertes vous permettent de tester sans injection de dépendance

Un framework de test dans un langage avec typage dynamique et classes ouvertes peut rendre TDD assez agréable. Voici un extrait de code d'un test rspec pour MessageSenderUser:

mock_message_sender = mock MessageSender
MessageSender.should_receive(:new).and_return(mock_message_sender)
mock_message_sender.should_receive(:send).twice.with(no_arguments)
MessageSenderUser.new.do_stuff

C'est tout le test. Si MessageSenderUser#do_stuffn'appelle pas MessageSender#sendexactement deux fois, ce test échoue. La vraie classe MessageSender n'est jamais invoquée: nous avons dit au test que chaque fois que quelqu'un essaie de créer un MessageSender, il devrait obtenir notre faux MessageSender à la place. Aucune injection de dépendance nécessaire.

C'est agréable de faire autant dans un test aussi simple. Il est toujours plus agréable de ne pas avoir à utiliser l'injection de dépendances à moins que cela ne soit réellement logique pour votre conception.

Mais qu'est-ce que cela a à voir avec les classes ouvertes? Notez l'appel à MessageSender.should_receive. Nous n'avons pas défini #should_receive lorsque nous avons écrit MessageSender, alors qui l'a fait? La réponse est que le framework de test, en apportant quelques modifications soigneuses aux classes système, est capable de le faire apparaître car à travers #should_receive est défini sur chaque objet. Si vous pensez que la modification de classes système comme celle-ci nécessite une certaine prudence, vous avez raison. Mais c'est la chose parfaite pour ce que fait la bibliothèque de tests ici, et les classes ouvertes le permettent.

Wayne Conrad
la source
Très bonne réponse! Vous commencez à me parler des langues dynamiques :) Je pense que la frappe de canard est la clé ici, l'astuce avec .new serait peut-être aussi dans une langue typée (bien que ce soit beaucoup moins élégant).
Geoff
3

Alors, à quoi ressemblerait un langage s'il était conçu à partir de zéro pour être parfait avec TDD?

'fonctionne bien avec TDD' n'est sûrement pas suffisant pour décrire un langage, donc il pourrait "ressembler" à n'importe quoi. Lisp, Prolog, C ++, Ruby, Python ... faites votre choix.

De plus, il n'est pas clair que le support de TDD soit quelque chose qui soit mieux géré par le langage lui-même. Bien sûr, vous pouvez créer un langage dans lequel chaque fonction ou méthode a un test associé, et vous pouvez intégrer la prise en charge de la découverte et de l'exécution de ces tests. Mais les frameworks de tests unitaires gèrent déjà bien la partie découverte et exécution, et il est difficile de voir comment ajouter proprement l'exigence d'un test pour chaque fonction. Les tests nécessitent-ils également des tests? Ou existe-t-il deux classes de fonctions - normales qui nécessitent des tests et des fonctions de test qui n'en ont pas besoin? Cela ne semble pas très élégant.

Il est peut-être préférable de prendre en charge TDD avec des outils et des cadres. Construisez-le dans l'IDE. Créez un processus de développement qui l'encourage.

De plus, si vous concevez une langue, il est bon de penser à long terme. N'oubliez pas que le TDD n'est qu'une méthode, et pas la méthode de travail préférée de tous. Il peut être difficile d'imaginer, mais il est possible que des moyens encore meilleurs se présentent. En tant que concepteur de langues, voulez-vous que les gens abandonnent votre langue lorsque cela se produit?

Tout ce que vous pouvez vraiment dire pour répondre à la question, c'est qu'une telle langue serait propice aux tests. Je sais que cela n'aide pas beaucoup, mais je pense que le problème vient de la question.

Caleb
la source
D'accord, c'est une question très difficile à bien formuler. Je pense que ce que je veux dire, c'est que les outils de test actuels pour des langages comme Java / C # donnent l'impression que le langage gêne un peu et que certaines fonctionnalités de langage supplémentaires / alternatives rendraient l'expérience plus élégante (c'est-à-dire sans interfaces pour 90). % de mes cours, seulement ceux où cela a du sens d'un point de vue de conception de niveau supérieur).
Geoff
0

Eh bien, les langages typés dynamiquement ne nécessitent pas d'interfaces explicites. Voir Ruby ou PHP, etc.

D'un autre côté, les langages typés statiquement comme Java et C # ou C ++ appliquent les types et vous obligent à écrire ces interfaces.

Ce que je ne comprends pas, c'est quel est votre problème avec eux. Les interfaces sont un élément clé de la conception et elles sont utilisées partout dans les modèles de conception et dans le respect des principes SOLIDES. Par exemple, j'utilise fréquemment des interfaces en PHP car elles rendent la conception explicite et imposent également la conception. D'un autre côté, dans Ruby, vous n'avez aucun moyen d'appliquer un type, c'est un langage typé canard. Mais encore, vous devez imaginer l'interface là-bas et vous devez abstraire la conception dans votre esprit afin de la mettre en œuvre correctement.

Ainsi, bien que votre question puisse sembler intéressante, cela implique que vous avez des problèmes avec la compréhension ou l'application des techniques d'injection de dépendance.

Et pour répondre directement à votre question, Ruby et PHP ont une excellente infrastructure de simulation à la fois intégrée dans leurs cadres de tests unitaires et livrée séparément (voir Mockery for PHP). Dans certains cas, ces frameworks vous permettent même de faire ce que vous proposez, des choses comme se moquer des appels statiques ou des initialisations d'objets sans injecter explicitement une dépendance.

Patkos Csaba
la source
1
Je suis d'accord que les interfaces sont géniales et un élément de conception clé. Cependant, dans mon code, je trouve que 90% des classes ont une interface et qu'il n'y a que deux implémentations de cette interface, la classe et les mocks de cette classe. Bien que ce soit techniquement exactement le point des interfaces, je ne peux m'empêcher de penser que c'est inélégant.
Geoff
Je ne suis pas très familier avec les moqueries en Java et C #, mais pour autant que je sache, un objet moqué imite le vrai objet. Je fais souvent une injection de dépendance en utilisant un paramètre du type de l'objet et en envoyant une maquette à la méthode / classe à la place. Quelque chose comme la fonction someName (AnotherClass $ object = null) {$ this-> anotherObject = $ object? : nouveau AnotherClass; } C'est une astuce fréquemment utilisée pour injecter des dépendances sans dériver d'une interface.
Patkos Csaba
1
C'est certainement là que les langages dynamiques ont l'avantage sur les langages de type Java / C # par rapport à ma question. Une maquette typique d'une classe concrète créera en fait une sous-classe de la classe, ce qui signifie que le constructeur de la classe concrète sera appelé, ce que vous voulez absolument éviter (il y a des exceptions, mais ils ont leurs propres problèmes). Une maquette dynamique ne fait que tirer parti du typage de canard, il n'y a donc pas de relation entre la classe concrète et une maquette de celle-ci. J'avais l'habitude de beaucoup coder en Python, mais c'était avant mes jours TDD, il est peut-être temps de jeter un autre coup d'œil!
Geoff