Prueba unitaria sucintamente prueba de corrección

Este es un extracto del eBook de Unit Testing Succinctly, por Marc Clifton, amablemente proporcionado por Syncfusion.

La frase "probar la corrección" se usa normalmente en el contexto de la veracidad de una computación, pero con respecto a la prueba unitaria, la corrección de la prueba en realidad tiene tres categorías amplias, de las cuales solo la segunda se relaciona con las computaciones en sí mismas:

  • Verificación de que las entradas a un cálculo son correctas (contrato de método).
  • Verificación de que una llamada a un método da como resultado el resultado computacional deseado (llamado el aspecto computacional), dividido en cuatro procesos típicos:
    • Transformación de datos
    • Reducción de datos
    • Cambio de estado
    • Corrección del estado
  • Manejo y recuperación de errores externos.

Hay muchos aspectos de una aplicación en los que las pruebas unitarias generalmente no se pueden aplicar para probar la corrección. Estos incluyen la mayoría de las características de la interfaz de usuario, como el diseño y la usabilidad. En muchos casos, las pruebas unitarias no son la tecnología adecuada para los requisitos de prueba y el comportamiento de la aplicación en relación con el rendimiento, la carga, etc..


Cómo las pruebas unitarias prueban la corrección

Demostrar la corrección implica:

  • Verificando el contrato.
  • Verificando resultados computacionales.
  • Verificación de resultados de transformación de datos..
  • Verificando errores externos se manejan correctamente.

Veamos algunos ejemplos de cada una de estas categorías, sus fortalezas, debilidades y problemas que podemos encontrar con nuestro código..

Probar el contrato se implementa

La forma más básica de prueba unitaria es verificar que el desarrollador haya escrito un método que indique claramente el "contrato" entre la persona que llama y el método al que se llama. Esto generalmente toma la forma de verificar que las entradas incorrectas de un método dan lugar a una excepción que se lanza. Por ejemplo, un método de "dividir por" podría lanzar un ArgumentOutOfRangeException si el denominador es 0:

public static int Divide (int numerator, int denominator) if (denominator == 0) lanza una nueva ArgumentOutOfRangeException ("Denominator no puede ser 0.");  devolver numerador / denominador;  [TestMethod] [ExpectedException (typeof (ArgumentOutOfRangeException))] public void BadParameterTest () Divide (5, 0); 

Sin embargo, verificar que un método implemente pruebas de contrato es una de las pruebas de unidad más débiles que se pueden escribir.

Demuestre los resultados computacionales

Una prueba de unidad más fuerte implica verificar que el cálculo sea correcto. Es útil categorizar sus métodos en una de las tres formas de cálculo:

  • Reducción de datos
  • Transformación de datos
  • Cambio de estado

Estos determinan los tipos de pruebas unitarias que podría querer escribir para un método particular.

Reducción de datos

los Dividir El método en la muestra anterior puede considerarse una forma de reducción de datos. Toma dos valores y devuelve un valor. Para ilustrar:

[TestMethod] public void VerifyDivisionTest () Assert.IsTrue (Divide (6, 2) == 3, "6/2 debería ser igual a 3!"); 

Esto es ilustrativo de probar un método que reduce las entradas, generalmente, a una salida resultante. Esta es la forma más simple de pruebas unitarias útiles..

Transformación de datos

Las pruebas unitarias de transformación de datos tienden a operar en conjuntos de valores. Por ejemplo, la siguiente es una prueba para un método que convierte las coordenadas cartesianas en coordenadas polares.

public static double [] ConvertToPolarCoordinates (double x, double y) double dist = Math.Sqrt (x * x + y * y); ángulo doble = Math.Atan2 (y, x); devuelve nuevo doble [] dist, angle;  [TestMethod] public void ConvertToPolarCoordinatesTest () double [] pcoord = ConvertToPolarCoordinates (3, 4); Assert.IsTrue (pcoord [0] == 5, "Distancia esperada igual a 5"); Assert.IsTrue (pcoord [1] == 0.92729521800161219, "El ángulo esperado es de 53.130 grados"); 

Esta prueba verifica la corrección de la transformación matemática..

Lista de transformaciones

Las transformaciones de la lista se deben separar en dos pruebas:

  • Verifique que la transformación del núcleo sea correcta.
  • Verifique que la operación de la lista sea correcta.

Por ejemplo, desde la perspectiva de la prueba unitaria, la siguiente muestra está mal escrita porque incorpora tanto la reducción de datos como la transformación de datos:

public struct Name public string FirstName get; conjunto;  cadena pública Apellido get; conjunto;  lista pública ConcatNames (Lista nombres) Lista concatenatedNames = new List(); foreach (Nombre en nombres) concatenatedNames.Add (name.LastName + "," + name.FirstName);  devolver los nombres concatenados;  [TestMethod] public void NameConcatenationTest () List nombres = nueva lista() new Name () FirstName = "John", LastName = "Travolta", new Name () FirstName = "Allen", LastName = "Nancy"; Lista newNames = ConcatNames (nombres); Assert.IsTrue (newNames [0] == "Travolta, John"); Assert.IsTrue (newNames [1] == "Nancy, Allen"); 

Este código se prueba mejor en unidades separando la reducción de datos de la transformación de datos:

pública cadena Concat (nombre del nombre) return name.LastName + "," + name.FirstName;  [TestMethod] public void ContactNameTest () Name name = new Name () FirstName = "John", LastName = "Travolta"; string concatenatedName = Concat (nombre); Assert.IsTrue (concatenatedName == "Travolta, John"); 

Expresiones Lambda y Pruebas Unitarias

La sintaxis de consulta integrada en el lenguaje (LINQ) está estrechamente unida a las expresiones lambda, lo que resulta en una sintaxis fácil de leer que dificulta la vida de las pruebas unitarias. Por ejemplo, este código:

Lista pública ConcatNamesWithLinq (Lista nombres) devolver nombres.Seleccione (t => t.LastName + "," + t.FirstName) .ToList (); 

es significativamente más elegante que los ejemplos anteriores, pero no se presta bien a la prueba unitaria de la "unidad" real, es decir, la reducción de datos de una estructura de nombre a una única cadena delimitada por comas expresada en la función lambda t => t.LastName + "," + t.FirstName. Para separar la unidad de la operación de lista se requiere:

Lista pública ConcatNamesWithLinq (Lista nombres) devolver nombres.Seleccione (t => Concat (t)). ToList (); 

Podemos ver que las pruebas unitarias a menudo pueden requerir la refactorización del código para separar las unidades de otras transformaciones.

Cambio de estado

La mayoría de los idiomas son "con estado" y las clases a menudo se administran en estado. El estado de una clase, representado por sus propiedades, es a menudo algo útil para probar. Considere esta clase que representa el concepto de una conexión:

clase pública AlreadyConnectedToServiceException: ApplicationException public AlreadyConnectedToServiceException (string msg): base (msg)  clase pública ServiceConnection public bool Connected get; conjunto protegido  public void Connect () si (Conectado) lanza la nueva excepción AlreadyConnectedToServiceException ("Sólo se permite una conexión a la vez.");  // Conectar con el servicio. Conectado = verdadero;  public void Disconnect () // Desconectar del servicio. Conectado = falso; 

Podemos escribir pruebas unitarias para verificar los diversos estados permitidos y no permitidos del objeto:

[TestClass] public class ServiceConnectionFixture [TestMethod] public void TestInitialState () ServiceConnection conn = new ServiceConnection (); Assert.IsFalse (conn.Connected);  [TestMethod] public void TestConnectedState () ServiceConnection conn = new ServiceConnection (); conn.Connect (); Assert.IsTrue (conn.Connected);  [TestMethod] public void TestDisconnectedState () ServiceConnection conn = new ServiceConnection (); conn.Connect (); conn.Disconnect (); Assert.IsFalse (conn.Connected);  [TestMethod] [ExpectedException (typeof (AlreadyConnectedToServiceException))] public void TestAlreadyConnectedException () ServiceConnection conn = new ServiceConnection (); conn.Connect (); conn.Connect (); 

Aquí, cada prueba verifica la corrección del estado del objeto:

  • Cuando se inicializa.
  • Cuando se le indique conectarse al servicio..
  • Cuando se le indique desconectarse del servicio..
  • Cuando se intenta más de una conexión simultánea.

La verificación del estado a menudo revela errores en la gestión del estado. También vea las siguientes "Clases burlonas" para obtener más mejoras al código de ejemplo anterior.

Probar que un método maneja correctamente una excepción externa

El manejo y recuperación de errores externos a menudo es más importante que comprobar si su propio código genera excepciones en los momentos correctos. Hay varias razones para esto:

  • No tiene control sobre una dependencia físicamente separada, ya sea un servicio web, una base de datos u otro servidor separado.
  • No tiene pruebas de la corrección del código de otra persona, por lo general una biblioteca de terceros.
  • Los servicios y el software de terceros pueden generar una excepción debido a un problema que su código está creando pero que no detecta (y no sería necesariamente fácil de detectar). Un ejemplo de esto es que, al eliminar registros en una base de datos, la base de datos lanza una excepción debido a que los registros en otras tablas hacen referencia a los registros que su programa está eliminando, lo que viola una restricción de clave externa..

Este tipo de excepciones son difíciles de probar porque requieren la creación de al menos algún error que normalmente sería generado por el servicio que no controla. Una forma de hacer esto es "burlarse" del servicio; sin embargo, esto solo es posible si el objeto externo se implementa con una interfaz, una clase abstracta o métodos virtuales.

Clases de burla

Por ejemplo, el código anterior para la clase "ServiceConnection" no es simulable. Si desea probar la administración de su estado, debe crear físicamente una conexión al servicio (sea lo que sea) que puede o no estar disponible cuando se ejecutan las pruebas de la unidad. Una mejor implementación podría verse así:

clase pública MockableServiceConnection public bool Connected get; conjunto protegido  vacío virtual protegido ConnectToService () // Conéctese al servicio.  vacío virtual protegido DisconnectFromService () // Desconectarse del servicio.  public void Connect () si (Conectado) lanza la nueva excepción AlreadyConnectedToServiceException ("Sólo se permite una conexión a la vez.");  ConnectToService (); Conectado = verdadero;  public void Disconnect () DisconnectFromService (); Conectado = falso; 

Observe cómo esta refactorización menor ahora le permite escribir una clase simulada:

clase pública ServiceConnectionMock: MockableServiceConnection protected override void ConnectToService () // No hacer nada.  anulación protegida void DisconnectFromService () // No hacer nada. 

que le permite escribir una prueba de unidad que prueba la administración del estado, independientemente de la disponibilidad del servicio. Como se ilustra, incluso los cambios simples de arquitectura o implementación pueden mejorar en gran medida la capacidad de prueba de una clase.

Probar un error es re-creable

Su primera línea de defensa para demostrar que el problema ha sido corregido es, irónicamente, probar que el problema existe. Anteriormente vimos un ejemplo de escritura de una prueba que demostró que el método de división busca un valor de denominador de 0. Digamos que se archiva un informe de error porque un usuario bloqueó el programa al ingresar 0 para el valor del denominador.

Pruebas negativas

La primera orden de negocio es crear una prueba que ejerce esta condición:

[TestMethod] [ExpectedException (typeof (DivideByZeroException))) public void BadParameterTest () Divide (5, 0); 

Esta prueba pasa porque estamos probando que el error existe al verificar que cuando el denominador es 0, una DivideByZeroException es elevado. Este tipo de pruebas se consideran "pruebas negativas", ya que pasar cuando se produce un error. La prueba negativa es tan importante como la prueba positiva (que se analiza a continuación) porque verifica la existencia de un problema antes de que se corrija.

Probar que un error está arreglado

Obviamente, queremos probar que un error ha sido corregido. Esta es una prueba "positiva".

Pruebas positivas

Ahora podemos introducir una nueva prueba, una que probará que el código mismo detecta el error lanzando una ArgumentOutOfRangeException.

[TestMethod] [ExpectedException (typeof (ArgumentOutOfRangeException))] public void BadParameterTest () Divide (5, 0); 

Si podemos escribir esta prueba antes de Arreglando el problema, veremos que la prueba falla. Finalmente, después de solucionar el problema, nuestra prueba positiva pasa, y la prueba negativa ahora falla.

Si bien este es un ejemplo trivial, demuestra dos conceptos:

  • Las pruebas negativas que prueban que algo no funciona repetidamente son importantes para comprender el problema y la solución.
  • Las pruebas positivas, que demuestran que el problema se ha solucionado, son importantes no solo para verificar la solución, sino también para repetir la prueba cada vez que se realiza un cambio. Las pruebas unitarias juegan un papel importante cuando se trata de pruebas de regresión.

Por último, demostrar que existe un error no siempre es fácil. Sin embargo, como regla general, las pruebas unitarias que requieren demasiada configuración y simulacros son un indicador de que el código que se está probando no está lo suficientemente aislado de las dependencias externas y podría ser un candidato para la refactorización.

No probar nada roto al cambiar el código

Debería ser obvio que las pruebas de regresión son un resultado de utilidad considerable en las pruebas unitarias. A medida que el código experimente cambios, se introducirán errores que se revelarán si tiene una buena cobertura de código en sus pruebas unitarias. Esto efectivamente ahorra un tiempo considerable en la depuración y, lo que es más importante, ahorra tiempo y dinero cuando el programador descubre el error en lugar del usuario..

Demuestre que se cumplen los requisitos

El desarrollo de aplicaciones generalmente comienza con un conjunto de requisitos de alto nivel, generalmente orientado alrededor de la interfaz de usuario, el flujo de trabajo y los cálculos. Idealmente, el equipo reduce el visible conjunto de requisitos hasta un conjunto de requisitos programáticos, que son invisible Al usuario, por su propia naturaleza..

La diferencia se manifiesta en cómo se prueba el programa. Pruebas de integración es típicamente en el visible nivel, mientras que la prueba de unidad está en el grano más fino de invisible, Pruebas de corrección programática. Es importante tener en cuenta que las pruebas unitarias no pretenden reemplazar las pruebas de integración; sin embargo, al igual que con los requisitos de aplicación de alto nivel, hay requisitos programáticos de bajo nivel que pueden definirse. Debido a estos requisitos programáticos, es importante escribir pruebas unitarias.

Tomemos un método redondo. El método .NET Math.Round redondeará un número cuyo componente fraccional es mayor que 0.5, pero se redondeará hacia abajo cuando el componente fraccionario sea 0.5 o menos. Digamos que no es el comportamiento que deseamos (por cualquier razón), y queremos redondear cuando el componente fraccional es 0.5 o mayor. Este es un requisito computacional que debería poder derivarse de un requisito de integración de nivel superior, lo que resulta en el siguiente método y prueba:

public static int RoundUpHalf (double n) if (n < 0) throw new ArgumentOutOfRangeException("Value must be >= 0. "); int ret = (int) n; doble fracción = n - ret; if (fracción> = 0.5) ++ ret; return ret; [TestMethod] public void RoundUpTest () int result1 = RoundUpHalf (1.5); int result2 = RoundUpHalf (1.499999); Assert.IsTrue (result1 == 2, "Expected 2."); Assert.IsTrue (result2 == 1, "Expected 1.");

También se debe escribir una prueba separada para la excepción.

Tomar los requisitos de nivel de aplicación que se verifican con las pruebas de integración y reducirlos a requisitos computacionales de nivel inferior es una parte importante de la estrategia de prueba de unidad general, ya que define los requisitos computacionales claros que la aplicación debe cumplir. Si encuentra dificultades con este proceso, intente convertir los requisitos de la aplicación en una de las tres categorías computacionales: reducción de datos, transformación de datos y cambio de estado.