unittest — Marco de prueba automatizado

Propósito:Marco de prueba automatizado

El módulo unittest de Python se basa en el diseño del marco XUnit de Kent Beck y Erich Gamma. El mismo patrón se repite en muchos otros lenguajes, incluidos C, Perl, Java y Smalltalk. El marco implementado por unittest admite accesorios (fixtures), conjuntos de pruebas y un corredor de pruebas para permitir las pruebas automatizadas.

Estructura básica de las pruebas

Las pruebas, según lo definido por unittest, tienen dos partes: código para administrar las dependencias de prueba (llamadas fixtures) y la prueba en sí. Las pruebas individuales se crean subclasificando TestCase y anulando o agregando los métodos apropiados. En el siguiente ejemplo, el SimplisticTest tiene un solo método test(), que fallaría si a es diferente de b.

unittest_simple.py
import unittest


class SimplisticTest(unittest.TestCase):

    def test(self):
        a = 'a'
        b = 'a'
        self.assertEqual(a, b)

Ejecutar las pruebas

La forma más fácil de ejecutar pruebas de unittest es usar el descubrimiento automático disponible a través de la interfaz de línea de comandos.

$ python3 -m unittest unittest_simple.py

.
----------------------------------------------------------------
Ran 1 test in 0.000s

OK

Esta salida abreviada incluye la cantidad de tiempo que tomaron las pruebas, junto con un indicador de estado para cada prueba (el «.» En la primera línea de salida significa que pasó una prueba). Para obtener resultados de prueba más detallados, incluye la opción -v.

$ python3 -m unittest -v unittest_simple.py

test (unittest_simple.SimplisticTest) ... ok

----------------------------------------------------------------
Ran 1 test in 0.000s

OK

Resultados de las pruebas

Las pruebas tienen 3 resultados posibles, descritos en: tabla: Resultados del caso de prueba.

Resultados del caso de prueba
Resultado Descripción
ok La prueba pasa.
FAIL La prueba no pasa y genera una excepción AssertionError.
ERROR La prueba genera cualquier excepción que no es AssertionError.

No hay una forma explícita de hacer que una prueba «pase», por lo que el estado de una prueba depende de la presencia (o ausencia) de una excepción.

unittest_outcomes.py
import unittest


class OutcomesTest(unittest.TestCase):

    def testPass(self):
        return

    def testFail(self):
        self.assertFalse(True)

    def testError(self):
        raise RuntimeError('Test error!')

Cuando una prueba falla o genera un error, el rastreo se incluye en la salida.

$ python3 -m unittest unittest_outcomes.py

EF.
================================================================
ERROR: testError (unittest_outcomes.OutcomesTest)
----------------------------------------------------------------
Traceback (most recent call last):
  File ".../unittest_outcomes.py", line 18, in testError
    raise RuntimeError('Test error!')
RuntimeError: Test error!

================================================================
FAIL: testFail (unittest_outcomes.OutcomesTest)
----------------------------------------------------------------
Traceback (most recent call last):
  File ".../unittest_outcomes.py", line 15, in testFail
    self.assertFalse(True)
AssertionError: True is not false

----------------------------------------------------------------
Ran 3 tests in 0.001s

FAILED (failures=1, errors=1)

En el ejemplo anterior, testFail() falla y el rastreo muestra la línea con el código de falla. Sin embargo, depende de la persona que lee el resultado de la prueba mirar el código para descubrir el significado de la prueba fallida.

unittest_failwithmessage.py
import unittest


class FailureMessageTest(unittest.TestCase):

    def testFail(self):
        self.assertFalse(True, 'failure message goes here')

Para facilitar la comprensión de la naturaleza de una falla en la prueba, los métodos fail*() y assert*() aceptan un argumento msg, que se puede usar para generar mensaje de error detallado.

$ python3 -m unittest -v unittest_failwithmessage.py

testFail (unittest_failwithmessage.FailureMessageTest) ... FAIL

================================================================
FAIL: testFail (unittest_failwithmessage.FailureMessageTest)
----------------------------------------------------------------
Traceback (most recent call last):
  File ".../unittest_failwithmessage.py", line 12, in testFail
    self.assertFalse(True, 'failure message goes here')
AssertionError: True is not false : failure message goes here

----------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (failures=1)

Probar la verdad

La mayoría de las pruebas afirman la verdad de alguna condición. Hay dos formas diferentes de escribir pruebas de verificación de la verdad, según la perspectiva del autor de la prueba y el resultado deseado del código que se está probando.

unittest_truth.py
import unittest


class TruthTest(unittest.TestCase):

    def testAssertTrue(self):
        self.assertTrue(True)

    def testAssertFalse(self):
        self.assertFalse(False)

Si el código produce un valor que se puede evaluar como verdadero, se debe usar el método assertTrue(). Si el código produce un valor falso, el método assertFalse() tiene más sentido.

$ python3 -m unittest -v unittest_truth.py

testAssertFalse (unittest_truth.TruthTest) ... ok
testAssertTrue (unittest_truth.TruthTest) ... ok

----------------------------------------------------------------
Ran 2 tests in 0.000s

OK

Probar la igualdad

Como caso especial, unittest incluye métodos para probar la igualdad de dos valores.

unittest_equality.py
import unittest


class EqualityTest(unittest.TestCase):

    def testExpectEqual(self):
        self.assertEqual(1, 3 - 2)

    def testExpectEqualFails(self):
        self.assertEqual(2, 3 - 2)

    def testExpectNotEqual(self):
        self.assertNotEqual(2, 3 - 2)

    def testExpectNotEqualFails(self):
        self.assertNotEqual(1, 3 - 2)

Cuando fallan, estos métodos de prueba especiales producen mensajes de error que incluyen los valores que se comparan.

$ python3 -m unittest -v unittest_equality.py

testExpectEqual (unittest_equality.EqualityTest) ... ok
testExpectEqualFails (unittest_equality.EqualityTest) ... FAIL
testExpectNotEqual (unittest_equality.EqualityTest) ... ok
testExpectNotEqualFails (unittest_equality.EqualityTest) ...
FAIL

================================================================
FAIL: testExpectEqualFails (unittest_equality.EqualityTest)
----------------------------------------------------------------
Traceback (most recent call last):
  File ".../unittest_equality.py", line 15, in
testExpectEqualFails
    self.assertEqual(2, 3 - 2)
AssertionError: 2 != 1

================================================================
FAIL: testExpectNotEqualFails (unittest_equality.EqualityTest)
----------------------------------------------------------------
Traceback (most recent call last):
  File ".../unittest_equality.py", line 21, in
testExpectNotEqualFails
    self.assertNotEqual(1, 3 - 2)
AssertionError: 1 == 1

----------------------------------------------------------------
Ran 4 tests in 0.001s

FAILED (failures=2)

¿Casi igual?

Además de la igualdad estricta, es posible probar la casi igualdad de los números de coma flotante usando assertAlmostEqual() y assertNotAlmostEqual().

unittest_almostequal.py
import unittest


class AlmostEqualTest(unittest.TestCase):

    def testEqual(self):
        self.assertEqual(1.1, 3.3 - 2.2)

    def testAlmostEqual(self):
        self.assertAlmostEqual(1.1, 3.3 - 2.2, places=1)

    def testNotAlmostEqual(self):
        self.assertNotAlmostEqual(1.1, 3.3 - 2.0, places=1)

Los argumentos son los valores a comparar y el número de decimales que se utilizarán para la prueba.

$ python3 -m unittest unittest_almostequal.py

.F.
================================================================
FAIL: testEqual (unittest_almostequal.AlmostEqualTest)
----------------------------------------------------------------
Traceback (most recent call last):
  File ".../unittest_almostequal.py", line 12, in testEqual
    self.assertEqual(1.1, 3.3 - 2.2)
AssertionError: 1.1 != 1.0999999999999996

----------------------------------------------------------------
Ran 3 tests in 0.001s

FAILED (failures=1)

Contenedores

Además del genérico assertEqual() y assertNotEqual(), hay métodos especiales para comparar contenedores como objetos list, dict y set.

unittest_equality_container.py
import textwrap
import unittest


class ContainerEqualityTest(unittest.TestCase):

    def testCount(self):
        self.assertCountEqual(
            [1, 2, 3, 2],
            [1, 3, 2, 3],
        )

    def testDict(self):
        self.assertDictEqual(
            {'a': 1, 'b': 2},
            {'a': 1, 'b': 3},
        )

    def testList(self):
        self.assertListEqual(
            [1, 2, 3],
            [1, 3, 2],
        )

    def testMultiLineString(self):
        self.assertMultiLineEqual(
            textwrap.dedent("""
            This string
            has more than one
            line.
            """),
            textwrap.dedent("""
            This string has
            more than two
            lines.
            """),
        )

    def testSequence(self):
        self.assertSequenceEqual(
            [1, 2, 3],
            [1, 3, 2],
        )

    def testSet(self):
        self.assertSetEqual(
            set([1, 2, 3]),
            set([1, 3, 2, 4]),
        )

    def testTuple(self):
        self.assertTupleEqual(
            (1, 'a'),
            (1, 'b'),
        )

Cada método informa la desigualdad utilizando un formato que es significativo para el tipo de entrada, lo que hace que las fallas de prueba sean más fáciles de entender y corregir.

$ python3 -m unittest unittest_equality_container.py

FFFFFFF
================================================================
FAIL: testCount
(unittest_equality_container.ContainerEqualityTest)
----------------------------------------------------------------
Traceback (most recent call last):
  File ".../unittest_equality_container.py", line 15, in
testCount
    [1, 3, 2, 3],
AssertionError: Element counts were not equal:
First has 2, Second has 1:  2
First has 1, Second has 2:  3

================================================================
FAIL: testDict
(unittest_equality_container.ContainerEqualityTest)
----------------------------------------------------------------
Traceback (most recent call last):
  File ".../unittest_equality_container.py", line 21, in
testDict
    {'a': 1, 'b': 3},
AssertionError: {'a': 1, 'b': 2} != {'a': 1, 'b': 3}
- {'a': 1, 'b': 2}
?               ^

+ {'a': 1, 'b': 3}
?               ^


================================================================
FAIL: testList
(unittest_equality_container.ContainerEqualityTest)
----------------------------------------------------------------
Traceback (most recent call last):
  File ".../unittest_equality_container.py", line 27, in
testList
    [1, 3, 2],
AssertionError: Lists differ: [1, 2, 3] != [1, 3, 2]

First differing element 1:
2
3

- [1, 2, 3]
+ [1, 3, 2]

================================================================
FAIL: testMultiLineString
(unittest_equality_container.ContainerEqualityTest)
----------------------------------------------------------------
Traceback (most recent call last):
  File ".../unittest_equality_container.py", line 41, in
testMultiLineString
    """),
AssertionError: '\nThis string\nhas more than one\nline.\n' !=
'\nThis string has\nmore than two\nlines.\n'

- This string
+ This string has
?            ++++
- has more than one
? ----           --
+ more than two
?           ++
- line.
+ lines.
?     +


================================================================
FAIL: testSequence
(unittest_equality_container.ContainerEqualityTest)
----------------------------------------------------------------
Traceback (most recent call last):
  File ".../unittest_equality_container.py", line 47, in
testSequence
    [1, 3, 2],
AssertionError: Sequences differ: [1, 2, 3] != [1, 3, 2]

First differing element 1:
2
3

- [1, 2, 3]
+ [1, 3, 2]

================================================================
FAIL: testSet
(unittest_equality_container.ContainerEqualityTest)
----------------------------------------------------------------
Traceback (most recent call last):
  File ".../unittest_equality_container.py", line 53, in testSet
    set([1, 3, 2, 4]),
AssertionError: Items in the second set but not the first:
4

================================================================
FAIL: testTuple
(unittest_equality_container.ContainerEqualityTest)
----------------------------------------------------------------
Traceback (most recent call last):
  File ".../unittest_equality_container.py", line 59, in
testTuple
    (1, 'b'),
AssertionError: Tuples differ: (1, 'a') != (1, 'b')

First differing element 1:
'a'
'b'

- (1, 'a')
?      ^

+ (1, 'b')
?      ^


----------------------------------------------------------------
Ran 7 tests in 0.005s

FAILED (failures=7)

Use assertIn() para probar la membresía del contenedor.

unittest_in.py
import unittest


class ContainerMembershipTest(unittest.TestCase):

    def testDict(self):
        self.assertIn(4, {1: 'a', 2: 'b', 3: 'c'})

    def testList(self):
        self.assertIn(4, [1, 2, 3])

    def testSet(self):
        self.assertIn(4, set([1, 2, 3]))

Cualquier objeto que admita el operador in o la interfaz de programación del contenedor se puede usar con assertIn().

$ python3 -m unittest unittest_in.py

FFF
================================================================
FAIL: testDict (unittest_in.ContainerMembershipTest)
----------------------------------------------------------------
Traceback (most recent call last):
  File ".../unittest_in.py", line 12, in testDict
    self.assertIn(4, {1: 'a', 2: 'b', 3: 'c'})
AssertionError: 4 not found in {1: 'a', 2: 'b', 3: 'c'}

================================================================
FAIL: testList (unittest_in.ContainerMembershipTest)
----------------------------------------------------------------
Traceback (most recent call last):
  File ".../unittest_in.py", line 15, in testList
    self.assertIn(4, [1, 2, 3])
AssertionError: 4 not found in [1, 2, 3]

================================================================
FAIL: testSet (unittest_in.ContainerMembershipTest)
----------------------------------------------------------------
Traceback (most recent call last):
  File ".../unittest_in.py", line 18, in testSet
    self.assertIn(4, set([1, 2, 3]))
AssertionError: 4 not found in {1, 2, 3}

----------------------------------------------------------------
Ran 3 tests in 0.001s

FAILED (failures=3)

Probar las excepciones

Como se mencionó anteriormente, si una prueba genera una excepción distinta de AssertionError, se trata como un error. Esto es muy útil para descubrir errores al modificar el código que tiene cobertura de prueba existente. Sin embargo, hay circunstancias en las que la prueba debe verificar que algún código produce una excepción. Por ejemplo, si se da un valor no válido a un atributo de un objeto. En tales casos, assertRaises() hace que el código sea más claro que atrapar la excepción en la prueba. Compara estas dos pruebas:

unittest_exception.py
import unittest


def raises_error(*args, **kwds):
    raise ValueError('Invalid value: ' + str(args) + str(kwds))


class ExceptionTest(unittest.TestCase):

    def testTrapLocally(self):
        try:
            raises_error('a', b='c')
        except ValueError:
            pass
        else:
            self.fail('Did not see ValueError')

    def testAssertRaises(self):
        self.assertRaises(
            ValueError,
            raises_error,
            'a',
            b='c',
        )

Los resultados para ambos son los mismos, pero la segunda prueba que usa assertRaises() es más sucinta.

$ python3 -m unittest -v unittest_exception.py

testAssertRaises (unittest_exception.ExceptionTest) ... ok
testTrapLocally (unittest_exception.ExceptionTest) ... ok

----------------------------------------------------------------
Ran 2 tests in 0.000s

OK

Accesorios de las pruebas

Los accesorios (fixtures) son recursos externos necesarios para una prueba. Por ejemplo, las pruebas para una clase pueden necesitar una instancia de otra clase que proporcione ajustes de configuración u otro recurso compartido. Otros accesorios de prueba incluyen conexiones de bases de datos y archivos temporales (muchas personas argumentan que el uso de recursos externos hace que tales pruebas no sean pruebas «unitarias», pero siguen siendo pruebas y aún son útiles).

unittest incluye ganchos especiales para configurar y limpiar cualquier accesorio necesario para las pruebas. Para establecer dispositivos para cada caso de prueba individual, anula setUp() en el TestCase. Para limpiarlos, anula tearDown(). Para administrar un conjunto de dispositivos para todas las instancias de una clase de prueba, anula los métodos de clase setUpClass() y tearDownClass() para el TestCase. Y para manejar operaciones de configuración especialmente costosas para todas las pruebas dentro de un módulo, usa las funciones de nivel de módulo setUpModule() y tearDownModule().

unittest_fixtures.py
import random
import unittest


def setUpModule():
    print('In setUpModule()')


def tearDownModule():
    print('In tearDownModule()')


class FixturesTest(unittest.TestCase):

    @classmethod
    def setUpClass(cls):
        print('In setUpClass()')
        cls.good_range = range(1, 10)

    @classmethod
    def tearDownClass(cls):
        print('In tearDownClass()')
        del cls.good_range

    def setUp(self):
        super().setUp()
        print('\nIn setUp()')
        # Pick a number sure to be in the range. The range is
        # defined as not including the "stop" value, so make
        # sure it is not included in the set of allowed values
        # for our choice.
        self.value = random.randint(
            self.good_range.start,
            self.good_range.stop - 1,
        )

    def tearDown(self):
        print('In tearDown()')
        del self.value
        super().tearDown()

    def test1(self):
        print('In test1()')
        self.assertIn(self.value, self.good_range)

    def test2(self):
        print('In test2()')
        self.assertIn(self.value, self.good_range)

Cuando se ejecuta esta prueba de muestra, el orden de ejecución del dispositivo y los métodos de prueba son evidentes.

$ python3 -u -m unittest -v unittest_fixtures.py

In setUpModule()
In setUpClass()
test1 (unittest_fixtures.FixturesTest) ...
In setUp()
In test1()
In tearDown()
ok
test2 (unittest_fixtures.FixturesTest) ...
In setUp()
In test2()
In tearDown()
ok
In tearDownClass()
In tearDownModule()

----------------------------------------------------------------
Ran 2 tests in 0.000s

OK

Es posible que no se invoquen todos los métodos tearDown si hay errores en el proceso de limpieza de los accesorios. Para asegurarte de que un dispositivo se libere siempre correctamente, usa addCleanup().

unittest_addcleanup.py
import random
import shutil
import tempfile
import unittest


def remove_tmpdir(dirname):
    print('In remove_tmpdir()')
    shutil.rmtree(dirname)


class FixturesTest(unittest.TestCase):

    def setUp(self):
        super().setUp()
        self.tmpdir = tempfile.mkdtemp()
        self.addCleanup(remove_tmpdir, self.tmpdir)

    def test1(self):
        print('\nIn test1()')

    def test2(self):
        print('\nIn test2()')

Esta prueba de ejemplo crea un directorio temporal y luego usa shutil para limpiarlo cuando se realiza la prueba.

$ python3 -u -m unittest -v unittest_addcleanup.py

test1 (unittest_addcleanup.FixturesTest) ...
In test1()
In remove_tmpdir()
ok
test2 (unittest_addcleanup.FixturesTest) ...
In test2()
In remove_tmpdir()
ok

----------------------------------------------------------------
Ran 2 tests in 0.002s

OK

Repetir pruebas con diferentes entradas

Con frecuencia es útil ejecutar la misma lógica de prueba con diferentes entradas. En lugar de definir un método de prueba separado para cada caso pequeño, una forma común de hacerlo es usar un método de prueba que contenga varias afirmaciones relacionadas. El problema con este enfoque es que tan pronto como una afirmación falla, el resto se omite. Una mejor solución es usar subTest() para crear un contexto para una prueba dentro de un método de prueba. Si la prueba falla, se informa la falla y las pruebas restantes continúan.

unittest_subtest.py
import unittest


class SubTest(unittest.TestCase):

    def test_combined(self):
        self.assertRegex('abc', 'a')
        self.assertRegex('abc', 'B')
        # The next assertions are not verified!
        self.assertRegex('abc', 'c')
        self.assertRegex('abc', 'd')

    def test_with_subtest(self):
        for pat in ['a', 'B', 'c', 'd']:
            with self.subTest(pattern=pat):
                self.assertRegex('abc', pat)

En este ejemplo, el método test_combined() nunca ejecuta las afirmaciones para los patrones 'c' y 'd'. El método test_with_subtest() lo hace, e informa correctamente la falla adicional. Ten en cuenta que el corredor de prueba todavía considera que solo hay dos casos de prueba, a pesar de que hay tres fallas informadas.

$ python3 -m unittest -v unittest_subtest.py

test_combined (unittest_subtest.SubTest) ... FAIL
test_with_subtest (unittest_subtest.SubTest) ...
================================================================
FAIL: test_combined (unittest_subtest.SubTest)
----------------------------------------------------------------
Traceback (most recent call last):
  File ".../unittest_subtest.py", line 13, in test_combined
    self.assertRegex('abc', 'B')
AssertionError: Regex didn't match: 'B' not found in 'abc'

================================================================
FAIL: test_with_subtest (unittest_subtest.SubTest) (pattern='B')
----------------------------------------------------------------
Traceback (most recent call last):
  File ".../unittest_subtest.py", line 21, in test_with_subtest
    self.assertRegex('abc', pat)
AssertionError: Regex didn't match: 'B' not found in 'abc'

================================================================
FAIL: test_with_subtest (unittest_subtest.SubTest) (pattern='d')
----------------------------------------------------------------
Traceback (most recent call last):
  File ".../unittest_subtest.py", line 21, in test_with_subtest
    self.assertRegex('abc', pat)
AssertionError: Regex didn't match: 'd' not found in 'abc'

----------------------------------------------------------------
Ran 2 tests in 0.001s

FAILED (failures=3)

Omitir pruebas

Con frecuencia es útil poder omitir una prueba si no se cumple alguna condición externa. Por ejemplo, al escribir pruebas para verificar el comportamiento de una biblioteca en una versión específica de Python, no hay razón para ejecutar esas pruebas en otras versiones de Python. Las clases y métodos de prueba se pueden decorar con skip() para omitir siempre las pruebas. Los decoradores skipIf() y skipUnless() se pueden usar para verificar una condición antes de omitir.

unittest_skip.py
import sys
import unittest


class SkippingTest(unittest.TestCase):

    @unittest.skip('always skipped')
    def test(self):
        self.assertTrue(False)

    @unittest.skipIf(sys.version_info[0] > 2,
                     'only runs on python 2')
    def test_python2_only(self):
        self.assertTrue(False)

    @unittest.skipUnless(sys.platform == 'Darwin',
                         'only runs on macOS')
    def test_macos_only(self):
        self.assertTrue(True)

    def test_raise_skiptest(self):
        raise unittest.SkipTest('skipping via exception')

Para condiciones complejas que son difíciles de expresar en una sola expresión para pasar a skipIf() o skipUnless(), un caso de prueba puede generar SkipTest directamente para hacer que la prueba se omita .

$ python3 -m unittest -v unittest_skip.py

test (unittest_skip.SkippingTest) ... skipped 'always skipped'
test_macos_only (unittest_skip.SkippingTest) ... skipped 'only
runs on macOS'
test_python2_only (unittest_skip.SkippingTest) ... skipped 'only
runs on python 2'
test_raise_skiptest (unittest_skip.SkippingTest) ... skipped
'skipping via exception'

----------------------------------------------------------------
Ran 4 tests in 0.000s

OK (skipped=4)

Ignorar las pruebas fallidas

En lugar de eliminar las pruebas que se rompen de manera persistente, se pueden marcar con el decorador expectedFailure() para que se ignore la falla.

unittest_expectedfailure.py
import unittest


class Test(unittest.TestCase):

    @unittest.expectedFailure
    def test_never_passes(self):
        self.assertTrue(False)

    @unittest.expectedFailure
    def test_always_passes(self):
        self.assertTrue(True)

Si de hecho se supera una prueba que se espera que falle, esa condición se trata como un tipo especial de falla y se informa como un «éxito inesperado».

$ python3 -m unittest -v unittest_expectedfailure.py

test_always_passes (unittest_expectedfailure.Test) ...
unexpected success
test_never_passes (unittest_expectedfailure.Test) ... expected
failure

----------------------------------------------------------------
Ran 2 tests in 0.001s

FAILED (expected failures=1, unexpected successes=1)

Ver también

  • Documentación de la biblioteca estándar para unittest
  • doctest – Un medio alternativo para ejecutar pruebas incrustadas en cadenas de documentos o archivos de documentación externos.
  • nose – Corredor de pruebas de terceros con funciones de descubrimiento sofisticadas.
  • pytest – Un popular corredor de pruebas de terceros con soporte para ejecución distribuida y un sistema de administración de accesorios alternativo.
  • testrepository – Corredor de prueba de terceros utilizado por el proyecto OpenStack, con soporte para ejecución paralela y seguimiento fallas de seguimiento.