¿Cómo funciona la prueba unitaria?

23

Estoy tratando de hacer mi código más robusto y he estado leyendo sobre pruebas unitarias, pero me resulta muy difícil encontrar un uso útil real. Por ejemplo, el ejemplo de Wikipedia :

public class TestAdder {
    public void testSum() {
        Adder adder = new AdderImpl();
        assert(adder.add(1, 1) == 2);
        assert(adder.add(1, 2) == 3);
        assert(adder.add(2, 2) == 4);
        assert(adder.add(0, 0) == 0);
        assert(adder.add(-1, -2) == -3);
        assert(adder.add(-1, 1) == 0);
        assert(adder.add(1234, 988) == 2222);
    }
}

Creo que esta prueba es totalmente inútil, ya que se requiere que calcules manualmente el resultado deseado y lo pruebes, siento que una prueba unitaria mejor aquí sería

assert(adder.add(a, b) == (a+b));

pero esto solo codifica la función en sí misma en la prueba. ¿Alguien puede proporcionarme un ejemplo en el que las pruebas unitarias sean realmente útiles? Para su información, actualmente estoy codificando principalmente funciones "de procedimiento" que toman ~ 10 booleanos y algunas entradas y me dan un resultado int basado en esto, siento que la única prueba de unidad que podría hacer sería simplemente volver a codificar el algoritmo en el prueba. editar: también debería haber precisado que esto es al portar (posiblemente mal diseñado) código ruby ​​(que no hice)

lezebulon
fuente
14
How does unit testing work?Nadie sabe realmente :)
Yannis
30
"se requiere que calcules manualmente el resultado deseado". ¿Cómo es eso "totalmente inútil"? ¿De qué otra manera puede estar seguro de que la respuesta es correcta?
S.Lott
99
@ S.Lott: Se llama progreso, en la antigüedad la gente usaba computadoras para descifrar números y ahorrar tiempo, en los días modernos la gente pasa tiempo para asegurarse de que las computadoras puedan descifrar números: D
Coder
2
@Coder: el propósito de las pruebas unitarias no es "descifrar números y ahorrar tiempo";)
Andres F.
77
@lezebulon: el ejemplo de Wikipedia no es muy bueno, pero ese es un problema con ese caso de prueba en particular, no con las pruebas unitarias en general. Aproximadamente la mitad de los datos de prueba del ejemplo no agrega nada nuevo, lo que lo hace redundante (me da miedo pensar qué haría el autor de esa prueba con escenarios más complejos). Una prueba más significativa dividiría los datos de la prueba en al menos los siguientes escenarios: "¿puede agregar números negativos?", "¿Es cero neutral?", "¿Puede agregar un número negativo y uno positivo?".
Andres F.

Respuestas:

26

Las pruebas unitarias, si está probando unidades lo suficientemente pequeñas, siempre afirman lo cegadoramente obvio.

La razón por la que add(x, y)incluso se menciona una prueba de unidad, es porque en algún momento alguien entrará addy colocará un código especial de manejo de lógica de impuestos sin darse cuenta de que el complemento se usa en todas partes.

Las pruebas unitarias son muy mucho sobre el principio asociativo: si A hace B, y B hace C, entonces A hace C. "A hace C" es una prueba de nivel más alto. Por ejemplo, considere el siguiente código comercial completamente legítimo:

public void LoginUser (string username, string password) {
    var user = db.FetchUser (username);

    if (user.Password != password)
        throw new Exception ("invalid password");

    var roles = db.FetchRoles (user);

    if (! roles.Contains ("member"))
        throw new Exception ("not a member");

    Session["user"] = user;
}

A primera vista, parece un método increíble para realizar pruebas unitarias, porque tiene un propósito muy claro. Sin embargo, hace alrededor de 5 cosas diferentes. Cada cosa tiene un caso válido e inválido, y hará una gran permutación de las pruebas unitarias. Idealmente, esto se desglosa aún más:

public void LoginUser (string username, string password) {

    var user = _userRepo.FetchValidUser (username, password);

    _rolesRepo.CheckUserForRole (user, "member");

    _localStorage.StoreValue ("user", user);
}

Ahora estamos en unidades. Una prueba unitaria no le importa para qué se _userRepoconsidera un comportamiento válido FetchValidUser, solo que se llama. Puede usar otra prueba para asegurarse exactamente de lo que constituye un usuario válido. De manera similar para CheckUserForRole... ha desacoplado su prueba de saber cómo se ve la estructura de Roles. También ha desacoplado todo su programa de estar estrictamente vinculado Session. Me imagino que todas las piezas que faltan aquí se verían así:

class UserRepository : IUserRepository
{
    public User FetchValidUser (string username, string password)
    {
        var user = db.FetchUser (username);

        if (user.Password != password)
            throw new Exception ("invalid password");

        return user;
    }
}

class RoleRepository : IRoleRepository
{
    public void CheckUserForRole (User user, string role)
    {
        var roles = db.FetchRoles (user);

        if (! roles.Contains (role))
            throw new Exception ("not a member");
    }
}

class SessionStorage : ILocalStorage
{
    public void StoreValue (string key, object value)
    {
        Session[key] = value;
    }
}

Al refactorizar ha logrado varias cosas a la vez. El programa es mucho más compatible con la eliminación de estructuras subyacentes (puede deshacerse de la capa de base de datos para NoSQL), o agregar bloqueo sin problemas una vez que se da cuenta de Sessionque no es seguro para subprocesos o lo que sea. También se ha realizado pruebas muy directas para escribir para estas tres dependencias.

Espero que esto ayude :)

Bryan Boettcher
fuente
13

Actualmente estoy codificando principalmente funciones "de procedimiento" que toman ~ 10 booleanos y algunas entradas y me dan un resultado int basado en esto, siento que la única prueba de unidad que podría hacer sería simplemente volver a codificar el algoritmo en la prueba

Estoy bastante seguro de que cada una de sus funciones de procedimiento es determinista, por lo que devuelve un resultado int específico para cada conjunto de valores de entrada. Idealmente, tendría una especificación funcional a partir de la cual puede determinar qué resultado debe recibir para ciertos conjuntos de valores de entrada. En ausencia de eso, puede ejecutar el código ruby ​​(que se supone que funciona correctamente) para ciertos conjuntos de valores de entrada y registrar los resultados. Luego, debe CÓDIGO DURO los resultados en su prueba. Se supone que la prueba es una prueba de que su código realmente produce resultados que se sabe que son correctos .

Mike Nakis
fuente
+1 para ejecutar el código existente y registrar los resultados. En esta situación, ese es probablemente el enfoque pragmático.
MarkJ
12

Como nadie más parece haber proporcionado un ejemplo real:

    public void testRoman() {
        RomanNumeral numeral = new RomanNumeral();
        assert( numeral.toRoman(1) == "I" )
        assert( numeral.toRoman(4) == "IV" )
        assert( numeral.toRoman(5) == "V" )
        assert( numeral.toRoman(9) == "IX" )
        assert( numeral.toRoman(10) == "X" )
    }
    public void testSqrt() {
        assert( sqrt(4) == 2 )
        assert( sqrt(9) == 3 )
    }

Tu dices:

Creo que esta prueba es totalmente inútil, porque se requiere que calcules manualmente el resultado deseado y lo pruebes

Pero el punto es que es mucho menos probable que cometas un error (o al menos más probable que notes tus errores) al hacer los cálculos manuales que al codificar.

¿Qué posibilidades hay de cometer un error en el código de conversión de decimal a romano? Muy probable ¿Qué posibilidades hay de que cometas un error al convertir a mano números decimales a romanos? No muy probable. Es por eso que probamos contra cálculos manuales.

¿Qué posibilidades hay de cometer un error al implementar una función de raíz cuadrada? Muy probable ¿Qué posibilidades hay de que cometas un error al calcular una raíz cuadrada a mano? Probablemente más probable. Pero con sqrt, puede usar una calculadora para obtener las respuestas.

Para su información, actualmente estoy codificando principalmente funciones "de procedimiento" que toman ~ 10 booleanos y algunas entradas y me dan un resultado int basado en esto, siento que la única prueba de unidad que podría hacer sería simplemente volver a codificar el algoritmo en el prueba

Así que voy a especular sobre lo que está sucediendo aquí. Sus funciones son un poco complicadas, por lo que es difícil determinar a partir de las entradas cuál debería ser la salida. Para hacer eso, debe ejecutar manualmente (en su cabeza) la función para descubrir cuál es la salida. Es comprensible que parezca inútil y propenso a errores.

La clave es que desea encontrar las salidas correctas. Pero debe probar esas salidas contra algo que se sabe que es correcto. No es bueno escribir su propio algoritmo para calcular eso porque eso puede ser incorrecto. En este caso, es demasiado difícil calcular manualmente los valores.

Volvería al código ruby ​​y ejecutaría estas funciones originales con varios parámetros. Tomaría los resultados del código ruby ​​y los pondría en la prueba de la unidad. De esa manera no tienes que hacer el cálculo manual. Pero estás probando contra el código original. Eso debería ayudar a mantener los resultados iguales, pero si hay errores en el original, entonces no lo ayudará. Básicamente, puede tratar el código original como la calculadora en el ejemplo sqrt.

Si mostró el código real que está transfiriendo, podríamos brindarle comentarios más detallados sobre cómo abordar el problema.

Winston Ewert
fuente
Y si el código Ruby tiene un error que no conoce que no está en su nuevo código y su código falla una prueba unitaria basada en las salidas Ruby, entonces la investigación de por qué falló finalmente lo reivindicará y dará como resultado error de Ruby latente encontrado. Eso es muy bueno.
Adam Wuerl
11

Siento que la única prueba unitaria que podría hacer sería simplemente volver a codificar el algoritmo en la prueba

Estás casi en lo correcto para una clase tan simple.

Pruébelo para una calculadora más compleja. Como una calculadora de puntuación de bolos.

El valor de las pruebas unitarias se ve más fácilmente cuando tiene reglas "comerciales" más complejas con diferentes escenarios para probar.

No digo que no deba probar una calculadora de ejecución de la fábrica (¿su calculadora tiene problemas con valores como 1/3 que no se pueden representar? ¿Qué hace con la división por cero?) Pero verá el valore más claramente si prueba algo con más ramas para obtener cobertura.

brian
fuente
44
+1 para notarlo se vuelve más útil para funciones complicadas. ¿Qué sucede si decide extender adder.add () a valores de punto flotante? Matrices? Valores de la cuenta de Leger?
joshin4colours
6

A pesar del fanatismo religioso sobre el 100% de cobertura de código, diré que no todos los métodos deben ser probados en unidades. Solo funcionalidad que contiene lógica empresarial significativa. Una función que simplemente agrega número no tiene sentido probar.

Actualmente estoy codificando principalmente funciones "de procedimiento" que toman ~ 10 booleanos y algunas entradas y me dan un resultado int basado en esto

Ahí está tu verdadero problema. Si las pruebas unitarias parecen anormalmente difíciles o inútiles, es probable que se deba a un defecto de diseño. Si estuviera más orientado a objetos, las firmas de su método no serían tan masivas y habría menos entradas posibles para probar.

No necesito entrar en mi OO es superior a la programación de procedimientos ...

maple_shaft
fuente
en este caso la "firma" del método no es masiva, acabo de leer de un std :: vector <bool> que es un miembro de la clase. También debería haber precisado que estoy portar (posiblemente mal diseñado) código Ruby (que no hice)
lezebulon
2
@lezebulon Independientemente de si hay tantas entradas posibles para que ese único método acepte, ese método está haciendo demasiado .
maple_shaft
3

En mi punto de vista, las pruebas unitarias son incluso útiles en su pequeña clase de sumador: no piense en "recodificar" el algoritmo y piense en él como una caja negra con el único conocimiento que tiene sobre el comportamiento funcional (si está familiarizado) con la multiplicación rápida, conoces algunos intentos más rápidos, pero más complejos que usar "a * b") y tu interfaz pública. Entonces deberías preguntarte "¿Qué demonios podría salir mal?" ...

En la mayoría de los casos, ocurre en el borde (veo que la prueba ya agrega estos patrones ++, -, + -, 00 - tiempo para completarlos por - +, 0+, 0-, +0, -0). Piense en lo que sucede en MAX_INT y MIN_INT al sumar o restar (sumar negativos;)) allí. O trate de asegurarse de que sus pruebas se vean exactamente exactamente lo que sucede en y alrededor de cero.

En general, el secreto es muy simple (quizás también para los más complejos;)) para clases simples: piense en los contratos de su clase (vea el diseño por contrato) y luego compárelos con ellos. Cuanto mejor conozca sus invitaciones, pre y post, más completados serán sus exámenes.

Sugerencia para sus clases de prueba: intente escribir solo una afirmación en un método. Dé a los métodos buenos nombres (por ejemplo, "testAddingToMaxInt", "testAddingTwoNegatives") para obtener los mejores comentarios cuando su prueba falla después del cambio de código.

Sebastian Bauer
fuente
2

En lugar de probar un valor de retorno calculado manualmente, o duplicar la lógica en la prueba para calcular el valor de retorno esperado, pruebe el valor de retorno de una propiedad esperada.

Por ejemplo, si desea probar un método que invierte una matriz, no desea invertir manualmente su valor de entrada, debe multiplicar el valor de retorno por la entrada y verificar que obtiene la matriz de identidad.

Para aplicar este enfoque a su método, deberá considerar su propósito y semántica, para identificar qué propiedades tendrá el valor de retorno en relación con las entradas.

JGWeissman
fuente
2

Las pruebas unitarias son una herramienta de productividad. Recibe una solicitud de cambio, la implementa y luego ejecuta su código a través del gambito de prueba de la unidad. Esta prueba automatizada ahorra tiempo.

I feel that this test is totally useless, because you are required to manually compute the wanted result and test it, I feel like a better unit test here would be

Un punto discutible. La prueba en el ejemplo solo muestra cómo crear una instancia de una clase y ejecutarla a través de una serie de pruebas. Al concentrarse en las minucias de una sola implementación, falta el bosque para los árboles.

Can someone provide me with an example where unit testing is actually useful?

Tienes una entidad de empleado. La entidad contiene un nombre y una dirección. El cliente decide agregar un campo ReportsTo.

void TestBusinessLayer()
{
   int employeeID = 1234
   Employee employee = Employee.GetEmployee(employeeID)
   BusinessLayer bl = new BusinessLayer()
   Assert.isTrue(bl.Add(employee))//assume Add returns true on pass
}

Esa es una prueba básica del BL para trabajar con un empleado. El código pasará / fallará el cambio de esquema que acaba de hacer. Recuerde que las afirmaciones no son lo único que hace la prueba. La ejecución del código también garantiza que no se generen excepciones.

Con el tiempo, tener las pruebas en su lugar hace que sea más fácil hacer cambios en general. El código se prueba automáticamente en busca de excepciones y contra las afirmaciones que realice. Esto evita gran parte de los gastos generales incurridos por las pruebas manuales de un grupo de control de calidad. Si bien la interfaz de usuario sigue siendo bastante difícil de automatizar, las otras capas son generalmente muy fáciles suponiendo que use los modificadores de acceso correctamente.

I feel like the only unit testing I could do would be to simply re-code the algorithm in the test.

Incluso la lógica de procedimiento se encapsula fácilmente dentro de una función. Encapsular, instanciar y pasar en el int / primitivo para ser probado (u objeto simulado). No copie y pegue el código en una Prueba de unidad. Eso derrota a DRY. También anula la prueba por completo porque no está probando el código, sino una copia del código. Si el código que debería haber sido probado cambia, ¡la prueba aún pasa!

P.Brian.Mackey
fuente
<pedantry> "gama", no "gambito". </
pedantry
@chao lol aprende algo nuevo todos los días.
P.Brian.Mackey
2

Tomando tu ejemplo (con un poco de refactorización),

assert(a + b, math.add(a, b));

no ayuda a:

  • entender cómo se math.addcomporta internamente
  • Sepa lo que sucederá con los casos límite.

Es casi como decir:

  • Si desea saber qué hace el método, vaya y vea los cientos de líneas de código fuente usted mismo (porque, sí, math.add puede contener cientos de LOC; consulte a continuación).
  • No me molesto en saber si el método funciona correctamente. Está bien si los valores esperados y reales son diferentes de lo que realmente esperaba .

Esto también significa que no tiene que agregar pruebas como:

assert(3, math.add(1, 2));
assert(4, math.add(2, 2));

Tampoco ayudan, o al menos, una vez que hizo la primera afirmación, la segunda no aporta nada útil.

En cambio, ¿qué pasa con:

const numeric Pi = 3.1415926535897932384626433832795;
const numeric Expected = 4.1415926535897932384626433832795;
assert(Expected, math.add(Pi, 1),
    "Adding an integer to a long numeric doesn't give a long numeric result.");
assert(Expected, math.add(1, Pi),
    "Adding a long numeric to an integer doesn't give a long numeric result.");

Esto se explica por sí mismo y es muy útil tanto para usted como para la persona que mantendrá el código fuente más adelante. Imagine que esta persona realiza una ligera modificación para math.addsimplificar el código y optimizar el rendimiento, y ve el resultado de la prueba como:

Test TestNumeric() failed on assertion 2, line 5: Adding a long numeric to an
integer doesn't give a long numeric result.

Expected value: 4.1415926535897932384626433832795
Actual value: 4

esta persona comprenderá de inmediato que el método recién modificado depende del orden de los argumentos: si el primer argumento es un número entero y el segundo es un número largo, el resultado sería un número entero, mientras que se esperaba un número largo.

Del mismo modo, obtener el valor real de 4.141592en la primera afirmación se explica por sí mismo: usted sabe que se espera que el método tenga una gran precisión , pero en realidad falla.

Por la misma razón, dos afirmaciones siguientes pueden tener sentido en algunos idiomas:

// We don't expect a concatenation. `math` library is not intended for this.
assert(0, math.add("Hello", "World"));

// We expect the method to convert every string as if it was a decimal.
assert(5, math.add("0x2F", 5));

Además, ¿qué pasa con:

assert(numeric.Infinity, math.add(numeric.Infinity, 1));

También se explica por sí mismo: desea que su método pueda manejar correctamente el infinito. Ir más allá del infinito o lanzar una excepción no es un comportamiento esperado.

¿O tal vez, dependiendo de tu idioma, esto tendrá más sentido?

/**
 * Ensures that when adding numbers which exceed the maximum value, the method
 * fails with OverflowException, instead of restarting at numeric.Minimum + 1.
 */
TestOverflow()
{
    UnitTest.ExpectException(ofType(OverflowException));

    numeric result = math.add(numeric.Maximum, 1));

    UnitTest.Fail("The tested code succeeded, while an OverflowException was
        expected.");
}
Arseni Mourzenko
fuente
1

Para una función muy simple como agregar, las pruebas podrían considerarse innecesarias, pero a medida que sus funciones se vuelven más complejas, se vuelve cada vez más obvio por qué es necesaria la prueba.

Piensa en lo que haces cuando estás programando (sin pruebas unitarias). Por lo general, escribe un código, lo ejecuta, ve que funciona y pasa a lo siguiente, ¿verdad? A medida que escribe más código, especialmente en un sistema / GUI / sitio web muy grande, descubre que debe hacer más y más "correr y ver si funciona". Tienes que probar esto y probar eso. Luego, haces algunos cambios y tienes que probar esas mismas cosas una y otra vez. Se vuelve muy obvio que podría ahorrar tiempo escribiendo pruebas unitarias que automatizarían toda la parte de "ejecutar y ver si funciona".

A medida que sus proyectos se hacen cada vez más grandes, la cantidad de cosas que tiene que "ejecutar y ver si funciona" se vuelve poco realista. Entonces terminas simplemente ejecutando y probando algunos componentes principales de la GUI / proyecto y luego esperando que todo lo demás esté bien. Esta es una receta para el desastre. Por supuesto, usted, como ser humano, no puede probar repetidamente cada situación posible que sus clientes podrían usar si la GUI es utilizada literalmente por cientos de personas. Si tenía pruebas unitarias en su lugar, simplemente podría ejecutar la prueba antes de enviar la versión estable, o incluso antes de comprometerse con el repositorio central (si su lugar de trabajo usa una). Y, si se encuentran errores más adelante, puede agregar una prueba unitaria para verificarlo en el futuro.

Asaf
fuente
1

Una de las ventajas de escribir pruebas unitarias es que te ayuda a escribir código más robusto al obligarte a pensar en casos extremos. ¿Qué hay de probar algunos casos límite, como desbordamiento de enteros, truncamiento decimal o manejo de valores nulos para los parámetros?

Acantilado
fuente
0

Tal vez asuma que add () se implementó con la instrucción ADD. Si algún programador junior o ingeniero de hardware reimplementa la función add () usando ANDS / ORS / XORS, bits invertidos y cambios, es posible que desee probar la unidad contra la instrucción ADD.

En general, si reemplaza las agallas de add (), o la unidad bajo prueba, con un número aleatorio o un generador de salida, ¿cómo podría saber que algo está roto? Codifique ese conocimiento en sus pruebas unitarias. Si nadie puede saber si está roto, simplemente ingrese un código para rand () e ir a casa, su trabajo está hecho.

hotpaw2
fuente
0

Podría haberlo omitido en todas las respuestas, pero, para mí, el impulso principal detrás de Unit Testing se trata menos de probar la corrección de un método hoy en día, sino que demuestra la corrección continua de ese método cuando [alguna vez] lo cambias .

Tome una función simple, como devolver el número de elementos en alguna colección. Hoy, cuando su lista se basa en una estructura de datos interna que conoce bien, podría pensar que este método es tan dolorosamente obvio que no necesita una prueba para ello. Luego, en varios meses o años, usted (u otra persona ) decide reemplazar la estructura de la lista interna. Aún necesita saber que getCount () devuelve el valor correcto.

Ahí es donde sus Pruebas de Unidad realmente entran en juego

Puede cambiar la implementación interna de su código, pero para cualquier consumidor de ese código, el resultado sigue siendo el mismo.

Phill W.
fuente