Código de prueba de unidad con una dependencia del sistema de archivos

138

Estoy escribiendo un componente que, dado un archivo ZIP, necesita:

  1. Descomprime el archivo.
  2. Encuentra una dll específica entre los archivos descomprimidos.
  3. Cargue ese dll a través de la reflexión e invoque un método sobre él.

Me gustaría probar unitariamente este componente.

Estoy tentado a escribir código que trate directamente con el sistema de archivos:

void DoIt()
{
   Zip.Unzip(theZipFile, "C:\\foo\\Unzipped");
   System.IO.File myDll = File.Open("C:\\foo\\Unzipped\\SuperSecret.bar");
   myDll.InvokeSomeSpecialMethod();
}

Pero la gente suele decir: "No escriba pruebas unitarias que dependan del sistema de archivos, base de datos, red, etc."

Si tuviera que escribir esto de una manera amigable para las pruebas unitarias, supongo que se vería así:

void DoIt(IZipper zipper, IFileSystem fileSystem, IDllRunner runner)
{
   string path = zipper.Unzip(theZipFile);
   IFakeFile file = fileSystem.Open(path);
   runner.Run(file);
}

¡Hurra! Ahora es comprobable; Puedo alimentar en dobles de prueba (simulacros) al método DoIt. ¿Pero a qué precio? Ahora he tenido que definir 3 nuevas interfaces solo para hacer que esto sea comprobable. ¿Y qué estoy probando exactamente? Estoy probando que mi función DoIt interactúa correctamente con sus dependencias. No prueba que el archivo zip se haya descomprimido correctamente, etc.

Ya no parece que esté probando la funcionalidad. Parece que solo estoy probando las interacciones de clase.

Mi pregunta es la siguiente : ¿cuál es la forma correcta de probar la unidad algo que depende del sistema de archivos?

editar Estoy usando .NET, pero el concepto podría aplicar Java o código nativo también.

Judá Gabriel Himango
fuente
8
La gente dice que no escriba en el sistema de archivos en una prueba unitaria porque si está tentado a escribir en el sistema de archivos, no está entendiendo qué constituye una prueba unitaria. Una prueba unitaria generalmente interactúa con un único objeto real (la unidad bajo prueba) y todas las demás dependencias se burlan y se pasan. La clase de prueba consiste en métodos de prueba que validan las rutas lógicas a través de los métodos del objeto y SOLAMENTE las rutas lógicas en La unidad bajo prueba.
Christopher Perry
1
en su situación, la única parte que necesita pruebas unitarias sería myDll.InvokeSomeSpecialMethod();donde verificaría que funciona correctamente tanto en situaciones de éxito como de fracaso, por lo que no haría una prueba unitaria, DoItpero DllRunner.Rundicho mal uso de una prueba UNIT para verificar que todo el proceso funcione un mal uso aceptable y, como sería una prueba de integración enmascarando una prueba unitaria, las reglas normales de la prueba unitaria no necesitan aplicarse estrictamente
MikeT

Respuestas:

47

Realmente no hay nada de malo en esto, es solo una cuestión de si lo llaman prueba de unidad o prueba de integración. Solo tiene que asegurarse de que si interactúa con el sistema de archivos, no haya efectos secundarios no deseados. Específicamente, asegúrese de limpiar después de usted mismo, elimine los archivos temporales que haya creado y de que no sobrescriba accidentalmente un archivo existente que tenga el mismo nombre de archivo que un archivo temporal que estaba usando. Utilice siempre rutas relativas y no rutas absolutas.

También sería una buena idea chdir()ingresar a un directorio temporal antes de ejecutar su prueba, y chdir()luego volver.

Adam Rosenfield
fuente
27
+1, sin embargo, tenga en cuenta que chdir()abarca todo el proceso, por lo que podría interrumpir la capacidad de ejecutar sus pruebas en paralelo, si su marco de prueba o una versión futura del mismo lo admite.
69

¡Hurra! Ahora es comprobable; Puedo alimentar en dobles de prueba (simulacros) al método DoIt. ¿Pero a qué precio? Ahora he tenido que definir 3 nuevas interfaces solo para hacer que esto sea comprobable. ¿Y qué estoy probando exactamente? Estoy probando que mi función DoIt interactúa correctamente con sus dependencias. No prueba que el archivo zip se haya descomprimido correctamente, etc.

Has golpeado el clavo justo en la cabeza. Lo que desea probar es la lógica de su método, no necesariamente si se puede abordar un archivo verdadero. No necesita probar (en esta prueba unitaria) si un archivo está descomprimido correctamente, su método lo da por sentado. Las interfaces son valiosas en sí mismas porque proporcionan abstracciones contra las que puede programar, en lugar de depender implícita o explícitamente de una implementación concreta.

andreas buykx
fuente
12
La DoItfunción comprobable como se indica ni siquiera necesita pruebas. Como señaló correctamente el interrogador, no queda nada de importancia para probar. Ahora es la implementación de IZipper, IFileSystemy IDllRunnereso necesita pruebas, ¡pero son las cosas que se han burlado para la prueba!
Ian Goldby
56

Su pregunta expone una de las partes más difíciles de las pruebas para los desarrolladores que solo se involucran:

"¿Qué demonios pruebo?"

Su ejemplo no es muy interesante porque simplemente pega algunas llamadas de API juntas, por lo que si escribiera una prueba unitaria para ello, terminaría simplemente afirmando que se llamaron a los métodos. Pruebas como esta combinan estrechamente los detalles de su implementación con la prueba. ¡Esto es malo porque ahora tiene que cambiar la prueba cada vez que cambia los detalles de implementación de su método porque cambiar los detalles de implementación interrumpe su (s) prueba (s)!

Tener malas pruebas es en realidad peor que no tener ninguna prueba.

En tu ejemplo:

void DoIt(IZipper zipper, IFileSystem fileSystem, IDllRunner runner)
{
   string path = zipper.Unzip(theZipFile);
   IFakeFile file = fileSystem.Open(path);
   runner.Run(file);
}

Si bien puede pasar simulacros, no hay lógica en el método para probar. Si intentara una prueba unitaria para esto, podría verse así:

// Assuming that zipper, fileSystem, and runner are mocks
void testDoIt()
{
  // mock behavior of the mock objects
  when(zipper.Unzip(any(File.class)).thenReturn("some path");
  when(fileSystem.Open("some path")).thenReturn(mock(IFakeFile.class));

  // run the test
  someObject.DoIt(zipper, fileSystem, runner);

  // verify things were called
  verify(zipper).Unzip(any(File.class));
  verify(fileSystem).Open("some path"));
  verify(runner).Run(file);
}

Felicitaciones, básicamente copió y pegó los detalles de implementación de su DoIt()método en una prueba. Feliz mantenimiento

Cuando escribes pruebas quieres probar el QUÉ y no el CÓMO . Ver Prueba de caja negra para más.

El WHAT es el nombre de su método (o al menos debería serlo). El CÓMO son todos los pequeños detalles de implementación que viven dentro de su método. Las buenas pruebas le permiten cambiar el CÓMO sin romper el QUÉ .

Piénselo de esta manera, pregúntese:

"Si cambio los detalles de implementación de este método (sin alterar el contrato público) ¿se romperán mis pruebas?"

Si la respuesta es sí, está probando el CÓMO y no el QUÉ .

Para responder a su pregunta específica sobre la prueba de código con dependencias del sistema de archivos, supongamos que tiene algo más interesante que hacer con un archivo y desea guardar el contenido codificado de Base64 de byte[]a en un archivo. Puede usar transmisiones para esto para probar que su código hace lo correcto sin tener que verificar cómo lo hace. Un ejemplo podría ser algo como esto (en Java):

interface StreamFactory {
    OutputStream outStream();
    InputStream inStream();
}

class Base64FileWriter {
    public void write(byte[] contents, StreamFactory streamFactory) {
        OutputStream outputStream = streamFactory.outStream();
        outputStream.write(Base64.encodeBase64(contents));
    }
}

@Test
public void save_shouldBase64EncodeContents() {
    OutputStream outputStream = new ByteArrayOutputStream();
    StreamFactory streamFactory = mock(StreamFactory.class);
    when(streamFactory.outStream()).thenReturn(outputStream);

    // Run the method under test
    Base64FileWriter fileWriter = new Base64FileWriter();
    fileWriter.write("Man".getBytes(), streamFactory);

    // Assert we saved the base64 encoded contents
    assertThat(outputStream.toString()).isEqualTo("TWFu");
}

La prueba utiliza un ByteArrayOutputStreamsino en la aplicación (mediante la inyección de dependencias) del StreamFactory real (tal vez llamado FileStreamFactory) devolvería FileOutputStreamdesde outputStream()y escribiría a una File.

Lo interesante del writemétodo aquí es que estaba escribiendo los contenidos codificados en Base64, así que eso es lo que probamos. Para su DoIt()método, esto se probaría más adecuadamente con una prueba de integración .

Christopher Perry
fuente
1
No estoy seguro de estar de acuerdo con su mensaje aquí. ¿Estás diciendo que no hay necesidad de probar unitariamente este tipo de método? Entonces, ¿básicamente estás diciendo que TDD es malo? Como si hicieras TDD, entonces no puedes escribir este método sin escribir primero una prueba. ¿O debe confiar en la intuición de que su método no requerirá una prueba? La razón por la cual TODOS los marcos de prueba de la unidad incluyen una función de "verificación" es que está bien usarla. "Esto es malo porque ahora tienes que cambiar la prueba cada vez que cambias los detalles de implementación de tu método" ... bienvenido al mundo de las pruebas unitarias.
Ronnie
2
Se supone que debe probar el CONTRATO de un método, no su implementación. Si tiene que cambiar su prueba cada vez que cambia la implementación de ese contrato, entonces se encontrará en un momento horrible manteniendo tanto la base del código de la aplicación como la base del código de la prueba.
Christopher Perry
@Ronnie aplicar ciegamente las pruebas unitarias no es útil. Hay proyectos de naturaleza muy variada, y las pruebas unitarias no son efectivas en todos ellos. Como ejemplo, estoy trabajando en un proyecto en el que el 95% del código trata sobre los efectos secundarios (tenga en cuenta que esta naturaleza de efectos secundarios es obligatoria , es una complejidad esencial, no incidental , ya que recopila datos de una amplia variedad de fuentes con estado y lo presenta con muy poca manipulación, por lo que casi no hay lógica pura). Las pruebas unitarias no son efectivas aquí, las pruebas de integración sí lo son.
Vicky Chijwani
Los efectos secundarios deben llevarse a los bordes de su sistema, no deben entrelazarse en todas las capas. En los bordes se prueban los efectos secundarios, que son comportamientos. En cualquier otro lugar, debe intentar tener funciones puras sin efectos secundarios, que se prueban y razonan, reutilizan y componen fácilmente.
Christopher Perry
24

Soy reticente a contaminar mi código con tipos y conceptos que existen solo para facilitar las pruebas unitarias. Claro, si hace que el diseño sea más limpio y mejor, entonces genial, pero creo que a menudo ese no es el caso.

Mi opinión sobre esto es que sus pruebas unitarias harían todo lo posible, lo que puede no ser una cobertura del 100%. De hecho, solo puede ser del 10%. El punto es que las pruebas unitarias deben ser rápidas y no tener dependencias externas. Pueden probar casos como "este método arroja una excepción ArgumentNullException cuando pasa nulo para este parámetro".

Luego agregaría pruebas de integración (también automatizadas y probablemente usando el mismo marco de prueba de unidad) que pueden tener dependencias externas y probar escenarios de extremo a extremo como estos.

Al medir la cobertura del código, mido las pruebas unitarias y de integración.

Kent Boogaart
fuente
55
Si, te escucho. Hay un mundo extraño al que llegas donde te desacoplas tanto, que todo lo que te queda son invocaciones de métodos en objetos abstractos. Pelusa aireada. Cuando llegas a este punto, no parece que realmente estés probando algo real. Solo estás probando interacciones entre clases.
Judá Gabriel Himango
66
Esta respuesta está equivocada. Las pruebas unitarias no son como el glaseado, es más como el azúcar. Está horneado en el pastel. Es parte de escribir su código ... una actividad de diseño. Por lo tanto, nunca "contamina" su código con nada que pueda "facilitar la prueba" porque la prueba es lo que le facilita escribir su código. El 99% del tiempo una prueba es difícil de escribir porque el desarrollador escribió el código antes de la prueba y terminó escribiendo un código mal verificable
Christopher Perry
1
@ Christopher: para extender tu analogía, no quiero que mi pastel termine pareciéndose a una rebanada de vainilla solo para poder usar azúcar. Todo lo que estoy abogando es el pragmatismo.
Kent Boogaart
1
@Christopher: tu biografía lo dice todo: "Soy un fanático de TDD". Yo, por otro lado, soy pragmático. Hago TDD donde encaja y no donde no: nada en mi respuesta sugiere que no hago TDD, aunque pareces pensar que sí. Y ya sea TDD o no, no introduciré grandes cantidades de complejidad en aras de facilitar las pruebas.
Kent Boogaart
3
@ChristopherPerry ¿Puede explicar cómo resolver el problema original del OP en una forma TDD? Me encuentro con esto todo el tiempo; Necesito escribir una función cuyo único propósito sea realizar una acción con una dependencia externa, como en esta pregunta. Entonces, incluso en el escenario de escribir primero la prueba, ¿cuál sería esa prueba?
Dax Fohl
8

No hay nada de malo en golpear el sistema de archivos, solo considérelo una prueba de integración en lugar de una prueba unitaria. Cambiaría la ruta codificada por una ruta relativa y crearía una subcarpeta TestData para contener las cremalleras para las pruebas unitarias.

Si las pruebas de integración tardan demasiado en ejecutarse, sepárelas para que no se ejecuten con tanta frecuencia como las pruebas de unidades rápidas.

Estoy de acuerdo, a veces creo que las pruebas basadas en la interacción pueden causar demasiado acoplamiento y, a menudo, no proporcionan suficiente valor. Realmente desea probar descomprimir el archivo aquí, no solo verificar que está llamando a los métodos correctos.

JC
fuente
La frecuencia con la que corren es de poca preocupación; Utilizamos un servidor de integración continua que los ejecuta automáticamente para nosotros. Realmente no nos importa cuánto tardan. Si "cuánto tiempo ejecutar" no es una preocupación, ¿hay alguna razón para distinguir entre las pruebas unitarias y de integración?
Judá Gabriel Himango
44
Realmente no. Pero si los desarrolladores quieren ejecutar rápidamente todas las pruebas unitarias localmente, es bueno tener una manera fácil de hacerlo.
JC.
6

Una forma sería escribir el método de descompresión para tomar InputStreams. Luego, la prueba unitaria podría construir tal InputStream a partir de una matriz de bytes usando ByteArrayInputStream. El contenido de esa matriz de bytes podría ser una constante en el código de prueba de la unidad.

nsayer
fuente
Ok, eso permite la inyección de la corriente. Inyección de dependencia / COI. ¿Qué tal la parte de descomprimir la secuencia en archivos, cargar un dll entre esos archivos y llamar a un método en ese dll?
Judá Gabriel Himango
3

Esto parece ser más una prueba de integración, ya que depende de un detalle específico (el sistema de archivos) que podría cambiar, en teoría.

Resumiría el código que trata con el sistema operativo en su propio módulo (clase, ensamblaje, jar, lo que sea). En su caso, desea cargar una DLL específica si la encuentra, por lo tanto, cree una interfaz IDllLoader y una clase DllLoader. Haga que su aplicación adquiera la DLL del DllLoader usando la interfaz y pruebe que ... no es responsable del código de descompresión después de todo, ¿verdad?

grifo
fuente
2

Suponiendo que las "interacciones del sistema de archivos" se prueban bien en el propio marco, cree su método para trabajar con secuencias y pruébelo. Abrir un FileStream y pasarlo al método puede quedar fuera de sus pruebas, ya que FileStream.Open está bien probado por los creadores del framework.

Sunny Milenov
fuente
Usted y nsayer tienen esencialmente la misma sugerencia: hacer que mi código funcione con transmisiones. ¿Qué tal la parte sobre descomprimir el contenido de la secuencia en archivos dll, abrir ese dll y llamar a una función en él? Qué harías allí?
Judá Gabriel Himango
3
@JudahHimango. Esas partes pueden no ser necesariamente comprobables. No puedes probar todo. Resuma los componentes no comprobables en sus propios bloques funcionales y suponga que funcionarán. Cuando encuentre un error con la forma en que funciona este bloque, idee una prueba y listo. Las pruebas unitarias NO significan que tiene que probar todo. La cobertura del 100% del código no es realista en algunos escenarios.
Zoran Pavlovic
1

No debe probar la interacción de clase y la llamada a funciones. en su lugar, debería considerar las pruebas de integración. Pruebe el resultado requerido y no la operación de carga de archivos.

Dror Helper
fuente
1

Para la prueba unitaria, sugeriría que incluya el archivo de prueba en su proyecto (archivo EAR o equivalente) y luego use una ruta relativa en las pruebas unitarias, es decir, "../testdata/testfile".

Siempre y cuando su proyecto se exporte / importe correctamente, su prueba unitaria debería funcionar.

James Anderson
fuente
0

Como han dicho otros, el primero está bien como prueba de integración. La segunda prueba solo lo que se supone que debe hacer la función, que es todo lo que debe hacer una prueba unitaria.

Como se muestra, el segundo ejemplo parece un poco inútil, pero le da la oportunidad de probar cómo responde la función a los errores en cualquiera de los pasos. No tiene ninguna comprobación de errores en el ejemplo, pero en el sistema real que puede tener, y la inyección de dependencia le permitiría probar todas las respuestas a cualquier error. Entonces el costo habrá valido la pena.

David Sykes
fuente