Dobles de prueba: mocks, fakes y fixtures en Python

Stub, fake, mock y spy ejecutados de verdad: el patch al nombre equivocado, autospec y las firmas rotas, fixtures con fugas y un mock que ocultó dos bugs.

Serie: curso de pruebas y depuración 4/6 (intermedio). Para quien ya escribe tests unitarios simples (lección 3) y ahora necesita probar código que habla con un servicio de red, una base de datos o el sistema de archivos. Al terminar vas a poder distinguir un stub de un fake, un mock y un spy, parchear el nombre correcto, dejar que autospec atrape cambios de firma, escribir fixtures que limpian aunque falle la preparación y reconocer un test con tantos mocks que ya no puede fallar.

Si trabajas como freelance y le facturas en dólares a un cliente pero cobras en pesos, tarde o temprano vas a escribir una función que consulta un tipo de cambio en un servicio externo. Y si en tu primer trabajo te dijeron "eso se mockea" y luego producción se rompió con todos los tests en verde, esta lección es para ti. Reproduce, con salida real, las tres maneras en que eso pasa: un patch que en silencio salió a la red real, un mock que aceptó una llamada con los argumentos equivocados y un repositorio mockeado que dejó pasar dos refactorizaciones que rompían producción. Todo se ejecutó con Python 3.9.6 (mensajes comparados con 3.12.14) y Dart 3.11 en una Mac con Apple Silicon (M1 Pro). Las rutas se acortaron a ~/demo/.

Los números que vas a ver: el test con Mock simple pasó las dos refactorizaciones rotas; el fake con SQLite en memoria atrapó las dos y además fue el doble más rápido que medimos (47 µs por test, contra 141 µs de Mock y 786 µs de create_autospec).

Stub, fake, mock, spy: cuatro trabajos, una palabra

"Mock" se usa para todo, pero el vocabulario clásico (de xUnit Test Patterns de Gerard Meszaros, popularizado por el artículo "Mocks Aren't Stubs" de Martin Fowler) separa cuatro trabajos. Un doble de prueba (test double) es cualquier objeto que reemplaza a una dependencia real durante un test.

Doble Qué hace Qué revisa el test Ejemplo en esta lección
Dummy llena un parámetro, nunca se usa nada sobre él None como logger que no se usa
Stub devuelve respuestas fijas el resultado del código probado fetch_rate parcheado para devolver 17.35
Fake una implementación que funciona, pero más simple el resultado, a través de comportamiento real SQLite :memory: en lugar de la base de producción
Mock registra llamadas; el test las verifica que ocurrieron las llamadas correctas repo.add.assert_called_once_with("lucía")
Spy envuelve el objeto real y registra llamadas el resultado real y las llamadas patch.object(repo, "add", wraps=repo.add)

En Python, una sola clase, unittest.mock.Mock, puede hacer de stub, de mock y de spy. Los nombres describen lo que tú haces con ella. Eso importa, porque el error más común es usar un mock (revisar llamadas) donde bastaba un stub o un fake (revisar resultados).

Diagrama de decisión. Primera pregunta: ¿la dependencia real puede correr en un test de forma rápida, local y determinista? Si sí, úsala sin doble, o envuélvela en un spy si además necesitas ver las llamadas. Si no, segunda pregunta: ¿es tu propio código de almacenamiento, como un repositorio? Si sí, usa un fake como SQLite en memoria. Si no, tercera pregunta: ¿al test le importa que ocurra una llamada? Si sí, usa un mock con autospec y verifica la llamada. Si no, usa un stub con autospec que devuelva una respuesta fija.
¿Qué doble de prueba usar? Usa lo real cuando puedas, un fake para tu propio almacenamiento, y deja stubs y mocks (con autospec) para los bordes que no puedes ejecutar en un test.

El código bajo prueba

Una factura en dólares convertida a pesos mexicanos. rates.py consulta un servicio de tipos de cambio; invoice.py lo usa. En esta máquina nadie escucha en el puerto 9, así que una llamada real falla rápido en vez de salir a internet.

# rates.py
import json
import urllib.request


def fetch_rate(currency):
    """How many units of `currency` one USD buys (rates service)."""
    url = f"http://127.0.0.1:9/rate?c={currency}"    # nothing listens on port 9
    with urllib.request.urlopen(url, timeout=2) as resp:
        return json.load(resp)["rate"]
# invoice.py
from rates import fetch_rate


def invoice_total(amount_usd, currency):
    return round(amount_usd * fetch_rate(currency))

El 17.35 de abajo es un valor inventado para el test, no un tipo de cambio real.

Trampa de patch 1: parchea donde se busca el nombre

# test_invoice.py
import unittest
from unittest.mock import patch

from invoice import invoice_total


class InvoiceTest(unittest.TestCase):
    @patch("rates.fetch_rate", return_value=17.35)        # wrong target
    def test_patched_at_definition(self, fake_rate):
        self.assertEqual(invoice_total(2400, "MXN"), 41640)

    @patch("invoice.fetch_rate", return_value=17.35)      # where it is looked up
    def test_patched_where_used(self, fake_rate):
        self.assertEqual(invoice_total(2400, "MXN"), 41640)
        fake_rate.assert_called_once_with("MXN")
$ python3 -m unittest
E.
======================================================================
ERROR: test_patched_at_definition (test_invoice.InvoiceTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File ".../python3.9/urllib/request.py", line 1346, in do_open
    h.request(req.get_method(), req.selector, req.data, headers,
  ... 8 frames más dentro de http.client y socket ...
ConnectionRefusedError: [Errno 61] Connection refused

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File ".../python3.9/unittest/mock.py", line 1337, in patched
    return func(*newargs, **newkeywargs)
  File "~/demo/invoice/test_invoice.py", line 10, in test_patched_at_definition
    self.assertEqual(invoice_total(2400, "MXN"), 41640)
  File "~/demo/invoice/invoice.py", line 5, in invoice_total
    return round(amount_usd * fetch_rate(currency))
  File "~/demo/invoice/rates.py", line 8, in fetch_rate
    with urllib.request.urlopen(url, timeout=2) as resp:
  ... 6 frames más dentro de urllib ...
urllib.error.URLError: <urlopen error [Errno 61] Connection refused>

----------------------------------------------------------------------
Ran 2 tests in 0.010s

FAILED (errors=1)

Lee los frames de tu código hacia abajo (lección 1): la línea 5 de invoice.py llamó a la línea 8 de rates.py, la función real. El patch funcionó, pero sobre el nombre equivocado. from rates import fetch_rate copia una referencia dentro del módulo invoice al momento de importar. patch("rates.fetch_rate") reemplaza el atributo en rates, y invoice sigue teniendo el original. En la vida real, esto "funciona" hasta el día en que hay red y el test le pega en silencio a un servicio real, o se cuelga todo el timeout en el CI.

La regla: parchea el nombre en el módulo que lo usa. Y el objetivo depende del estilo de importación. Cuando cambié invoice.py a import rates y rates.fetch_rate(currency), los resultados se invirtieron: patch("rates.fetch_rate") pasó y patch("invoice.fetch_rate") falló con:

AttributeError: <module 'invoice' from '~/demo/alt/invoice.py'> does not have the attribute 'fetch_rate'
Diagrama. El módulo rates tiene el nombre fetch_rate apuntando a la función real. Cuando invoice ejecuta from rates import fetch_rate, obtiene su propio nombre invoice.fetch_rate apuntando a la misma función. Parchear rates.fetch_rate reemplaza solo el nombre en rates, así que invoice_total, que busca invoice.fetch_rate, sigue llamando a la función real y sale a la red. Parchear invoice.fetch_rate reemplaza el nombre que invoice_total usa de verdad.
Por qué parchear rates.fetch_rate no funcionó. from rates import fetch_rate copia la referencia dentro de invoice al importar; invoice_total busca el nombre en invoice, así que ese es el nombre que hay que parchear.

Trampa de patch 2: un mock acepta cualquier llamada; autospec no

Ahora una refactorización realista. El servicio de tipos de cambio ahora necesita una fecha, así que rates.py pasa a ser def fetch_rate(currency, on_date):. Nadie actualiza invoice.py, que sigue llamando fetch_rate(currency). En producción:

TypeError: fetch_rate() missing 1 required positional argument: 'on_date'

Tres tests contra ese código roto:

class InvoiceTest(unittest.TestCase):
    @patch("invoice.fetch_rate", return_value=17.35)
    def test_plain_mock(self, fake_rate):
        self.assertEqual(invoice_total(2400, "MXN"), 41640)

    @patch("invoice.fetch_rate", return_value=17.35, autospec=True)
    def test_autospec(self, fake_rate):
        self.assertEqual(invoice_total(2400, "MXN"), 41640)

    @patch("invoice.fetch_rate", return_value=17.35)
    def test_typo_in_assert(self, fake_rate):
        invoice_total(2400, "MXN")
        fake_rate.called_once_with("USD")          # missing "assert_": checks nothing
$ python3 -m unittest -v          # Python 3.9.6
test_autospec (test_invoice.InvoiceTest) ... ERROR
test_plain_mock (test_invoice.InvoiceTest) ... ok
test_typo_in_assert (test_invoice.InvoiceTest) ... ok

======================================================================
ERROR: test_autospec (test_invoice.InvoiceTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  ...
  File "~/demo/invoice2/invoice.py", line 5, in invoice_total
    return round(amount_usd * fetch_rate(currency))
  File "<string>", line 2, in fetch_rate
  File ".../python3.9/unittest/mock.py", line 180, in checksig
    sig.bind(*args, **kwargs)
  ...
TypeError: missing a required argument: 'on_date'
  • El mock simple pasó. Un Mock acepta cualquier argumento, así que fingió que la firma vieja todavía existía.
  • autospec=True falló con el mismo TypeError que lanzaría producción. Construye el mock a partir de la firma de la función real.
  • El test con el error de tipeo pasó en 3.9. called_once_with (sin assert_) es solo un atributo creado automáticamente; llamarlo no revisa nada, y encima verificaba la moneda equivocada. Python 3.12 lo rechaza:
AttributeError: 'called_once_with' is not a valid assertion. Use a spec for the mock if 'called_once_with' is meant to be an attribute.. Did you mean: 'assert_called_once_with'?

(El doble punto está en el mensaje original de Python.) Si todavía usas 3.9 a 3.11, autospec te da la misma protección, porque un mock con spec no tiene el atributo called_once_with.

Fixtures: setUp, tearDown, addCleanup y archivos temporales

Un fixture es el estado conocido desde el que arranca un test: un directorio temporal, un archivo de configuración, una base de datos con dos usuarios. unittest lo construye en setUp y lo destruye en tearDown. Hay una trampa: si setUp lanza una excepción, tearDown nunca se ejecuta.

class TearDownVersion(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.mkdtemp(prefix=PREFIX)
        self.config = os.path.join(self.tmp, "config.json")
        with open(self.config, "w") as f:
            json.dump({"currency": "EUR"}, f)
        self.client = json.load(open(os.path.join(self.tmp, "client.json")))  # forgot to create it

    def tearDown(self):
        shutil.rmtree(self.tmp)


class CleanupVersion(unittest.TestCase):
    def setUp(self):
        tmp = tempfile.TemporaryDirectory(prefix=PREFIX)
        self.addCleanup(tmp.cleanup)          # registered before anything can fail
        self.config = os.path.join(tmp.name, "config.json")
        with open(self.config, "w") as f:
            json.dump({"currency": "EUR"}, f)
        self.client = json.load(open(os.path.join(tmp.name, "client.json")))

Las dos versiones tienen el mismo bug en setUp (falta client.json). Ejecuté cada test 5 veces y conté los directorios que quedaron en la carpeta temporal del sistema:

TearDownVersion: runs=5 errors=5 leftover dirs=5
CleanupVersion: runs=5 errors=5 leftover dirs=0

El error en sí fue el mismo en ambas:

FileNotFoundError: [Errno 2] No such file or directory: '$TMPDIR/invoice-test-9nvxeqa1/client.json'

Las funciones registradas con addCleanup se ejecutan aunque setUp falle, en orden inverso al de registro. Registra la limpieza justo después de crear el recurso, y prefiere tempfile.TemporaryDirectory a escribir en una ruta fija, para que dos corridas de tests (o dos personas en el mismo runner de CI) nunca compartan archivos. Para fixtures caros que se pueden compartir sin riesgo, setUpClass corre una vez por clase, pero el estado compartido es justo lo que vuelve a los tests dependientes del orden (lección 5).

Un fake le gana a un mock: SQLite en memoria

Ahora un servicio de registro con un repositorio. Los nombres se normalizan para que "Lucía" y "lucía" sean el mismo usuario.

# users.py
import sqlite3


class UserRepo:
    def __init__(self, conn):
        self.conn = conn
        conn.execute("CREATE TABLE IF NOT EXISTS users (name TEXT PRIMARY KEY)")

    def exists(self, name):
        row = self.conn.execute("SELECT 1 FROM users WHERE name = ?", (name,)).fetchone()
        return row is not None

    def add(self, name):
        self.conn.execute("INSERT INTO users (name) VALUES (?)", (name,))


def signup(repo, name):
    key = name.strip().casefold()
    if repo.exists(key):
        raise ValueError(f"name already taken: {key}")
    repo.add(key)
    return key

Tres formas de probar signup:

class SignupWithMock(unittest.TestCase):
    def test_new_name_is_added(self):
        repo = Mock()
        repo.exists.return_value = False
        signup(repo, " Lucía ")
        repo.add.assert_called_once()


class SignupWithAutospec(unittest.TestCase):
    def test_new_name_is_added(self):
        repo = create_autospec(UserRepo, instance=True)
        repo.exists.return_value = False
        self.assertEqual(signup(repo, " Lucía "), "lucía")
        repo.add.assert_called_once_with("lucía")


class SignupWithSqliteFake(unittest.TestCase):
    def setUp(self):
        conn = sqlite3.connect(":memory:")
        self.addCleanup(conn.close)
        self.repo = UserRepo(conn)

    def test_new_name_is_added(self):
        self.assertEqual(signup(self.repo, " Lucía "), "lucía")
        self.assertTrue(self.repo.exists("lucía"))

    def test_same_name_different_case_is_rejected(self):
        signup(self.repo, "Lucía")
        with self.assertRaises(ValueError):
            signup(self.repo, "lucía")

Los cuatro tests pasan con el código correcto. La diferencia solo aparece cuando alguien cambia el código.

Demasiados mocks: dos refactorizaciones rotas, un test en verde

Refactorización 1: alguien del equipo renombra UserRepo.add a insert y se olvida de actualizar signup. Ahora producción lanza AttributeError en cada registro.

test_new_name_is_added (test_users.SignupWithAutospec) ... ERROR
test_new_name_is_added (test_users.SignupWithMock) ... ok
test_new_name_is_added (test_users.SignupWithSqliteFake) ... ERROR
test_same_name_different_case_is_rejected (test_users.SignupWithSqliteFake) ... ERROR
...
AttributeError: Mock object has no attribute 'add'
...
AttributeError: 'UserRepo' object has no attribute 'add'

Refactorización 2: alguien quita .casefold() de signup ("la base de datos se encarga de las mayúsculas"), pero la base no se encarga. Ahora "Lucía" y "lucía" son dos cuentas distintas.

test_new_name_is_added (test_users.SignupWithAutospec) ... FAIL
test_new_name_is_added (test_users.SignupWithMock) ... ok
test_new_name_is_added (test_users.SignupWithSqliteFake) ... FAIL
test_same_name_different_case_is_rejected (test_users.SignupWithSqliteFake) ... FAIL
...
AssertionError: 'Lucía' != 'lucía'
- Lucía
? ^
+ lucía
? ^
...
AssertionError: ValueError not raised
Refactorización rota Mock simple create_autospec Fake con SQLite
método renombrado, quien lo llama sin actualizar pasó la atrapó (AttributeError) la atrapó (AttributeError)
normalización eliminada pasó la atrapó (revisa el valor devuelto) la atrapó, incluido el bug de cuentas duplicadas

El test con mock simple no puede fallar, porque solo verifica que se llamó algún add de alguna forma, sobre un objeto que inventa cualquier atributo que le pidas. Eso es exceso de mocks (over-mocking): el test repite la implementación y no revisa nada que le importe al usuario. El test con autospec atrapó la segunda refactorización solo porque también revisa el valor devuelto; el fake la atrapó comportándose como una base de datos. Solo el fake expresaba la regla real: "el mismo nombre con distintas mayúsculas se rechaza".

Usa mocks en los bordes que no puedes ejecutar (red, proveedor de pagos, reloj, envío de correos). Para tu propio código, prefiere lo real o un fake.

Un spy: comportamiento real, llamadas registradas

Cuando quieres el repositorio real pero también ver las llamadas, envuélvelo:

repo = UserRepo(sqlite3.connect(":memory:"))
with patch.object(repo, "add", wraps=repo.add) as spy:
    signup(repo, "Lucía")
print(spy.call_args_list, repo.exists("lucía"))
[call('lucía')] True

La fila sí se insertó y la llamada quedó registrada.

La misma idea en Dart: el compilador es tu autospec

Dart no tiene patch; las dependencias se pasan como parámetro. Un fake escrito a mano que implements una interfaz lo revisa el compilador. El paquete mocktail también está en la caché local de pub, pero un fake no necesita ningún paquete:

// lib/rates.dart
abstract interface class RateSource {
  Future<double> rate(String currency);
}

Future<int> invoiceTotal(RateSource rates, int amountUsd, String currency) async =>
    (amountUsd * await rates.rate(currency)).round();

// test/rates_test.dart
class FakeRates implements RateSource {
  final Map<String, double> table;
  final calls = <String>[];          // a spy: records what was asked
  FakeRates(this.table);

  @override
  Future<double> rate(String currency) async {
    calls.add(currency);
    return table[currency]!;
  }
}

void main() {
  test('converts with the fake rate', () async {
    final rates = FakeRates({'EUR': 0.92});
    expect(await invoiceTotal(rates, 1000, 'EUR'), 920);
    expect(rates.calls, ['EUR']);
  });
}

Pasó. Luego agregué el mismo parámetro de fecha a la interfaz (rate(String currency, DateTime onDate)) y corrí dart test otra vez:

  Failed to load "test/rates_test.dart":
  test/rates_test.dart:10:18: Error: The method 'FakeRates.rate' has fewer positional arguments than those of overridden method 'RateSource.rate'.
  ...
  lib/rates.dart:6:34: Error: Too few positional arguments: 2 required, 1 given.

El fake y el código que llama se rompieron en tiempo de compilación, que es lo que autospec intenta imitar en tiempo de ejecución en Python.

Lo que cuesta cada doble, medido

Muchas veces se eligen mocks "porque la base de datos es lenta". Medí un mismo test de registro escrito de cuatro maneras: N = 1,000 corridas, 5 repeticiones, mediana por test, después de una llamada de calentamiento. Python 3.9.6, Mac con Apple Silicon (M1 Pro), SQLite 3.51.0; lo corrí dos veces con resultados parecidos.

Doble Python 3.9.6 Python 3.12.14
Mock() simple 140.9 µs 54.3 µs
create_autospec(UserRepo, instance=True) 785.8 µs 660.0 µs
fake SQLite :memory: 47.2 µs 45.4 µs
archivo SQLite en un TemporaryDirectory 1,071.2 µs 815.4 µs
Gráfico de barras en microsegundos por test. SQLite en memoria 47.2, Mock simple 140.9, create_autospec 785.8, archivo SQLite en un directorio temporal 1,071.2.
Tiempo por test de registro según el tipo de doble: N = 1,000, mediana de 5 repeticiones, Python 3.9.6, SQLite 3.51.0, Mac con Apple Silicon (M1 Pro). El fake de SQLite en memoria fue el más rápido; create_autospec paga por inspeccionar la clase.

En ambas versiones la base en memoria fue la opción más rápida, y create_autospec fue la más lenta de las opciones en memoria, porque inspecciona la clase para copiar la firma de cada método. Mil tests con autospec cuestan menos de un segundo, lo cual sigue siendo barato por la protección que dan; el punto es que "los mocks son más rápidos" no es verdad automáticamente. El archivo de base de datos real en un directorio temporal fue lo más lento, sobre todo por crear y borrar el directorio y el archivo.

Tabla de decisión

Situación Usa
Tu propia función pura ningún doble
Tu propio repositorio o capa de almacenamiento un fake en memoria (SQLite :memory:, una clase sobre un dict)
Servicio de red, API de pagos, correo un stub con autospec=True, más un test de contrato de tu cliente contra respuestas grabadas
"¿De verdad se envió la alerta o el correo?" un mock, verificando la llamada con assert_called_once_with
Objeto real, pero necesitas ver las llamadas un spy (wraps=)
Tiempo y aleatoriedad inyecta un reloj o una semilla (lección 5)

Límites honestos y notas de versión

  • Python 3.9.6 fue el entorno principal; 3.12.14 se usó para la revisión del error de tipeo y para la segunda columna de tiempos. El rechazo de called_once_with y errores parecidos llegó en Python 3.12; en nuestra corrida, 3.9 lo aceptó en silencio. Desde Python 3.5 ya se rechazan los atributos que empiezan con assert o assret (comprobado en 3.9).
  • autospec, create_autospec y wraps= existen desde que unittest.mock entró en la biblioteca estándar en Python 3.3.
  • El servicio de tipos de cambio se simula con un puerto local cerrado. Contra un servicio real y lento, el patch equivocado se colgaría todo el timeout en lugar de fallar en 10 ms.
  • SQLite :memory: es un buen fake para código escrito contra SQLite. Si producción usa otra base, las diferencias de dialecto SQL (tipos, ON CONFLICT, sensibilidad a mayúsculas en comparaciones) pueden hacer que el fake pase donde producción falla; conserva algunos tests de integración contra el motor real. Eso no se ejecutó aquí.
  • Dart 3.11.5, test 1.30.0 desde la caché local de pub. mocktail no se usó en la corrida.
  • Los tiempos en microsegundos son de una sola máquina y varían entre corridas (en las dos mediciones, el mínimo y el máximo quedaron dentro del 12% de la mediana).

Ejercicios

  1. report.py tiene import datetime y llama datetime.date.today(). ¿Qué objetivo parchearías, y por qué falla patch("datetime.date.today")? (Pruébalo.)
  2. Reescribe SignupWithMock.test_new_name_is_added para que hubiera atrapado la refactorización 2, sin usar SQLite.

Respuestas

  1. datetime.date es un tipo integrado escrito en C, así que no se le pueden asignar atributos: patch("datetime.date.today") lanza TypeError: cannot set 'today' attribute of immutable type 'datetime.date' en 3.12 (en 3.9: can't set attributes of built-in/extension type 'datetime.date'). Parchea el nombre que usa tu módulo, patch("report.datetime"), y asigna mock_dt.date.today.return_value. Mejor todavía: pasa un reloj o una fecha a la función, que es lo que hace la lección 5.
  2. Revisa el resultado y la llamada exacta: self.assertEqual(signup(repo, " Lucía "), "lucía") y repo.add.assert_called_once_with("lucía"). Usa create_autospec(UserRepo, instance=True) en lugar de Mock() para atrapar también el renombre de la refactorización 1. Aun así es más débil que el fake: no puede atrapar el bug de cuentas duplicadas, porque exists está fijado en False.

Para seguir

Preguntas frecuentes

¿Por qué unittest.mock.patch no reemplaza mi función?

Porque parcheaste el nombre donde se define la función, no donde se busca. Si invoice.py tiene 'from rates import fetch_rate', el test debe parchear 'invoice.fetch_rate'. Parchear 'rates.fetch_rate' dejó intacta la referencia de invoice, y nuestro test hizo una conexión real que falló con Connection refused.

¿Qué atrapa autospec=True que un Mock simple no atrapa?

Llamadas que no coinciden con la firma real y atributos que no existen. Cuando fetch_rate pasó a requerir un argumento on_date, el test con mock simple siguió pasando mientras producción lanzaba TypeError; el test con autospec falló con 'missing a required argument: on_date'.

¿Un fake con SQLite en memoria es más lento que un mock?

No en nuestra medición. Con Python 3.9 en una Apple M1 Pro, un test con una base sqlite3 ':memory:' tardó unos 47 microsegundos, un Mock simple unos 141, create_autospec unos 786 y un archivo SQLite en un directorio temporal unos 1,071 microsegundos por test.

  • #testing
  • #intermedio
  • #python
  • #unittest.mock
  • #mock
  • #fixtures
  • #sqlite

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

Acerca de · Apps

↑