¿Cómo pueden los programadores asegurarse de que el software esté libre de errores?

Autor | Daniel Lemire Traductor | Meniscus

Listado | CSDN (ID: CSDNnews)

8248a2a9ef6829da0b3d498c4fa3d648.png

Nuestro objetivo principal al escribir software es asegurarnos de que sea correcto. El software debe hacer lo que el programador quiere que haga y debe satisfacer las necesidades del usuario.

La contabilidad por partida doble se utiliza para las operaciones comerciales y las transacciones deben registrarse en al menos dos cuentas: débito y crédito. Una de las ventajas de la contabilidad por partida doble sobre los métodos más primitivos es que permite cierto grado de auditoría y detección de errores. Si comparamos la contabilidad con la programación de software, podemos pensar en las pruebas de software como el equivalente de la contabilidad de doble entrada y su auditoría de seguimiento.

Convertir un sistema de contabilidad original en un sistema de contabilidad de doble entrada suele ser una tarea abrumadora para los contadores. En muchos casos, los contadores necesitan reconstruir sus libros desde cero. Además, agregar pruebas puede ser muy difícil para una aplicación grande que se ha desarrollado sin ninguna prueba. Esta es la razón por la cual, al crear software, las pruebas deben ser la primera consideración.

Un programador entusiasta, o un programador novato, podría escribir una rutina rápidamente, compilarla, ejecutarla y ver que el resultado es correcto. Pero los programadores prudentes o experimentados entienden que no se debe asumir que las rutinas son correctas.

1. Errores comunes de software

Los errores de software comunes pueden hacer que los programas finalicen abruptamente o incluso que dañen las bases de datos. Las consecuencias podrían ser nefastas: en 1996, un error de software provocó la explosión del vehículo de lanzamiento Ariane-5. Este error se debe a la conversión de un número de punto flotante en un número entero, que es un número entero de 16 bits con signo que solo puede representar valores enteros pequeños. El entero no puede representar un número de punto flotante y el programa se detuvo cuando detectó este error inesperado. Irónicamente, la función que activa este error no es necesaria, simplemente se integró como un subsistema de modelos anteriores de cohetes Ariane. A precios de 1996, el costo del error fue de unos 400 millones de dólares.

Hace tiempo que se conoce la importancia de producir el software adecuado. Los buenos científicos e ingenieros han estado trabajando duro durante décadas.

Las estrategias comunes para garantizar la corrección incluyen las siguientes. Por ejemplo, si queremos hacer un cálculo científico complejo, entonces necesitamos configurar varios equipos independientes para calcular la respuesta. Si todos los equipos llegan a la misma respuesta, se puede concluir que esta respuesta es correcta. Esta estrategia de redundancia se usa a menudo para protegerse contra fallas relacionadas con el hardware. Desafortunadamente, escribir múltiples versiones de software a menudo no es práctico.

Muchos programadores tienen educación matemática avanzada. Quieren que demostremos que un programa es correcto. Dejando a un lado las fallas de hardware, debemos asegurarnos de que el software no encuentre ningún error. De hecho, el software actual es tan maduro que podemos probar que los programas son correctos.

A continuación, ilustramos nuestro punto con un ejemplo. Podemos usar la biblioteca z3 de Python. Los usuarios que no son de Python no se preocupen, no es necesario que ejecute este ejemplo.

Primero, ejecutamos el comando pip install z3-solver para instalar las bibliotecas necesarias. Supongamos que necesitamos asegurarnos de que la desigualdad ( 1 + y ) / 2 < y se cumpla para todos los enteros de 32 bits. Podemos usar el siguiente script:

import z3
y = z3.BitVec("y", 32)
s = z3.Solver()
s.add( ( 1 + y ) / 2 >= y )
if(s.check() == z3.sat):
    model = s.model()
    print(model)

En este ejemplo, construimos una palabra BitVec de 32 bits para representar nuestro entero de ejemplo. De forma predeterminada, la biblioteca z3 interpreta esta variable como un valor entero entre -2147483648 y 2147483647, que es -~ (incluyendo - y). Introducimos la desigualdad: ( 1 + y ) / 2 >= y (nota: lo contrario de la desigualdad que queremos comprobar). Si z3 no encuentra un contraejemplo, entonces se cumple la desigualdad ( 1 + y ) / 2 < y.

Al ejecutar el script, Python mostró un valor entero de 2863038463, lo que indica que z3 encontró un contraejemplo. La biblioteca z3 siempre da un entero positivo, y solo podemos decidir cómo interpretar el resultado, por ejemplo, el número 2147483648 debe interpretarse como -2147483648, 2147483649 debe interpretarse como -2147483647 y así sucesivamente. Esta representación a menudo se llama complemento a dos. Entonces, el número 2863038463 en realidad debe entenderse como un número negativo. Sin embargo, no importa cuál sea el valor exacto, lo que importa es que nuestra desigualdad ( 1 + y ) / 2 < y no se cumple cuando la variable es negativa. Simplemente podemos verificar que asignando -1 a la variable, el resultado es: 0 < -1. Esta desigualdad tampoco se cumple cuando a la variable se le asigna el valor 0: 0 < 0. Además, también podemos comprobar si la desigualdad se cumple cuando a la variable se le asigna un valor de 1. Para hacer esto, necesitamos agregar una condición donde la variable sea mayor que 1 ( s.add( y > 1 )):

import z3
y = z3.BitVec("y", 32)
s = z3.Solver()
s.add( ( 1 + y ) / 2 >= y )
s.add( y > 1 )


if(s.check() == z3.sat):
    model = s.model()
    print(model)

Después de la modificación, el script no mostró nada cuando se ejecutó, por lo que podemos concluir que esta desigualdad se mantiene siempre que la variable variable sea mayor que 1.

Ahora que hemos probado que la desigualdad ( 1 + y ) / 2 < y se cumple, ¿la desigualdad ( 1 + y ) < 2 * y también se cumple? Probemos:

import z3
y = z3.BitVec("y", 32)
s = z3.Solver()
s.add( ( 1 + y ) >= 2 * y )
s.add( y > 1 )


if(s.check() == z3.sat):
    model = s.model()
    print(model)

Después de ejecutar el script, muestra 1412098654, que es la mitad de 2824197308, y debemos interpretar este resultado para z3 como un valor negativo. Para evitar este problema, agreguemos una nueva condición para que el valor de la variable aún pueda interpretarse como un valor positivo después de multiplicar por 2:

import z3
y = z3.BitVec("y", 32)
s = z3.Solver()
s.add( ( 1 + y ) / 2 >= y )
s.add( y > 0 )
s.add( y < 2147483647/2)


if(s.check() == z3.sat):
model = s.model()
print(model)

Esta vez se confirmó el resultado. Como se muestra arriba, incluso en casos relativamente simples, este enfoque formalizado requiere mucho trabajo. En los primeros días de las ciencias de la computación, los informáticos podrían haber sido optimistas, pero en la década de 1970, Dijkstra y otros se mostraron escépticos:

Hemos visto que los verificadores automáticos de programas pueden alcanzar rápidamente sus límites de procesamiento cuando verifican programas muy pequeños, incluso en máquinas relativamente rápidas, incluso si pueden realizar muchos procesos paralelos al mismo tiempo. Pero aún así, todavía tenemos que preguntarnos, ¿el resultado de la verificación es realmente correcto? A veces pienso...

La aplicación de tales métodos matemáticos a gran escala no es práctica. Los errores vienen en muchas formas, y no todos los errores se pueden representar de manera concisa y concisa en forma matemática. Incluso si podemos representar con precisión el problema en forma matemática, no podemos creer que una herramienta como z3 por sí sola encuentre una solución y, a medida que el problema se vuelve más difícil, el cálculo lleva más y más tiempo. En general, un enfoque empírico es más apropiado.

2. Es necesario probar el software

Con el tiempo, los programadores comprenden gradualmente la necesidad de probar el software. Pero no es necesario probar todo el código y, a menudo, los prototipos o ejemplos no requieren verificación adicional. Sin embargo, cualquier funcionalidad significativa diseñada para implementarse en un entorno profesional debe probarse al menos parcialmente. Las pruebas pueden reducir la probabilidad de tener que enfrentarse a una situación catastrófica en el futuro.

Hay dos tipos principales de pruebas comunes.

  • prueba de unidad. Estos están diseñados para probar componentes específicos de un programa de software. Por ejemplo, pruebas unitarias para una sola función. En la mayoría de los casos, las pruebas unitarias se realizan automáticamente y los programadores pueden ejecutarlas simplemente presionando un botón o ingresando un comando. Las pruebas unitarias a menudo evitan la adquisición de recursos valiosos, como la creación de archivos grandes en el disco o el establecimiento de conexiones de red. Las pruebas unitarias generalmente no implican la configuración del sistema operativo.

  • Pruebas de integración. Estos están diseñados para verificar aplicaciones completas. A menudo, estas pruebas requieren acceso a la red y, a veces, grandes cantidades de datos. Las pruebas de integración a veces requieren intervención humana y también requieren conocimientos específicos de la aplicación. Las pruebas de integración pueden requerir la configuración del sistema operativo y la instalación del software. Las pruebas de integración también se pueden automatizar, al menos parcialmente. En la mayoría de los casos, las pruebas de integración se basan en pruebas unitarias.

Las pruebas unitarias generalmente se realizan como parte de la integración continua. La integración continua a menudo automatiza tareas específicas, incluidas pruebas unitarias, copias de seguridad, aplicación de firmas criptográficas y más. La integración continua se puede realizar periódicamente o cuando cambia el código.

Las pruebas unitarias se pueden utilizar para establecer el proceso de desarrollo de software y guiar el desarrollo de software. Estas pruebas se pueden escribir antes de escribir el código en sí, también conocido como "Desarrollo dirigido por pruebas". Por lo general, las pruebas se escriben después de que se completa el desarrollo de funciones. La escritura de pruebas unitarias y el desarrollo de funcionalidades pueden ser realizadas por diferentes programadores. A veces es más fácil detectar errores con las pruebas proporcionadas por otros desarrolladores porque pueden hacer suposiciones diferentes.

Podemos integrar pruebas en alguna función o aplicación. Por ejemplo, la aplicación ejecuta algunas pruebas al inicio. En este caso, las pruebas pasan a formar parte del código distribuido. Sin embargo, es una práctica más común no exponer las pruebas unitarias. Después de todo, las pruebas unitarias son solo para programadores y no afectan la funcionalidad de la aplicación. En particular, no suponen un riesgo de seguridad y no afectan al rendimiento de la aplicación.

3. La cobertura de la prueba no es un buen indicador de la calidad de la prueba

Los programadores experimentados a menudo consideran que las pruebas son tan importantes como el código. Por lo tanto, no es raro pasar la mitad de su tiempo de trabajo escribiendo exámenes. Si bien afecta la velocidad a la que puede escribir código, la prueba es una inversión a largo plazo, por lo que a menudo ahorra tiempo. A menudo, el software que no está bien probado será más difícil de actualizar. Las pruebas pueden reducir la incertidumbre sobre los cambios o extensiones del código.

Las pruebas deben ser fáciles de leer, simples y rápidas de ejecutar, y no usar mucha memoria.

Sin embargo, es difícil definir con precisión la calidad de las pruebas. Hay varios métodos estadísticos comunes. Por ejemplo, podemos contar el número de líneas de código cubiertas por la prueba. Aquí, tenemos que hablar sobre la cobertura de la prueba. 100% de cobertura significa que se prueba todo el código. En la práctica, sin embargo, la cobertura no es un buen indicador de la calidad de la prueba.

Echemos un vistazo al siguiente ejemplo:

package main


import (
    "testing"
)




func Average(x, y uint16) uint16 {
   return (x + y)/2
}


func TestAverage(t *testing.T) {
    if Average(2,4) != 3 {
       t.Error(Average(2,4))
    }
}

En el lenguaje Go, podemos usar el comando go test para ejecutar pruebas. El código anterior se prueba en consecuencia para la función Promedio. Para el ejemplo anterior, la prueba funciona muy bien con una cobertura del 100 %.

Sin embargo, es posible que la corrección de la función Promedio no cumpla con nuestras expectativas. Si el parámetro pasado es un número entero (40000, 40000), entonces esperamos que el promedio devuelto sea 40000. Pero la suma de dos enteros 40000 no se puede representar con un entero de 16 bits (uint16), por lo que el resultado será (40000+4000)%65536=14464. Entonces esta función devolverá 7232. ¿Te sientes un poco sorprendido? La siguiente prueba fallará:

func TestAverage(t *testing.T) {
if Average(40000,40000) != 40000 {
t.Error(Average(40000,40000))
}
}

Si es posible, y lo suficientemente rápido, podemos intentar probar este código de manera más exhaustiva, como en el ejemplo a continuación, donde usamos algunos valores más:

package main


import (
    "testing"
)




func Average(x, y uint16) uint16 {
   if y > x {
     return (y - x)/2 + x
   } else {
     return (x - y)/2 + y
   }
}


func TestAverage(t *testing.T) {
  for x := 0; x <65536; x++ {
    for y := 0; y <65536; y++ {
      m :=int(Average(uint16(x),uint16(y)))
      if x < y {
        if m < x || m> y {
         t.Error("error ", x, " ", y)
        }          
      } else {
        if m < y || m> x {
         t.Error("error ", x, " ", y)
        } 
      }
    }
  }
}

En la práctica, rara vez hacemos pruebas exhaustivas. Por lo general, usamos pruebas pseudoaleatorias. Por ejemplo, podemos generar números pseudoaleatorios y usarlos como parámetros. En las pruebas aleatorias, es importante seguir siendo determinista, es decir, utilizar los mismos valores para cada ejecución de prueba. Para hacer esto, podemos proporcionar una semilla fija al generador de números aleatorios, como en el siguiente ejemplo:

package main


import (
   "testing"  
       "math/rand"
)




func Average(x, y uint16) uint16 {
   if y > x {
     return (y - x)/2 + x
   } else {
     return (x - y)/2 + y
   }
}


func TestAverage(t *testing.T) {
  rand.Seed(1234)
  for test := 0; test <1000; test++ {
    x := rand.Intn(65536)
    y := rand.Intn(65536)
    m :=int(Average(uint16(x),uint16(y)))
    if x < y {
      if m < x || m> y {
       t.Error("error ", x, " ", y)
      }          
    } else {
      if m < y || m> x {
       t.Error("error ", x, " ", y)
      } 
    }
  }
}

Las pruebas basadas en la exploración aleatoria son parte de una estrategia comúnmente conocida como "fuzzing".

Nuestras pruebas generalmente se pueden dividir en dos categorías, a saber, pruebas directas y pruebas inversas. Las pruebas directas tienen como objetivo verificar que una función o componente se comporta según lo acordado. La primera prueba de la función Promedio anterior es la prueba directa. Backtesting examina si el software funciona correctamente en situaciones inesperadas. Podemos realizar pruebas inversas proporcionando datos aleatorios (fuzzing). Si el programa anterior solo puede manejar valores enteros pequeños, entonces nuestro segundo ejemplo puede considerarse una prueba inversa.

Si se modifica el código, ninguna de las pruebas anteriores pasará. Sobre esta base, también podemos adoptar métodos de prueba más complejos, como modificar aleatoriamente el código y confirmar que estas modificaciones harán que la prueba falle.

Algunos programadores optan por generar automáticamente pruebas a partir del código. Este enfoque prueba el componente y registra los resultados. Por ejemplo, en el ejemplo anterior de cálculo del promedio, Average(40000,40000) produce 7232. Si el código cambia posteriormente, causando que los resultados cambien, la prueba fallará. Este enfoque ahorra tiempo porque las pruebas se generan automáticamente. Podemos lograr una cobertura de prueba del 100% rápida y fácilmente. Sin embargo, tales pruebas pueden ser engañosas. En particular, este enfoque puede registrar un comportamiento incorrecto. Además, tales pruebas solo garantizan cantidad, no calidad. Las pruebas que no son útiles para verificar la funcionalidad básica del software pueden incluso ser dañinas. Las pruebas irrelevantes desperdician el tiempo de los programadores cuando cambian las versiones posteriores.

4. Los beneficios de las pruebas

Finalmente, repasemos los beneficios de las pruebas: las pruebas nos ayudan a organizar nuestro flujo de trabajo, las pruebas son una medida de calidad, nos ayudan a documentar el código, evitan errores de regresión, ayudan con la depuración y nos ayudan a escribir un código más eficiente.

organizar

Diseñar una pieza compleja de software puede llevar semanas o meses de arduo trabajo. La mayoría de las veces, dividimos el trabajo en unidades individuales. Es difícil juzgar el resultado hasta que llega el producto final. Al desarrollar software, escribir pruebas ayuda a organizar nuestro trabajo. Por ejemplo, un componente no se considera completo hasta que se haya escrito y probado. Sin el proceso de escribir pruebas, es más difícil estimar el progreso de un proyecto porque los componentes no probados pueden estar lejos de estar completos.

calidad

Las pruebas también pueden mostrar cuán comprometidos están los programadores con su trabajo. Podemos evaluar rápidamente varias funciones y componentes de un programa de software a través de pruebas, y las pruebas bien escritas muestran que el código correspondiente es confiable. Y las características no probadas se pueden usar como advertencia.

Algunos lenguajes de programación son muy estrictos y pueden verificar el código compilando. Y algunos lenguajes de programación (Python, JavaScript) dejan más libertad a los programadores. Algunos programadores creen que las pruebas pueden superar las limitaciones de los lenguajes de programación menos restrictivos e imponer una restricción adicional al programador.

Documentación

El desarrollo de software generalmente debe tener una documentación clara y completa. En la práctica, sin embargo, la documentación suele ser incompleta, inexacta o incluso incorrecta o inexistente. Por lo tanto, la prueba se convierte en la única especificación técnica. Los programadores pueden leer casos de prueba y luego ajustar su comprensión de los componentes y la funcionalidad del software. A diferencia de la documentación, las pruebas generalmente están actualizadas y son muy precisas si se ejecutan regularmente, ya que las pruebas están escritas en lenguajes de programación. Por lo tanto, las pruebas demuestran cómo se puede utilizar el código.

Incluso si queremos escribir documentación de alta calidad, las pruebas pueden desempeñar un papel importante. Para ilustrar el código de computadora, a menudo necesitamos usar ejemplos. Cada ejemplo se puede convertir en una prueba. Por lo tanto, podemos asegurar que los ejemplos incluidos en la documentación son confiables. Si el código cambia y es necesario modificar los ejemplos, el proceso de prueba de los ejemplos nos recordará que actualicemos la documentación. De esta forma, podemos evitar ejemplos desactualizados en la documentación que dan una mala experiencia a los lectores.

devolver

Los programadores corrigen regularmente errores en el software. El mismo problema también puede repetirse por diferentes razones: el problema original no se resuelve fundamentalmente; un cambio en una parte del código hace que se devuelva un error en otra parte; agregar una nueva característica u optimizar el software hace que se devuelva un error o una aparece un nuevo error. Cuando ocurre un nuevo defecto en el software, lo llamamos problema de regresión. Para evitar tales regresiones, un paso importante es realizar las pruebas correspondientes para cada corrección de errores o nueva característica. Al ejecutar este tipo de prueba, podemos notar problemas de regresión tan pronto como surjan. Idealmente, después de modificar el código y ejecutar pruebas de regresión, puede encontrar problemas de regresión, de modo que se puedan evitar los problemas de regresión. Para convertir los errores en pruebas simples y efectivas, debemos reducir los errores a su forma más simple. Por ejemplo, para el ejemplo de promedio anterior, podemos agregar el error detectado a una prueba adicional:

package main


import (
    "testing
)




func Average(x, y uint16) uint16 {
   if y > x {
     return (y - x)/2 + x
   } else {
     return (x - y)/2 + y
   }
}


func TestAverage(t *testing.T) {
   if Average(2,4) != 3 {
     t.Error("error1")
   }
   if Average(40000,40000)!= 40000 {
     t.Error("error2")
   }           
}

arreglo del fallo

En la práctica, un amplio conjunto de pruebas puede identificar y corregir errores más rápido. Esto se debe a que las pruebas reducen el alcance de los errores y brindan cierta seguridad al programador. En cierto modo, el tiempo que lleva escribir pruebas puede reducir el tiempo para encontrar errores, al tiempo que reduce la cantidad de errores.

Además, escribir nuevas pruebas es una estrategia efectiva para identificar y corregir errores. A la larga, este enfoque es más eficaz que otras estrategias de depuración, como recorrer el código paso a paso. De hecho, después de realizar la depuración, además de corregir errores, también debe agregar nuevas pruebas unitarias.

actuación

La función principal de las pruebas es verificar que las funciones y los componentes produzcan los resultados esperados. Sin embargo, también hay muchos programadores que utilizan pruebas para medir el rendimiento de los componentes. Por ejemplo, mida la velocidad de ejecución de una función, el tamaño de un ejecutable o el uso de la memoria. Estas pruebas pueden detectar la penalización de rendimiento causada por los cambios de código. Puede comparar el rendimiento de su propio código con el código de referencia y utilizar pruebas estadísticas para comprobar si hay diferencias.

5. Resumen

Todos los sistemas informáticos tienen fallas. El hardware puede fallar en cualquier momento. Incluso si el hardware es confiable, es casi imposible para los programadores predecir todas las situaciones que encontrará el software en funcionamiento. No importa quién sea o cuánto trabaje, su software no será perfecto. No obstante, debemos hacer todo lo posible para escribir el código correcto: uno que cumpla con las expectativas del usuario.

Si bien es posible escribir código correcto sin escribir pruebas, los beneficios de los conjuntos de pruebas son tangibles en proyectos difíciles o más grandes. Muchos programadores experimentados se negarán a utilizar componentes de software no probados.

Un buen hábito de escribir pruebas puede ayudarlo a convertirse en un mejor programador. A medida que escribe pruebas, se vuelve más consciente de las limitaciones humanas. Al interactuar con otros programadores y usuarios, si tiene un conjunto de pruebas, puede pensar mejor en sus comentarios.

Lista de libros recomendados

  • James Whittaker, Jason Arbon, Jeff Carrollo, How GoogleTests Software, Addison-Wesley Professional, 1.ª edición (23 de marzo de 2012)

  • Lisa Crispin, JanetGregory, Pruebas ágiles: una guía práctica para evaluadores y equipos ágiles, Addison-Wesley Professional Publishing, 1.ª edición (30 de diciembre de 2008)

Enlace original:

https://lemire.me/blog/2022/01/03/cómo-los-programadores-se-aseguran-de-que-su-software-es-correcto/

Este artículo ha sido autorizado por el autor, ¡indique la fuente y la fuente para la reimpresión!

Supongo que te gusta

Origin blog.csdn.net/csdnnews/article/details/124311199
Recomendado
Clasificación