¿Cómo se vería un nuevo lenguaje si fue diseñado desde cero para que sea fácil de usar con TDD?

9

Con los lenguajes más comunes (Java, C #, Java, etc.) a veces parece que está trabajando en desacuerdo con el idioma cuando desea TDD completo de su código.

Por ejemplo, en Java y C # querrás burlarte de cualquier dependencia de tus clases y la mayoría de los frameworks de burla te recomendarán burlarte de las interfaces, no de las clases. Esto a menudo significa que tiene muchas interfaces con una sola implementación (este efecto es aún más notable porque TDD lo obligará a escribir un mayor número de clases más pequeñas). Las soluciones que le permiten burlarse de clases concretas hacen cosas como alterar el compilador o anular los cargadores de clases, etc., lo cual es bastante desagradable.

Entonces, ¿cómo sería un lenguaje si estuviera diseñado desde cero para ser excelente para TDD? ¿Posiblemente de alguna manera a nivel de lenguaje para describir dependencias (en lugar de pasar interfaces a un constructor) y poder separar la interfaz de una clase sin hacerlo explícitamente?

Geoff
fuente
¿Qué tal un lenguaje que no necesita TDD? blog.8thlight.com/uncle-bob/2011/10/20/Simple-Hickey.html
Trabajo
2
Ningún idioma necesita TDD. TDD es una práctica útil , y uno de los puntos de Hickey es que el hecho de que la prueba no significa que puede dejar de pensar .
Frank Shearar
Test Driven Development se trata de obtener sus API internas y externas correctas, y hacerlo por adelantado. Por lo tanto, en Java se trata de las interfaces: las clases reales son subproductos.

Respuestas:

6

Hace muchos años, armé un prototipo que abordaba una pregunta similar; Aquí hay una captura de pantalla:

Prueba de botón cero

La idea era que las afirmaciones están en línea con el código en sí, y todas las pruebas se ejecutan básicamente en cada pulsación de tecla. Entonces, tan pronto como pase la prueba, verá que el método se vuelve verde.

Carl Manaster
fuente
2
Jaja, eso es increíble! De hecho, me gusta bastante la idea de poner las pruebas junto con el código. Es bastante tedioso (aunque hay muy buenas razones) en que .NET tenga ensamblados separados con espacios de nombres paralelos para pruebas unitarias. También facilita la refactorización porque al mover el código, las pruebas se mueven automáticamente: P
Geoff
¿Pero quieres dejar las pruebas allí? ¿Los dejarías habilitados para el código de producción? Tal vez podrían ser # ifdef'd para C, de lo contrario, estamos buscando éxitos de tamaño de código / tiempo de ejecución.
Mawg dice que reinstalar a Monica
Es puramente un prototipo. Si se volviera real, entonces tendríamos que considerar cosas como el rendimiento y el tamaño, pero es demasiado temprano para preocuparnos por eso, y si alguna vez llegamos a ese punto, no sería difícil elegir qué dejar de lado. o, si lo desea, dejar las afirmaciones fuera del código compilado. Gracias por tu interés.
Carl Manaster
5

Sería escrito dinámicamente en lugar de estáticamente. La escritura de pato haría el mismo trabajo que las interfaces en los idiomas de escritura estática. Además, sus clases serían modificables en tiempo de ejecución para que un marco de prueba pudiera fácilmente tropezar o burlarse de los métodos en las clases existentes. Ruby es uno de esos idiomas; rspec es su principal marco de prueba para TDD.

Cómo la escritura dinámica ayuda a las pruebas

Con la escritura dinámica, puede crear objetos simulados simplemente creando una clase que tenga la misma interfaz (firmas de método) que el objeto colaborador que necesita simular. Por ejemplo, suponga que tiene alguna clase que envía mensajes:

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

Digamos que tenemos un MessageSenderUser que usa una instancia de MessageSender:

class MessageSenderUser

  def initialize(message_sender)
    @message_sender = message_sender
  end

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

end

Tenga en cuenta el uso aquí de la inyección de dependencia , un elemento básico de las pruebas unitarias. Volveremos a eso.

Desea probar que las MessageSenderUser#do_stuffllamadas se envían dos veces. Del mismo modo que lo haría en un lenguaje estáticamente escrito, puede crear un MessageSender simulado que cuente cuántas veces sendse llamó. Pero a diferencia de un lenguaje de tipo estático, no necesita una clase de interfaz. Simplemente sigue adelante y créalo:

class MockMessageSender

  attr_accessor :send_count

  def initialize
    @send_count = 0
  end

  def send
    @send_count += 1
  end

end

Y úsalo en tu prueba:

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

Por sí solo, el "tipeo de pato" de un lenguaje de tipo dinámico no agrega mucho a las pruebas en comparación con un lenguaje de tipo estático. Pero, ¿qué pasa si las clases no están cerradas, pero pueden modificarse en tiempo de ejecución? Eso es un cambio de juego. A ver cómo.

¿Qué pasaría si no tuviera que usar la inyección de dependencia para hacer que una clase sea comprobable?

Suponga que MessageSenderUser solo usará MessageSender para enviar mensajes, y no tiene necesidad de permitir la sustitución de MessageSender por otra clase. Dentro de un solo programa, este suele ser el caso. Reescribamos MessageSenderUser para que simplemente cree y use un MessageSender, sin inyección de dependencia.

class MessageSenderUser

  def initialize
    @message_sender = MessageSender.new
  end

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

end

MessageSenderUser ahora es más fácil de usar: nadie que lo cree necesita crear un MessageSender para que lo use. No parece una gran mejora en este simple ejemplo, pero ahora imagine que MessageSenderUser se crea en más de una vez, o que tiene tres dependencias. Ahora el sistema tiene una gran cantidad de instancias que pasan solo para hacer felices las pruebas unitarias, no porque necesariamente mejore el diseño en absoluto.

Las clases abiertas te permiten probar sin inyección de dependencia

Un marco de prueba en un lenguaje con escritura dinámica y clases abiertas puede hacer que TDD sea bastante agradable. Aquí hay un fragmento de código de una prueba rspec para 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

Esa es toda la prueba. Si MessageSenderUser#do_stuffno se invoca MessageSender#sendexactamente dos veces, esta prueba falla. La clase real de MessageSender nunca se invoca: le dijimos a la prueba que cada vez que alguien intenta crear un MessageSender, debería obtener nuestro falso MessageSender. No se necesita inyección de dependencia.

Es bueno hacer mucho en una prueba tan simple. Siempre es mejor no tener que usar la inyección de dependencia a menos que realmente tenga sentido para su diseño.

Pero, ¿qué tiene esto que ver con las clases abiertas? Tenga en cuenta la llamada a MessageSender.should_receive. No definimos #should_receive cuando escribimos MessageSender, entonces, ¿quién lo hizo? La respuesta es que el marco de prueba, haciendo algunas modificaciones cuidadosas de las clases del sistema, puede hacer que aparezca como a través de #should_receive se define en cada objeto. Si crees que modificar clases de sistemas así requiere cierta precaución, tienes razón. Pero es lo perfecto para lo que la biblioteca de prueba está haciendo aquí, y las clases abiertas lo hacen posible.

Wayne Conrad
fuente
¡Gran respuesta! Ustedes están empezando a hablarme de vuelta a los lenguajes dinámicos :) Creo que escribir el pato es la clave aquí, el truco con .new también podría estar en un lenguaje tipado estáticamente (aunque sería mucho menos elegante).
Geoff
3

Entonces, ¿cómo sería un lenguaje si estuviera diseñado desde cero para ser excelente para TDD?

'funciona bien con TDD' seguramente no es suficiente para describir un lenguaje, por lo que podría "verse" como cualquier cosa. Lisp, Prolog, C ++, Ruby, Python ... elige tu opción.

Además, no está claro que admitir TDD es algo que se maneja mejor con el propio lenguaje. Claro, podría crear un lenguaje donde cada función o método tenga una prueba asociada, y podría construir un soporte para descubrir y ejecutar esas pruebas. Pero los marcos de pruebas unitarias ya manejan el descubrimiento y la ejecución de forma agradable, y es difícil ver cómo agregar limpiamente el requisito de una prueba para cada función. ¿Las pruebas también necesitan pruebas? ¿O hay dos clases de funciones: las normales que necesitan pruebas y las funciones de prueba que no las necesitan? Eso no parece muy elegante.

Quizás sea mejor admitir TDD con herramientas y marcos. Construirlo en el IDE. Crea un proceso de desarrollo que lo aliente.

Además, si está diseñando un lenguaje, es bueno pensar a largo plazo. Recuerde que TDD es solo una metodología, y no la forma preferida de trabajar de todos. Puede ser difícil de imaginar, pero es posible que surjan formas aún mejores . Como diseñador de idiomas, ¿quieres que la gente tenga que abandonar tu idioma cuando eso suceda?

Todo lo que realmente puede decir para responder a la pregunta es que tal lenguaje sería propicio para las pruebas. Sé que eso no ayuda mucho, pero creo que el problema está en la pregunta.

Caleb
fuente
De acuerdo, es una pregunta muy difícil de expresar bien. Creo que lo que quiero decir es que las herramientas de prueba actuales para lenguajes como Java / C # sienten que el lenguaje se está interponiendo un poco y que alguna característica de lenguaje adicional / alternativa hará que toda la experiencia sea más elegante (es decir, no tener interfaces para 90) % de mis clases, solo aquellas en las que tiene sentido desde un punto de vista de diseño de nivel superior).
Geoff
0

Bueno, los lenguajes escritos dinámicamente no requieren interfaces explícitas. Ver Ruby o PHP, etc.

Por otro lado, los lenguajes estáticamente tipados como Java y C # o C ++ imponen los tipos y lo obligan a escribir esas interfaces.

Lo que no entiendo es cuál es su problema con ellos. Las interfaces son un elemento clave del diseño y se utilizan en todos los patrones de diseño y en el respeto de los principios SÓLIDOS. Por ejemplo, con frecuencia uso interfaces en PHP porque hacen que el diseño sea explícito y también lo imponen. Por otro lado, en Ruby no tienes forma de imponer un tipo, es un lenguaje de tipo pato. Pero aún así, debe imaginar la interfaz allí y debe abstraer el diseño en su mente para implementarlo correctamente.

Entonces, aunque su pregunta puede sonar interesante, implica que tiene problemas para comprender o aplicar técnicas de inyección de dependencia.

Y para responder directamente a su pregunta, Ruby y PHP tienen una excelente infraestructura de burla, ambos integrados en sus marcos de prueba de unidad y entregados por separado (consulte Mockery para PHP). En algunos casos, estos marcos incluso le permiten hacer lo que está sugiriendo, como burlarse de llamadas estáticas o inicializaciones de objetos sin inyectar una dependencia explícitamente.

Patkos Csaba
fuente
1
Estoy de acuerdo en que las interfaces son excelentes y un elemento clave de diseño. Sin embargo, en mi código encuentro que el 90% de las clases tienen una interfaz y que solo hay dos implementaciones de esa interfaz, la clase y los simulacros de esa clase. Aunque técnicamente este es exactamente el punto de las interfaces, no puedo evitar sentir que no es elegante.
Geoff
No estoy muy familiarizado con las burlas en Java y C #, pero que yo sepa, un objeto burlado imita al objeto real. Frecuentemente hago la inyección de dependencia usando un parámetro del tipo de objeto y enviando un simulacro al método / clase en su lugar. Algo así como la función someName (AnotherClass $ object = null) {$ this-> anotherObject = $ object? : nuevo AnotherClass; } Este es un truco de uso frecuente para inyectar dependencia sin derivar de una interfaz.
Patkos Csaba
1
Esto es definitivamente donde los lenguajes dinámicos tienen la ventaja sobre los lenguajes de tipo Java / C # con respecto a mi pregunta. Un simulacro típico de una clase concreta en realidad creará una subclase de la clase, lo que significa que se llamará al constructor de la clase concreta, que es algo que definitivamente desea evitar (hay excepciones, pero tienen sus propios problemas). Un simulacro dinámico solo aprovecha la tipificación de pato, por lo que no hay relación entre la clase concreta y un simulacro de ella. Solía ​​codificar mucho en Python, pero eso fue antes de mis días TDD, ¡tal vez es hora de echarle otro vistazo!
Geoff