Wie würde eine neue Sprache aussehen, wenn sie von Grund auf so gestaltet wäre, dass sie für TDD einfach ist?

9

Bei den gängigsten Sprachen (Java, C #, Java usw.) scheint es manchmal so, als würden Sie im Widerspruch zur Sprache arbeiten, wenn Sie Ihren Code vollständig TDD-fähig machen möchten.

In Java und C # möchten Sie beispielsweise alle Abhängigkeiten Ihrer Klassen verspotten, und die meisten Verspottungs-Frameworks empfehlen, dass Sie Schnittstellen und keine Klassen verspotten. Dies bedeutet häufig, dass Sie viele Schnittstellen mit einer einzigen Implementierung haben (dieser Effekt macht sich noch deutlicher bemerkbar, da TDD Sie zwingt, eine größere Anzahl kleinerer Klassen zu schreiben). Lösungen, mit denen Sie konkrete Klassen richtig verspotten können, können beispielsweise den Compiler ändern oder Klassenlader usw. überschreiben, was ziemlich unangenehm ist.

Wie würde eine Sprache aussehen, wenn sie von Grund auf neu für TDD entwickelt würde? Möglicherweise eine Möglichkeit auf Sprachebene, Abhängigkeiten zu beschreiben (anstatt Schnittstellen an einen Konstruktor zu übergeben) und die Schnittstelle einer Klasse zu trennen, ohne dies explizit zu tun?

Geoff
quelle
Wie wäre es mit einer Sprache, die kein TDD benötigt? blog.8thlight.com/uncle-bob/2011/10/20/Simple-Hickey.html
Job
2
Keine Sprache benötigt TDD. TDD ist eine nützliche Praxis , und einer der Punkte von Hickey ist, dass nur weil Sie testen, dies nicht bedeutet, dass Sie möglicherweise aufhören zu denken .
Frank Shearar
Bei der testgetriebenen Entwicklung geht es darum, die internen und externen APIs im Voraus richtig zu machen. Daher wird in Java es ist alles über die Schnittstellen - tatsächliche Klassen sind Nebenprodukte.

Antworten:

6

Vor vielen Jahren habe ich einen Prototyp zusammengestellt, der sich mit einer ähnlichen Frage befasste. Hier ist ein Screenshot:

Null-Knopf-Test

Die Idee war, dass die Aussagen mit dem Code selbst übereinstimmen und alle Tests grundsätzlich bei jedem Tastendruck ausgeführt werden. Sobald Sie den Test bestanden haben, wird die Methode grün.

Carl Manaster
quelle
2
Haha, das ist unglaublich! Die Idee, Tests mit dem Code zusammenzustellen, gefällt mir eigentlich sehr gut. Es ist ziemlich mühsam (obwohl es sehr gute Gründe gibt), in .NET separate Assemblys mit parallelen Namespaces für Komponententests zu haben. Es erleichtert auch das Refactoring, da durch das Verschieben von Code die Tests automatisch verschoben werden: P
Geoff
Aber möchten Sie die Tests dort belassen? Würden Sie sie für den Produktionscode aktiviert lassen? Vielleicht könnten sie für C # ifdef'd sein, andernfalls betrachten wir Treffer in Codegröße / Laufzeit.
Mawg sagt, Monica
Es ist nur ein Prototyp. Wenn es real werden würde, müssten wir Dinge wie Leistung und Größe berücksichtigen, aber es ist viel zu früh, um sich darüber Sorgen zu machen, und wenn wir jemals an diesen Punkt gelangen, wäre es nicht schwer zu entscheiden, was weggelassen werden soll oder, falls gewünscht, um die Zusicherungen aus dem kompilierten Code herauszulassen. Danke für Ihr Interesse.
Carl Manaster
5

Es wäre eher dynamisch als statisch typisiert. Das Entenschreiben würde dann den gleichen Job machen wie Schnittstellen in statisch typisierten Sprachen. Außerdem können die Klassen zur Laufzeit geändert werden, sodass ein Testframework Methoden für vorhandene Klassen leicht stubben oder verspotten kann. Ruby ist eine solche Sprache; rspec ist das führende Test-Framework für TDD.

Wie dynamisches Schreiben das Testen unterstützt

Bei der dynamischen Typisierung können Sie Scheinobjekte erstellen, indem Sie einfach eine Klasse erstellen, die dieselbe Schnittstelle (Methodensignaturen) wie das zu verspottende Collaborator-Objekt hat. Angenommen, Sie hatten eine Klasse, die Nachrichten gesendet hat:

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

Angenommen, wir haben einen MessageSenderUser, der eine Instanz von MessageSender verwendet:

class MessageSenderUser

  def initialize(message_sender)
    @message_sender = message_sender
  end

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

end

Beachten Sie hier die Verwendung der Abhängigkeitsinjektion , eine Grundvoraussetzung für Unit-Tests. Wir werden darauf zurückkommen.

Sie möchten testen, dass MessageSenderUser#do_stuffAnrufe zweimal gesendet werden. Genau wie in einer statisch typisierten Sprache können Sie einen nachgebildeten MessageSender erstellen, der zählt, wie oft sendaufgerufen wurde. Im Gegensatz zu einer statisch typisierten Sprache benötigen Sie jedoch keine Schnittstellenklasse. Sie erstellen es einfach:

class MockMessageSender

  attr_accessor :send_count

  def initialize
    @send_count = 0
  end

  def send
    @send_count += 1
  end

end

Und verwenden Sie es in Ihrem Test:

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

An sich trägt die "Ententypisierung" einer dynamisch typisierten Sprache im Vergleich zu einer statisch typisierten Sprache nicht so viel zum Testen bei. Was aber, wenn Klassen nicht geschlossen sind, sondern zur Laufzeit geändert werden können? Das ist ein Game Changer. Mal sehen wie.

Was wäre, wenn Sie keine Abhängigkeitsinjektion verwenden müssten, um eine Klasse testbar zu machen?

Angenommen, MessageSenderUser verwendet MessageSender immer nur zum Senden von Nachrichten, und Sie müssen MessageSender nicht durch eine andere Klasse ersetzen. Innerhalb eines einzelnen Programms ist dies häufig der Fall. Lassen Sie uns MessageSenderUser so umschreiben, dass es einfach einen MessageSender ohne Abhängigkeitsinjektion erstellt und verwendet.

class MessageSenderUser

  def initialize
    @message_sender = MessageSender.new
  end

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

end

MessageSenderUser ist jetzt einfacher zu verwenden: Niemand, der es erstellt, muss einen MessageSender erstellen, damit es verwendet werden kann. In diesem einfachen Beispiel sieht es nicht nach einer großen Verbesserung aus, aber stellen Sie sich jetzt vor, dass MessageSenderUser an mehreren Stellen erstellt wird oder drei Abhängigkeiten aufweist. Jetzt hat das System eine ganze Reihe von Instanzen, nur um die Unit-Tests glücklich zu machen, nicht weil es das Design notwendigerweise überhaupt verbessert.

Mit offenen Klassen können Sie ohne Abhängigkeitsinjektion testen

Ein Testframework in einer Sprache mit dynamischer Typisierung und offenen Klassen kann TDD sehr schön machen. Hier ist ein Codefragment aus einem rspec-Test für 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

Das ist der ganze Test. Wenn MessageSenderUser#do_stuffnicht MessageSender#sendgenau zweimal aufgerufen wird, schlägt dieser Test fehl. Die echte MessageSender-Klasse wird niemals aufgerufen: Wir haben dem Test mitgeteilt, dass jemand, der versucht, einen MessageSender zu erstellen, stattdessen unseren nachgebildeten MessageSender erhalten sollte. Keine Abhängigkeitsinjektion erforderlich.

Es ist schön, in einem so einfachen Test so viel zu tun. Es ist immer schöner, keine Abhängigkeitsinjektion verwenden zu müssen, es sei denn, dies ist für Ihr Design tatsächlich sinnvoll.

Aber was hat das mit offenen Klassen zu tun? Beachten Sie den Anruf an MessageSender.should_receive. Wir haben #should_receive nicht definiert, als wir MessageSender geschrieben haben. Wer hat das getan? Die Antwort ist, dass das Testframework, das einige sorgfältige Änderungen an Systemklassen vornimmt, es so aussehen lässt, als ob durch #should_receive für jedes Objekt definiert wird. Wenn Sie der Meinung sind, dass das Ändern solcher Systemklassen einige Vorsicht erfordert, haben Sie Recht. Aber es ist die perfekte Sache für das, was die Testbibliothek hier tut, und offene Klassen machen es möglich.

Wayne Conrad
quelle
Gute Antwort! Ihr fängt an, mich wieder mit dynamischen Sprachen zu beschäftigen :) Ich denke, Entenschreiben ist hier der Schlüssel, der Trick mit .new wäre möglicherweise auch in einer statisch typisierten Sprache (obwohl es viel weniger elegant wäre).
Geoff
3

Wie würde eine Sprache aussehen, wenn sie von Grund auf neu für TDD entwickelt würde?

'funktioniert gut mit TDD' reicht sicherlich nicht aus, um eine Sprache zu beschreiben, daher könnte sie wie alles "aussehen". Lisp, Prolog, C ++, Ruby, Python ... treffen Sie Ihre Wahl.

Darüber hinaus ist nicht klar, dass die Unterstützung von TDD am besten von der Sprache selbst gehandhabt wird. Sicher, Sie könnten eine Sprache erstellen, in der jeder Funktion oder Methode ein Test zugeordnet ist, und Sie könnten Unterstützung für das Erkennen und Ausführen dieser Tests einbauen. Unit-Testing-Frameworks behandeln den Erkennungs- und Ausführungsteil jedoch bereits gut, und es ist schwer zu erkennen, wie die Anforderung eines Tests für jede Funktion sauber hinzugefügt werden kann. Brauchen Tests auch Tests? Oder gibt es zwei Funktionsklassen - normale, die Tests benötigen, und Testfunktionen, die sie nicht benötigen? Das scheint nicht sehr elegant zu sein.

Vielleicht ist es besser, TDD mit Tools und Frameworks zu unterstützen. Bauen Sie es in die IDE ein. Erstellen Sie einen Entwicklungsprozess, der ihn fördert.

Wenn Sie eine Sprache entwerfen, ist es auch gut, langfristig zu denken. Denken Sie daran, dass TDD nur eine Methode ist und nicht jedermanns bevorzugte Arbeitsweise. Es mag schwer vorstellbar sein, aber es ist möglich, dass noch bessere Wege kommen. Möchten Sie als Sprachdesigner, dass die Leute Ihre Sprache verlassen müssen, wenn dies passiert?

Alles, was Sie wirklich sagen können, um die Frage zu beantworten, ist, dass eine solche Sprache dem Testen förderlich wäre. Ich weiß, dass das nicht viel hilft, aber ich denke, das Problem liegt in der Frage.

Caleb
quelle
Einverstanden, es ist eine sehr schwierige Frage, gut zu formulieren. Ich denke, was ich damit meine, ist, dass aktuelle Testtools für Sprachen wie Java / C # das Gefühl haben, dass die Sprache ein wenig in die Quere kommt und dass eine zusätzliche / alternative Sprachfunktion die gesamte Erfahrung eleganter machen würde (dh keine Schnittstellen für 90 % meiner Klassen, nur diejenigen, bei denen es aus übergeordneter Sicht sinnvoll ist).
Geoff
0

Dynamisch typisierte Sprachen erfordern keine expliziten Schnittstellen. Siehe Ruby oder PHP usw.

Auf der anderen Seite erzwingen statisch typisierte Sprachen wie Java und C # oder C ++ Typen und zwingen Sie, diese Schnittstellen zu schreiben.

Was ich nicht verstehe ist, was ist dein Problem mit ihnen. Schnittstellen sind ein Schlüsselelement des Designs und werden in allen Designmustern und unter Einhaltung der SOLID-Prinzipien verwendet. Ich verwende zum Beispiel häufig Schnittstellen in PHP, weil sie das Design explizit machen und auch das Design erzwingen. Auf der anderen Seite haben Sie in Ruby keine Möglichkeit, einen Typ zu erzwingen, es ist eine Sprache vom Typ Ente. Trotzdem müssen Sie sich die Benutzeroberfläche dort vorstellen und das Design in Ihrem Kopf abstrahieren, um es korrekt zu implementieren.

Obwohl Ihre Frage interessant klingt, bedeutet dies, dass Sie Probleme mit dem Verständnis oder der Anwendung von Abhängigkeitsinjektionstechniken haben.

Um Ihre Frage direkt zu beantworten, verfügen Ruby und PHP über eine hervorragende Verspottungsinfrastruktur, die sowohl in ihre Unit-Test-Frameworks integriert als auch separat bereitgestellt wird (siehe Mockery für PHP). In einigen Fällen können Sie mit diesen Frameworks sogar das tun, was Sie vorschlagen, z. B. statische Aufrufe oder Objektinitialisierungen verspotten, ohne explizit eine Abhängigkeit einzufügen.

Patkos Csaba
quelle
1
Ich stimme zu, dass Schnittstellen großartig und ein wichtiges Gestaltungselement sind. In meinem Code stelle ich jedoch fest, dass 90% der Klassen eine Schnittstelle haben und dass es immer nur zwei Implementierungen dieser Schnittstelle gibt, die Klasse und die Mocks dieser Klasse. Obwohl dies technisch genau der Punkt von Schnittstellen ist, kann ich nicht anders, als das Gefühl zu haben, dass es unelegant ist.
Geoff
Ich bin mit dem Verspotten in Java und C # nicht sehr vertraut, aber soweit ich weiß, ahmt ein verspottetes Objekt das reale Objekt nach. Ich führe häufig Abhängigkeitsinjektionen durch, indem ich einen Parameter des Objekttyps verwende und stattdessen einen Mock an die Methode / Klasse sende. So etwas wie die Funktion someName (AnotherClass $ object = null) {$ this-> anotherObject = $ object? : neue AnotherClass; } Dies ist ein häufig verwendeter Trick, um Abhängigkeiten einzufügen, ohne von einer Schnittstelle abgeleitet zu werden.
Patkos Csaba
1
Hier haben dynamische Sprachen in Bezug auf meine Frage definitiv den Vorteil gegenüber Sprachen vom Typ Java / C #. Ein typisches Modell einer konkreten Klasse erstellt tatsächlich eine Unterklasse der Klasse. Dies bedeutet, dass der konkrete Klassenkonstruktor aufgerufen wird. Dies sollten Sie unbedingt vermeiden (es gibt Ausnahmen, aber sie haben ihre eigenen Probleme). Ein dynamischer Mock nutzt nur das Tippen von Enten, sodass keine Beziehung zwischen der konkreten Klasse und einem Mock davon besteht. Früher habe ich viel in Python programmiert, aber das war vor meinen TDD-Tagen, vielleicht ist es Zeit, einen weiteren Blick darauf zu werfen!
Geoff