Luchando con dependencias cíclicas en pruebas unitarias

24

Estoy tratando de practicar TDD, usándolo para desarrollar un simple como Bit Vector. Estoy usando Swift, pero esta es una pregunta independiente del lenguaje.

My BitVectores un structque almacena un single UInt64y presenta una API sobre él que le permite tratarlo como una colección. Los detalles no importan mucho, pero es bastante simple. Los 57 bits superiores son bits de almacenamiento, y los 6 bits inferiores son bits de "recuento", que le indica cuántos bits de almacenamiento almacenan realmente un valor contenido.

Hasta ahora, tengo un puñado de capacidades muy simples:

  1. Un inicializador que construye vectores de bits vacíos
  2. Una countpropiedad de tipoInt
  3. Una isEmptypropiedad de tipoBool
  4. Un operador de igualdad ( ==). NB: este es un operador de igualdad de valores similar a Object.equals()Java, no un operador de igualdad de referencia como ==en Java.

Me encuentro con un montón de dependencias cíclicas:

  1. La prueba unitaria que prueba mi inicializador necesita verificar que el recién construido BitVector. Puede hacerlo de una de 3 maneras:

    1. Comprobar bv.count == 0
    2. Comprobar bv.isEmpty == true
    3. Mira esto bv == knownEmptyBitVector

    El método 1 se basa count, el método 2 se basa isEmpty(que en sí mismo depende count, por lo que no tiene sentido usarlo), el método 3 se basa ==. En cualquier caso, no puedo probar mi inicializador de forma aislada.

  2. La prueba para countnecesita operar en algo, que inevitablemente prueba mi (s) inicializador (es)

  3. La implementación de se isEmptybasa encount

  4. La implementación de se ==basa en count.

Pude resolver en parte este problema introduciendo una API privada que construye a BitVectorpartir de un patrón de bits existente (como a UInt64). Esto me permitió inicializar valores sin probar ningún otro inicializador, de modo que pudiera "arrancar la correa" hacia arriba.

Para que mis pruebas unitarias sean realmente pruebas unitarias, me encuentro haciendo un montón de hacks, lo que complica sustancialmente mi código de prueba y prueba.

¿Cómo se resuelve exactamente este tipo de problemas?

Alexander - Restablece a Monica
fuente
20
Está tomando una visión demasiado limitada sobre el término "unidad". BitVectores un tamaño de unidad perfectamente fino para pruebas unitarias e inmediatamente resuelve sus problemas que los miembros públicos se BitVectornecesitan mutuamente para realizar pruebas significativas.
Bart van Ingen Schenau
Conoces demasiados detalles de implementación por adelantado. ¿Es el desarrollo realmente TEST- impulsado ?
herby
@herby No, por eso estoy practicando. Aunque eso parece un estándar realmente inalcanzable. No creo que haya programado nada sin una aproximación mental bastante clara de lo que implicará la implementación.
Alexander - Restablece a Mónica el
@Alexander Debes intentar relajar eso, de lo contrario será primero una prueba, pero no será una prueba. Simplemente diga vago "Haré un vector de bits con un int 64bit como una tienda de respaldo" y eso es todo; a partir de ese momento, haga TDD red-green-refactor uno tras otro. Los detalles de implementación, así como la API, deben surgir al intentar hacer que se ejecuten las pruebas (la primera) y al escribir esas pruebas en primer lugar (la última).
herby

Respuestas:

66

Te preocupas demasiado por los detalles de implementación.

No importa que en su implementación actual , isEmptyse base count(o cualquier otra relación que pueda tener): todo lo que debe preocuparse es la interfaz pública. Por ejemplo, puede tener tres pruebas:

  • Que tiene un objeto recién inicializado count == 0.
  • Que un objeto recién inicializado tiene isEmpty == true
  • Que un objeto recién inicializado es igual al objeto vacío conocido.

Estas son todas las pruebas válidas, y se vuelven especialmente importantes si alguna vez decides refactorizar las partes internas de tu clase para que isEmptytenga una implementación diferente que no dependa count, siempre y cuando tus pruebas sigan aprobadas, sabes que no has retrocedido cualquier cosa.

Cosas similares se aplican a sus otros puntos: recuerde probar la interfaz pública, no su implementación interna. Puede que encuentre TDD útil aquí, ya que luego escribiría las pruebas que necesita isEmptyantes de escribir cualquier implementación para ello.

Philip Kendall
fuente
66
@Alexander Suenas como un hombre que necesita una definición clara de las pruebas unitarias. El mejor que conozco proviene de Michael Feathers
candied_orange
14
@Alexander, estás tratando cada método como un fragmento de código que se puede probar de forma independiente. Esa es la fuente de tus dificultades. Estas dificultades desaparecen si prueba el objeto como un todo, sin tratar de dividirlo en partes más pequeñas. Las dependencias entre objetos no son comparables con las dependencias entre métodos.
amon
99
@Alexander "una pieza de código" es una medida arbitraria. Con solo inicializar una variable, está utilizando muchas "piezas de código". Lo importante es que esté probando una unidad de comportamiento coherente según lo definido por usted .
Ant P
99
"Por lo que he leído, tengo la impresión de que si rompes solo una parte del código, solo las pruebas unitarias directamente relacionadas con ese código deberían fallar". Esa parece ser una regla muy difícil de seguir. (por ejemplo, si escribe una clase de vector y comete un error en el método de índice, probablemente tendrá toneladas de roturas en todo el código que usa esa clase de vector)
jhominal
44
@Alexander Además, busque pruebas en el patrón "Organizar, Actuar, Afirmar". Básicamente, configura el objeto en cualquier estado en el que necesite estar (Organizar), llame al método que realmente está probando (Actuar) y luego verifique que su estado haya cambiado de acuerdo con sus expectativas. (Afirmar). Las cosas que configures en Arrange serían "condiciones previas" para la prueba.
GalacticCowboy
5

¿Cómo se resuelve exactamente este tipo de problemas?

Revisas tu pensamiento sobre lo que es una "prueba unitaria".

Un objeto que gestiona datos mutables en la memoria es fundamentalmente una máquina de estados. Por lo tanto, cualquier caso de uso valioso va a, como mínimo, invocar un método para poner información en el objeto e invocar un método para leer una copia de la información del objeto. En los casos de uso interesantes, también va a invocar métodos adicionales que cambien la estructura de datos.

En la práctica, esto a menudo parece

// GIVEN
obj = new Object(...)

// THEN
assert object.read(...)

o

// GIVEN
obj = new Object(...)

// WHEN
object.change(...)

// THEN
assert object.read(...)

La terminología de "prueba unitaria", bueno, tiene una larga historia de no ser muy buena.

Los llamo pruebas unitarias, pero no coinciden muy bien con la definición aceptada de pruebas unitarias: Kent Beck, Test Driven Development by Example

Kent escribió la primera versión de SUnit en 1994 , el puerto a JUnit fue en 1998, el primer borrador del libro TDD fue a principios de 2002. La confusión tuvo mucho tiempo para extenderse.

La idea clave de estas pruebas (más exactamente llamadas "pruebas de programador" o "pruebas de desarrollador") es que las pruebas están aisladas unas de otras. Las pruebas no comparten ninguna estructura de datos mutables, por lo que pueden ejecutarse simultáneamente. No le preocupa que las pruebas se ejecuten en un orden específico para medir correctamente la solución.

El caso de uso principal para estas pruebas es que el programador las ejecuta entre las ediciones de su propio código fuente. Si está realizando el protocolo de refactorización rojo verde, un ROJO inesperado siempre indica una falla en su última edición; revierte ese cambio, verifica que las pruebas son VERDES e intenta nuevamente. No hay muchas ventajas en tratar de invertir en un diseño en el que todos y cada uno de los posibles errores sean detectados por una sola prueba.

Por supuesto, si una fusión introduce una falla, encontrar esa falla ya no es trivial. Hay varios pasos que puede seguir para asegurarse de que las fallas sean fáciles de localizar. Ver

VoiceOfUnreason
fuente
1

En general (incluso si no usa TDD), debe esforzarse por escribir pruebas tanto como sea posible mientras finge que no sabe cómo se implementa.

Si realmente está haciendo TDD, ese ya debería ser el caso. Sus pruebas son una especificación ejecutable del programa.

El aspecto del gráfico de llamada debajo de las pruebas es irrelevante, siempre que las pruebas en sí sean sensatas y estén bien mantenidas.

Creo que su problema es su comprensión de TDD.

Su problema en mi opinión es que está "mezclando" sus personajes TDD. Sus personas de "prueba", "código" y "refactorizador" operan de manera completamente independiente, idealmente. En particular, sus personas de codificación y refactorización no tienen obligaciones con las pruebas que no sean hacer / mantenerlas funcionando en verde.

Claro, en principio, sería mejor si todas las pruebas fueran ortogonales e independientes entre sí. Pero eso no es una preocupación de sus otras dos personas TDD, y definitivamente no es un requisito estricto o incluso necesariamente realista de sus pruebas. Básicamente: no deseche sus sentimientos de sentido común sobre la calidad del código para tratar de cumplir un requisito que nadie le está pidiendo.

Tim Seguine
fuente