¿Se deben codificar los resultados esperados de la prueba unitaria?

30

¿Deben codificarse los resultados esperados de una prueba unitaria, o pueden depender de variables inicializadas? ¿Los resultados codificados o calculados aumentan el riesgo de introducir errores en la prueba unitaria? ¿Hay otros factores que no he considerado?

Por ejemplo, ¿cuál de estos dos es un formato más confiable?

[TestMethod]
public void GetPath_Hardcoded()
{
    MyClass target = new MyClass("fields", "that later", "determine", "a folder");
    string expected = "C:\\Output Folder\\fields\\that later\\determine\\a folder";
    string actual = target.GetPath();
    Assert.AreEqual(expected, actual,
        "GetPath should return a full directory path based on its fields.");
}

[TestMethod]
public void GetPath_Softcoded()
{
    MyClass target = new MyClass("fields", "that later", "determine", "a folder");
    string expected = "C:\\Output Folder\\" + string.Join("\\", target.Field1, target.Field2, target.Field3, target.Field4);
    string actual = target.GetPath();
    Assert.AreEqual(expected, actual,
        "GetPath should return a full directory path based on its fields.");
}

EDITAR 1: En respuesta a la respuesta de DXM, ¿es la opción 3 una solución preferida?

[TestMethod]
public void GetPath_Option3()
{
    string field1 = "fields";
    string field2 = "that later";
    string field3 = "determine";
    string field4 = "a folder";
    MyClass target = new MyClass(field1, field2, field3, field4);
    string expected = "C:\\Output Folder\\" + string.Join("\\", field1, field2, field3, field4);
    string actual = target.GetPath();
    Assert.AreEqual(expected, actual,
        "GetPath should return a full directory path based on its fields.");
}
Hand-E-Food
fuente
2
Haz ambos. Seriamente. Las pruebas pueden y deben superponerse. También busque algún tipo de prueba basada en datos si se encuentra con valores codificados.
Trabajo
Estoy de acuerdo en que la tercera opción es lo que me gusta usar. No creo que la opción 1 duela ya que eliminas la manipulación en la compilación.
kwelch
Sin embargo, ambas opciones usan codificación fija y se romperán si la prueba no se ejecuta en C: \\
Qwertie

Respuestas:

27

Creo que el valor esperado calculado da como resultado casos de prueba más robustos y flexibles. Además, al usar buenos nombres de variables en la expresión que calculan el resultado esperado, es mucho más claro de dónde vino el resultado esperado en primer lugar.

Dicho esto, en su ejemplo específico NO confiaría en el método "Softcoded" porque usa su SUT (sistema bajo prueba) como entrada para sus cálculos. Si hay un error en MyClass donde los campos no se almacenan correctamente, su prueba realmente pasará porque su cálculo de valor esperado usará la cadena incorrecta al igual que target.GetPath ().

Mi sugerencia sería calcular el valor esperado donde tenga sentido, pero asegúrese de que el cálculo no dependa de ningún código del SUT.

En respuesta a la actualización de OP a mi respuesta:

Sí, según mi conocimiento pero con una experiencia algo limitada en hacer TDD, elegiría la opción # 3.

DXM
fuente
1
¡Buen punto! No confíe en el objeto no verificado en la prueba.
Hand-E-Food
¿No es una duplicación del código SUT?
Abyx
1
en cierto modo lo es, pero así es como se verifica que SUT está funcionando. Si tuviéramos que usar el mismo código y se rompiera, nunca lo sabríamos. Por supuesto, si para realizar el cálculo, necesita duplicar una gran cantidad de SUT, entonces tal vez la opción # 1 sería mejor, solo codifique el valor.
DXM
16

¿Qué pasa si el código es el siguiente?

MyTarget() // constructor
{
   Field1 = Field2 = Field3 = Field4 = "";
}

Su segundo ejemplo no detectaría el error, pero el primer ejemplo sí.

En general, recomendaría contra la codificación suave porque puede ocultar errores. Por ejemplo:

string expected = "C:\\Output Folder" + string.Join("\\", target.Field1, target.Field2, target.Field3, target.Field4);

¿Puedes detectar el problema? No cometerías el mismo error en una versión codificada. Es más difícil obtener los cálculos correctos que los valores codificados. Es por eso que prefiero trabajar con valores codificados que con valores codificados.

Pero hay excepciones. ¿Qué pasa si su código tiene que ejecutarse en Windows y Linux? ¡La ruta no solo tendrá que ser diferente, sino que también deberá usar separadores de ruta diferentes! Calcular el camino usando funciones que abstraen la diferencia entre podría tener sentido en ese contexto.

Winston Ewert
fuente
Escucho lo que dices y eso me da algo para considerar. Softcoding se basa en mis otros casos de prueba (como ConstructorShouldCorrectlyInitialiseFields). El fallo que describa estaría referenciado por otros fallos de pruebas unitarias.
Hand-E-Food
@ Hand-E-Food, parece que está escribiendo pruebas sobre métodos individuales de sus objetos. No lo hagas Debería escribir pruebas que verifiquen la corrección de todo su objeto en conjunto, no métodos individuales. De lo contrario, sus pruebas serán frágiles con respecto a los cambios dentro del objeto.
Winston Ewert
No estoy seguro de seguirlo. El ejemplo que di fue puramente hipotético, un escenario fácil de entender. Estoy escribiendo pruebas unitarias para evaluar a miembros públicos de clases y objetos. ¿Es esa la forma correcta de usarlos?
Hand-E-Food
@ Hand-E-Food, si lo entiendo correctamente, su prueba ConstructShouldCorrectlyInitialiseFields invocaría al constructor y luego afirmaría que los campos están configurados correctamente. Pero no deberías hacer eso. No debería importarle lo que están haciendo los campos internos. Solo debe afirmar que el comportamiento externo del objeto es correcto. De lo contrario, puede llegar el día en que necesite reemplazar la implementación interna. Si ha hecho afirmaciones sobre el estado interno, todas sus pruebas se romperán. Pero si solo ha hecho afirmaciones sobre el comportamiento externo, todo seguirá funcionando.
Winston Ewert
@ Winston: en realidad estoy en el proceso de leer el libro de patrones de prueba de xUnit y antes de eso terminé El arte de las pruebas unitarias. No voy a fingir que sé de lo que estoy hablando, pero me gustaría pensar que tomé algo de esos libros. Ambos libros recomiendan encarecidamente que cada método de prueba pruebe el mínimo absoluto y que tenga muchos casos de prueba para probar todo su objeto. De esa manera, cuando las interfaces o la funcionalidad cambien, solo debe esperar corregir algunos métodos de prueba, en lugar de la mayoría de ellos. Y como son pequeños, los cambios deberían ser más fáciles.
DXM
4

En mi opinión, ambas sugerencias son menos que ideales. La forma ideal de hacerlo es esta:

[TestMethod]
public void GetPath_Hardcoded()
{
    const string f1 = "fields"; const string f2 = "that later"; 
    const string f3 = "determine"; const string f4 = "a folder";

    MyClass target = new MyClass( f1, f2, f3, f4 );
    string expected = "C:\\Output Folder\\" + string.Join("\\", f1, f2, f3, f4);
    string actual = target.GetPath();
    Assert.AreEqual(expected, actual,
        "GetPath should return a full directory path based on its fields.");
}

En otras palabras, la prueba debe funcionar exclusivamente en función de la entrada y la salida del objeto, y no en función del estado interno del objeto. El objeto debe tratarse como un cuadro negro. (No tengo en cuenta otros problemas, como lo inapropiado de usar string.Join en lugar de Path.Combine, porque esto es solo un ejemplo).

Mike Nakis
fuente
1
No todos los métodos son funcionales: muchos tienen correctamente efectos secundarios que cambian el estado de algunos objetos. Una prueba unitaria para un método con efectos secundarios probablemente necesitaría evaluar el estado de los objetos afectados por el método.
Matthew Flynn
Entonces ese estado se consideraría como la salida del método. La intención de esta prueba de muestra es verificar el método GetPath (), no el constructor de MyClass. Lea la respuesta de @ DXM, él proporciona una muy buena razón para adoptar el enfoque de caja negra.
Mike Nakis
@MatthewFlynn, entonces debes probar los métodos afectados por ese estado. El estado interno exacto es un detalle de implementación y ninguno de los negocios de la prueba.
Winston Ewert
@MatthewFlynn, solo para aclarar, ¿está relacionado con el ejemplo que se muestra o con algo más a considerar para otras pruebas unitarias? Pude ver que importa algo como target.Dispose(); Assert.IsTrue(target.IsDisposed);(un ejemplo muy simple)
Hand-E-Food
Incluso en este caso, la propiedad IsDisposed es (o debería ser) una parte indispensable de la interfaz pública de la clase, y no un detalle de implementación. (La interfaz IDispose no proporciona esa propiedad, pero es desafortunado)
Mike Nakis
2

Hay dos aspectos en la discusión:

1. Uso del objetivo en sí para el caso de prueba
La primera pregunta es ¿debería / puede usar la clase en sí misma para confiar y realizar parte del trabajo en el trozo de prueba? - La respuesta es NO ya que, en general, nunca debe asumir el código que está probando. Si esto no se hace correctamente, con el tiempo los errores se vuelven inmunes a algunas pruebas unitarias.

2. Hardcoding
debería codificar duro ? De nuevo la respuesta es no . porque, como cualquier software, la codificación dura de la información se vuelve difícil cuando las cosas evolucionan. Por ejemplo, cuando desea que la ruta anterior se modifique nuevamente, debe escribir una unidad adicional o seguir modificando. Un mejor método es mantener la entrada y la fecha de evaluación derivadas de la configuración separada que se puede adaptar fácilmente.

Por ejemplo, así es como corregiría el trozo de prueba.

[TestMethod]
public void GetPath_Tested(int CaseId)
{
    testParams = GetTestConfig(caseID,"testConfig.txt"); // some wrapper that does read line and chops the field. 
    MyClass target = new MyClass(testParams.field1, testParams.field2);
    string expected = testParams.field5;
    string actual = target.GetPath();
    Assert.AreEqual(expected, actual,
        "GetPath should return a full directory path based on its fields.");
}
Dipan Mehta
fuente
0

Hay muchos conceptos posibles, hicimos algunos ejemplos para ver la diferencia

[TestMethod]
public void GetPath_Softcoded()
{
    //Hardcoded since you want to see what you expect is most simple and clear
    string expected = "C:\\Output Folder\\fields\\that later\\determine\\a folder";

    //If this test should also use a mocked filesystem it might be that you want to use
    //some base directory, which you could set in the setUp of your test class
    //that is usefull if you you need to run the same test on different environments
    string expected = this.outputPath + "fields\\that later\\determine\\a folder";


    //another readable way could be interesting if you have difficult variables needed to test
    string fields = "fields";
    string thatLater = "that later";
    string determine = "determine";
    string aFolder = "a folder";
    string expected = this.outputPath + fields + "\\" + thatLater + "\\" + determine + "\\" + aFolder;
    MyClass target = new MyClass(fields, thatLater, determine, aFolder);

    //in general testing with real words is not needed, so code could be shorter on that
    //for testing difficult folder names you write a separate test anyway
    string f1 = "f1";
    string f2 = "f2";
    string f3 = "f3";
    string f4 = "f4";
    string expected = this.outputPath + f1 + "\\" + f2 + "\\" + f3 + "\\" + f4;
    MyClass target = new MyClass(f1, f2, f3, f4);

    //so here we start to see a structure, it looks more like an array of fields
    //so what would make testing more interesting with lots of variables is the use of a data provider
    //the data provider will re-use your test with many different kinds of inputs. That will reduce the amount of duplication of code for testing
    //http://msdn.microsoft.com/en-us/library/ms182527.aspx


    The part where you compare already seems correct
    MyClass target = new MyClass(fields, thatLater, determine, aFolder);

    string actual = target.GetPath();
    Assert.AreEqual(expected, actual,
        "GetPath should return a full directory path based on its fields.");
}

Para resumir: en general, su primera prueba codificada tiene más sentido para mí porque es simple, directo al punto, etc. Si comienza a codificar una ruta demasiadas veces, simplemente póngala en el método de configuración.

Para obtener más pruebas estructuradas futuras, iría a consultar fuentes de datos para que pueda agregar más filas de datos si necesita más situaciones de prueba.

Luc Franken
fuente
0

Los marcos de prueba modernos le permiten proporcionar parámetros a su método. Aprovecharía esos:

[TestCase("fields", "that later", "determine", "a folder", @"C:\Output Folder\fields\that later\determine\a folder")]
public void GetPathShouldReturnFullDirectoryPathBasedOnItsFields(
    string field1, string field2, string field3, string field,
    string expected)
{
    MyClass target = new MyClass(field1, field2, field3, field4);
    string actual = target.GetPath();
    Assert.AreEqual(expected, actual,
        "GetPath should return a full directory path based on its fields.");
}

Hay varias ventajas para esto, en mi opinión:

  1. Los desarrolladores a menudo se ven tentados a copiar las partes aparentemente simples del código de su SUT en sus pruebas unitarias. Como señala Winston , esos todavía pueden tener errores engañosos ocultos en ellos. "Codificar" el resultado esperado ayuda a evitar situaciones en las que su código de prueba es incorrecto por la misma razón que su código original es incorrecto. Pero si un cambio en los requisitos lo obliga a rastrear cadenas codificadas incrustadas dentro de docenas de métodos de prueba, eso puede ser molesto. Tener todos los valores codificados en un solo lugar, fuera de su lógica de prueba, le brinda lo mejor de ambos mundos.
  2. Puede agregar pruebas para diferentes entradas y salidas esperadas con una sola línea de código. Esto lo alienta a escribir más pruebas, mientras mantiene su código de prueba SECO y fácil de mantener. Creo que debido a que es muy barato agregar pruebas, mi mente está abierta a nuevos casos de prueba en los que no habría pensado si hubiera tenido que escribir un método completamente nuevo para ellos. Por ejemplo, ¿qué comportamiento esperaría si una de las entradas tuviera un punto? ¿Una barra invertida? ¿Qué pasa si uno estaba vacío? O espacio en blanco? ¿O comenzó o terminó con espacios en blanco?
  3. El marco de prueba tratará cada TestCase como su propia prueba, incluso poniendo las entradas y salidas proporcionadas en el nombre de la prueba. Si todos los TestCases pasan menos uno, es muy fácil ver cuál se rompió y en qué se diferenciaba de todos los demás.
StriplingWarrior
fuente