¿Cómo pruebo un sistema donde los objetos son difíciles de burlar?

34

Estoy trabajando con el siguiente sistema:

Network Data Feed -> Third Party Nio Library -> My Objects via adapter pattern

Recientemente tuvimos un problema en el que actualicé la versión de la biblioteca que estaba usando, lo que, entre otras cosas, causó que las marcas de tiempo (que la biblioteca de terceros devuelve long) cambien de milisegundos después de la época a nanosegundos después de la época.

El problema:

Si escribo pruebas que se burlan de los objetos de la biblioteca de terceros, mi prueba será incorrecta si he cometido un error sobre los objetos de la biblioteca de terceros. Por ejemplo, no me di cuenta de que las marcas de tiempo cambiaron la precisión, lo que resultó en la necesidad de un cambio en la prueba de la unidad, porque mi simulación devolvió los datos incorrectos. Esto no es un error en la biblioteca , sucedió porque me perdí algo en la documentación.

El problema es que no puedo estar seguro acerca de los datos contenidos en estas estructuras de datos porque no puedo generar los reales sin una fuente de datos real. Estos objetos son grandes y complicados y tienen muchos datos diferentes. La documentación para la biblioteca de terceros es deficiente.

La pregunta:

¿Cómo puedo configurar mis pruebas para probar este comportamiento? No estoy seguro de poder resolver este problema en una prueba unitaria, porque la prueba en sí misma puede ser incorrecta. Además, el sistema integrado es grande y complicado y es fácil perderse algo. Por ejemplo, en la situación anterior, había ajustado correctamente el manejo de la marca de tiempo en varios lugares, pero me perdí uno de ellos. El sistema parecía estar haciendo principalmente las cosas correctas en mi prueba de integración, pero cuando lo implementé en producción (que tiene muchos más datos), el problema se hizo evidente.

No tengo un proceso para mis pruebas de integración en este momento. La prueba es esencialmente: trate de mantener las pruebas unitarias buenas, agregue más pruebas cuando las cosas se rompan, luego implemente en mi servidor de prueba y asegúrese de que las cosas parezcan sensatas, luego implemente en producción. Este problema de marca de tiempo pasó las pruebas unitarias porque los simulacros se crearon incorrectamente, luego pasó la prueba de integración porque no causó ningún problema obvio e inmediato. No tengo un departamento de control de calidad.

durron597
fuente
3
¿Puede "grabar" una fuente de datos reales y "reproducirla" más tarde en la biblioteca de terceros?
Idan Arye
2
Alguien podría escribir un libro sobre problemas como este. De hecho, Michael Feathers escribió exactamente ese libro: c2.com/cgi/wiki?WorkingEffectivelyWithLegacyCode En él, describe una serie de técnicas para romper dependencias difíciles para que el código sea más comprobable.
cbojar
2
¿El adaptador alrededor de la biblioteca de terceros? Sí, eso es exactamente lo que recomiendo. Esas pruebas unitarias no mejorarán su código. No lo harán más confiable o más fácil de mantener. Solo estás duplicando parcialmente el código de otra persona en ese momento; en este caso, estás duplicando un código mal escrito del sonido del mismo. Esa es una pérdida neta. Algunas de las respuestas sugieren hacer algunas pruebas de integración; esa es una buena idea si solo quieres un "¿Funciona esto?" prueba de cordura. Una buena prueba es difícil, y requiere tanta habilidad e intuición como un buen código.
jpmc26
44
Una ilustración perfecta del mal de los empotrados. ¿Por qué no la biblioteca devuelve una Timestampclase (que contiene cualquier representación que quieren) y proporcionar métodos llamados ( .seconds(), .milliseconds(), .microseconds(), .nanoseconds()) y de constructores supuesto con nombre. Entonces no habría habido problemas.
Matthieu M.
2
El dicho "todos los problemas de codificación pueden ser resueltos por una capa de indirección (excepto, por supuesto, el problema de demasiadas capas de direccionamiento indirecto)" viene a la mente ..
Dan despensa

Respuestas:

27

Parece que ya estás haciendo la debida diligencia. Pero ...

En el nivel más práctico, siempre incluya un buen puñado de pruebas de integración de "bucle completo" en su suite para su propio código, y escriba más afirmaciones de las que cree que necesita. En particular, debe tener un puñado de pruebas que realicen un ciclo completo create-read- [do_stuff] -validate.

[TestMethod]
public void MyFormatter_FormatsTimesCorrectly() {

  // this test isn't necessarily about the stream or the external interpreter.
  // but ... we depend on them working how we think they work:
  var stream = new StreamThingy();
  var interpreter = new InterpreterThingy(stream);
  stream.Write("id-123, some description, 12345");

  // this is what you're actually testing. but, it'll also hiccup
  // if your 3rd party dependencies introduce a breaking change.
  var formatter = new MyFormatter(interpreter);
  var line = formatter.getLine();
  Assert.equal(
    "some description took 123.45 seconds to complete (id-123)", line
  );
}

Y parece que ya estás haciendo este tipo de cosas. Solo se trata de una biblioteca escamosa y / o complicada. Y en ese caso, es bueno incluir algunos tipos de pruebas "así es como funciona la biblioteca" que verifican su comprensión de la biblioteca y sirven como ejemplos de cómo usar la biblioteca.

Suponga que necesita comprender y depender de cómo un analizador JSON interpreta cada "tipo" en una cadena JSON. Es útil y trivial incluir algo como esto en su suite:

[TestMethod]
public void JSONParser_InterpretsTypesAsExpected() {
  String datastream = "{nbr:11,str:"22",nll:null,udf:undefined}";
  var o = (new JSONParser()).parse(datastream);

  Assert.equal(11, o.nbr);
  Assert.equal(Int32.getType(), o.nbr.getType());
  Assert.equal("22", o.str);
  Assert.equal(null, o.nll);
  Assert.equal(Object.getType(), o.nll.getType());
  Assert.isFalse(o.KeyExists(udf));
}

Pero en segundo lugar, recuerde que las pruebas automatizadas de cualquier tipo, y en casi cualquier nivel de rigor, aún no lo protegerán contra todos los errores. Es perfectamente común agregar pruebas a medida que descubre problemas. Al no tener un departamento de control de calidad, esto significa que muchos de esos problemas serán descubiertos por los usuarios finales.

Y en un grado significativo, eso es normal.

Y en tercer lugar, cuando una biblioteca cambia el significado de un valor de retorno o campo sin renombrar el campo o método o "romper" el código dependiente (tal vez cambiando su tipo), estaría muy descontento con ese editor. Y argumentaría que, aunque probablemente debería haber leído el registro de cambios si hay uno, probablemente también deba pasar algo de su estrés al editor. Yo diría que necesitan la crítica optimista y constructiva ...

svidgen
fuente
Ugh, desearía que fuera tan simple como alimentar una cadena json en la biblioteca. No es. No puedo hacer el equivalente de (new JSONParser()).parse(datastream), ya que obtienen los datos directamente de una NetworkInterfacey todas las clases que hacen el análisis real son privadas y protegidas.
durron597
Además, el registro de cambios no incluía el hecho de que cambiaron las marcas de tiempo de ms a ns, entre otros dolores de cabeza que no documentaron. Sí, estoy muy descontento con ellos, y les he expresado esto.
durron597
@ durron597 Oh, casi nunca lo es. Pero, a menudo puede falsificar la fuente de datos subyacente, como en el primer ejemplo de código. ... El punto es: hacer las pruebas de integración de aro completo, cuando sea posible, poner a prueba su comprensión de la biblioteca cuando sea posible, y acaba de ser conscientes de que va siendo dejar que los errores en el medio natural. Y sus proveedores externos deben ser responsables de hacer cambios invisibles y de última hora.
svidgen
@ durron597 No estoy familiarizado con NetworkInterface... ¿es algo en lo que pueda alimentar datos conectando la interfaz a un puerto en localhost o algo así?
svidgen
NetworkInterface. Es un objeto de bajo nivel para trabajar directamente con una tarjeta de red y abrir sockets, etc.
durron597
11

Respuesta corta: es difícil. Probablemente sienta que no hay buenas respuestas, y eso es porque no hay respuestas fáciles.

Respuesta larga: como dice @ptyx , necesita pruebas del sistema y pruebas de integración, así como pruebas unitarias:

  • Las pruebas unitarias son rápidas y fáciles de ejecutar. Detectan errores en secciones individuales de código y utilizan simulacros para que sea posible ejecutarlos. Por necesidad, no pueden detectar desajustes entre piezas de código (como milisegundos versus nanosegundos).
  • Las pruebas de integración y las pruebas del sistema son lentas (er) y difíciles (er) de ejecutar, pero detectan más errores.

Algunas sugerencias específicas:

  • Hay algún beneficio en simplemente obtener una prueba del sistema para ejecutar la mayor cantidad de sistema posible. Incluso si no puede validar mucho el comportamiento o hacer muy bien en identificar el problema. (Micheal Feathers analiza esto más en Trabajar eficazmente con código heredado ).
  • Invertir en comprobabilidad ayuda. Aquí puede utilizar una gran cantidad de técnicas: integración continua, scripts, máquinas virtuales, herramientas para reproducir, proxy o redirigir el tráfico de red.
  • Una de las ventajas (al menos para mí) de invertir en la comprobabilidad puede no ser obvia: si las pruebas son tediosas, molestas o engorrosas para escribir o ejecutar, entonces es demasiado fácil para mí saltearlas si estoy presionado o cansado Es importante mantener sus pruebas por debajo del umbral "Es tan fácil que no hay excusa para no hacer esto".
  • El software perfecto no es factible. Como todo lo demás, el esfuerzo dedicado a las pruebas es una compensación, y a veces no vale la pena el esfuerzo. Existen restricciones (como la falta de un departamento de control de calidad). Acepte que ocurrirán errores, recupere y aprenda.

He visto la programación descrita como la actividad de aprender sobre un problema y un espacio de solución. Lograr que todo sea perfecto con anticipación puede no ser factible, pero puede aprender después del hecho. ("Arreglé el manejo de la marca de tiempo en varios lugares pero perdí uno. ¿Puedo cambiar mis tipos de datos o clases para hacer que el manejo de la marca de tiempo sea más explícito y más difícil de omitir, o para hacerlo más centralizado para que solo tenga un lugar para cambiar? ¿Puedo modificar? mis pruebas para verificar más aspectos del manejo de la marca de tiempo? ¿Puedo simplificar mi entorno de prueba para que esto sea más fácil en el futuro? ¿Puedo imaginar alguna herramienta que lo hubiera hecho más fácil y, de ser así, puedo encontrarla en Google? "Etc.)

Josh Kelley
fuente
7

Actualicé la versión de la biblioteca ... que ... causó que las marcas de tiempo (que la biblioteca de terceros devuelve como long) cambien de milisegundos después de la época a nanosegundos después de la época.

...

Esto no es un error en la biblioteca.

Estoy totalmente en desacuerdo contigo aquí. Es un error en la biblioteca , de hecho bastante insidioso. Han cambiado el tipo semántico del valor de retorno, pero no cambiaron el tipo programático del valor de retorno. Esto puede causar todo tipo de estragos, especialmente si se trataba de una versión menor, pero incluso si se trataba de una grave.

Digamos, en cambio, que la biblioteca devolvió un tipo de MillisecondsSinceEpoch, un contenedor simple que contiene a long. Cuando lo cambiaron a un NanosecondsSinceEpochvalor, su código no pudo compilarse y obviamente lo habría señalado a los lugares donde necesita realizar cambios. El cambio no pudo corromper silenciosamente su programa.

Mejor aún sería un TimeSinceEpochobjeto que pudiera adaptar su interfaz a medida que se agregara más precisión, como agregar un #toLongNanosecondsmétodo junto con el #toLongMillisecondsmétodo, sin requerir ningún cambio en su código.

El siguiente problema es que no tiene un conjunto confiable de pruebas de integración a la biblioteca. Deberías escribir esos. Mejor sería crear una interfaz alrededor de esa biblioteca para encapsularla lejos del resto de su aplicación. Varias otras respuestas abordan esto (y más siguen apareciendo mientras escribo). Las pruebas de integración deben ejecutarse con menos frecuencia que las pruebas de su unidad. Por eso es útil tener una capa de protección. Separe sus pruebas de integración en un área separada (o asígneles un nombre diferente) para que pueda ejecutarlas según sea necesario, pero no cada vez que ejecute su prueba unitaria.

cbojar
fuente
2
@ durron597 Todavía afirmaría que es un error. Más allá de la falta de documentación, ¿por qué cambiar el comportamiento esperado? ¿Por qué no un nuevo método que proporciona la nueva precisión y permite que el método anterior siga proporcionando milis? ¿Y por qué no proporcionar una manera para que el compilador le avise a través de un cambio en el tipo de retorno? No hace falta mucho para aclarar esto, no solo en la documentación, sino también en el código.
cbojar
1
@gbjbaanb, "que tienen malas prácticas de liberación" me parece un error
Arturo Torres Sánchez
2
@gbjbaanb Una biblioteca de terceros [debería] hacer un "contrato" con sus usuarios. Romper ese contrato, ya sea documentado o no, puede / debe considerarse como un error. Como han dicho otros, si debe cambiar algo, agregue al contrato una nueva función / método ( ...Ex()consulte todos los métodos en Win32API). Si esto no es factible, "romper" el contrato cambiando el nombre de la función (o su tipo de retorno) hubiera sido mejor que alterar el comportamiento.
TripeHound
1
Este es un error en la biblioteca. Usar nanosegundos en mucho tiempo lo está empujando.
Joshua
1
@gbjbaanb Dices que no es un error ya que es el comportamiento previsto, incluso si es inesperado. En ese sentido, no es un error de implementación , pero es un error igual. Podría llamarse un defecto de diseño o un error de interfaz . Las fallas radican en el hecho de que expone una obsesión primitiva con unidades largas en lugar de unidades explícitas, su abstracción es permeable ya que exporta detalles de su implementación interna (que los datos se almacenan como una unidad larga) y que viola El principio del menor asombro con un cambio de unidad sutil.
cbojar
5

Necesita pruebas de integración y sistema.

Las pruebas unitarias son excelentes para verificar que su código se comporte como espera. Como se da cuenta, no hace nada para desafiar sus suposiciones o garantizar que sus expectativas sean sensatas.

A menos que su producto tenga poca interacción con sistemas externos, o interactúe con sistemas tan conocidos, estables y documentados que puedan ser burlados con confianza (esto rara vez ocurre en el mundo real), las pruebas unitarias no son suficientes.

Cuanto más alto sea el nivel de sus pruebas, más lo protegerán contra lo inesperado. Eso tiene un costo (conveniencia, velocidad, fragilidad ...), por lo que las pruebas unitarias deben seguir siendo la base de sus pruebas, pero necesita otras capas, que incluyen, eventualmente, un poco de prueba en humanos que ayuda mucho a atrapar cosas estúpidas en las que nadie pensó.

ptyx
fuente
2

Lo mejor sería crear un prototipo mínimo y comprender exactamente cómo funciona la biblioteca. Al hacerlo, obtendrá algunos conocimientos sobre la biblioteca con poca documentación. Un prototipo puede ser un programa minimalista que usa esa biblioteca y hace la funcionalidad.

De lo contrario, no tiene sentido escribir pruebas unitarias, con requisitos medio definidos y una comprensión débil del sistema.

En cuanto a su problema específico: sobre el uso de métricas incorrectas: lo trataría como un cambio de requisitos. Una vez que haya reconocido el problema, cambie las pruebas unitarias y el código.

BЈовић
fuente
1

Si usabas una biblioteca popular y estable, entonces podrías suponer que no te jugará trucos desagradables. Pero si cosas como lo que describiste suceden con esta biblioteca, entonces obviamente, esta no es una. Después de esta mala experiencia, cada vez que algo sale mal en su interacción con esta biblioteca, deberá examinar no solo la posibilidad de que haya cometido un error, sino también la posibilidad de que la biblioteca haya cometido un error. Entonces, digamos que esta es una biblioteca de la que no está seguro.

Una de las técnicas empleadas con las bibliotecas de las que no estamos seguros es construir una capa intermedia entre nuestro sistema y dichas bibliotecas, que abstrae la funcionalidad ofrecida por las bibliotecas, afirma que nuestras expectativas de la biblioteca son correctas y también simplifica enormemente nuestra vida en el futuro, si decidimos darle el arranque a esa biblioteca y reemplazarla con otra biblioteca que se comporte mejor.

Mike Nakis
fuente
Esto realmente no responde la pregunta. Ya tengo una capa que separa la biblioteca de mi sistema, pero el problema es que mi capa de abstracción puede tener "errores" cuando la biblioteca cambia sobre mí sin previo aviso.
durron597
1
@ durron597 Entonces, tal vez la capa no esté aislando suficientemente la biblioteca del resto de su aplicación. Si descubre que le está costando probar esa capa, tal vez necesite simplificar el comportamiento y aislar más fuertemente los datos subyacentes.
cbojar
Lo que dijo @cbojar. Además, permítame repetir algo que puede haber pasado desapercibido en el texto anterior: la assertpalabra clave (o función o facilidad, según el idioma que esté usando) es su amigo. No estoy hablando de aserciones en las pruebas de unidad / integración, estoy diciendo que la capa de aislamiento debe estar muy cargada de aserciones, afirmando todo lo afirmable sobre el comportamiento de la biblioteca.
Mike Nakis
Estas afirmaciones no se ejecutan necesariamente en ejecuciones de producción, pero se ejecutan durante las pruebas, teniendo una vista de recuadro blanco de su capa de aislamiento y, por lo tanto, poder asegurarse (tanto como sea posible) de que la información que su capa recibe de la biblioteca es sonido.
Mike Nakis