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).
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'
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
Mockacepta cualquier argumento, así que fingió que la firma vieja todavía existía. autospec=Truefalló con el mismoTypeErrorque 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(sinassert_) 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 |
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_withy 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 conassertoassret(comprobado en 3.9). autospec,create_autospecywraps=existen desde queunittest.mockentró 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.0desde la caché local de pub.mocktailno 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
report.pytieneimport datetimey llamadatetime.date.today(). ¿Qué objetivo parchearías, y por qué fallapatch("datetime.date.today")? (Pruébalo.)- Reescribe
SignupWithMock.test_new_name_is_addedpara que hubiera atrapado la refactorización 2, sin usar SQLite.
Respuestas
datetime.datees un tipo integrado escrito en C, así que no se le pueden asignar atributos:patch("datetime.date.today")lanzaTypeError: 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 asignamock_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.- Revisa el resultado y la llamada exacta:
self.assertEqual(signup(repo, " Lucía "), "lucía")yrepo.add.assert_called_once_with("lucía"). Usacreate_autospec(UserRepo, instance=True)en lugar deMock()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, porqueexistsestá fijado enFalse.
Para seguir
- Anterior: Lección 3: pruebas unitarias básicas
- Siguiente: Lección 5: tests inestables por tiempo, aleatoriedad y concurrencia
- Antes: Lección 1: leer mensajes de error, Lección 2: print, logging y el depurador
- Después: Lección 6: git bisect
- Relacionado: decoradores y context managers en Python (en inglés; cómo funcionan
@patchywith patch(...)), el diagnóstico equivocado de un agente de IA que delataron los logs (en inglés; otro caso en el que un check en verde no era la verdad) y la ruta de aprendizaje de programación.
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.