21. Probar código que trabaja con archivos sin tocar el sistema real

21.1 Objetivo del tema

Muchos programas leen y escriben archivos. En una prueba unitaria, no siempre queremos depender de archivos reales del sistema: pueden no existir, cambiar de contenido o dejar residuos después de la ejecución.

En este tema veremos cómo probar lectura y escritura usando mock_open, patch, funciones inyectadas y tmp_path de pytest.

Objetivo práctico: probar lógica que lee y escribe archivos manteniendo las pruebas controladas, rápidas y repetibles.

21.2 Código de lectura simple

Supongamos esta función:

def leer_configuracion(ruta):
    with open(ruta, "r", encoding="utf-8") as archivo:
        return archivo.read()

Si la prueba usa un archivo real, depende de que exista en una ruta concreta. Podemos evitarlo reemplazando open.

21.3 Usar mock_open

mock_open crea un mock preparado para comportarse como open en muchos casos simples:

from unittest.mock import mock_open, patch

from configuracion import leer_configuracion


def test_leer_configuracion():
    abrir_archivo = mock_open(read_data="modo=debug")

    with patch("builtins.open", abrir_archivo):
        contenido = leer_configuracion("config.txt")

    assert contenido == "modo=debug"
    abrir_archivo.assert_called_once_with("config.txt", "r", encoding="utf-8")

La prueba no crea ni lee un archivo real.

21.4 Por qué patch sobre builtins.open

Si el código llama directamente a open(...), normalmente ese nombre se resuelve desde builtins. Por eso usamos:

patch("builtins.open", abrir_archivo)

Si el módulo importara open de otra forma o usara una función propia, habría que parchear el nombre usado por ese módulo.

21.5 Leer líneas

Función:

def leer_usuarios(ruta):
    with open(ruta, "r", encoding="utf-8") as archivo:
        return [linea.strip() for linea in archivo if linea.strip()]

Prueba:

def test_leer_usuarios():
    abrir_archivo = mock_open(read_data="Ana\n\nLuis\n")

    with patch("builtins.open", abrir_archivo):
        usuarios = leer_usuarios("usuarios.txt")

    assert usuarios == ["Ana", "Luis"]

mock_open puede simular contenido línea por línea para casos simples.

21.6 Escribir archivos

Función:

def guardar_reporte(ruta, contenido):
    with open(ruta, "w", encoding="utf-8") as archivo:
        archivo.write(contenido)

Prueba:

def test_guardar_reporte():
    abrir_archivo = mock_open()

    with patch("builtins.open", abrir_archivo):
        guardar_reporte("reporte.txt", "resultado final")

    abrir_archivo.assert_called_once_with("reporte.txt", "w", encoding="utf-8")
    archivo = abrir_archivo()
    archivo.write.assert_called_once_with("resultado final")

Verificamos que se intentó escribir el contenido correcto.

21.7 Agregar contenido a un archivo

Si el código abre en modo append:

def agregar_log(ruta, mensaje):
    with open(ruta, "a", encoding="utf-8") as archivo:
        archivo.write(mensaje + "\n")

La prueba debe verificar el modo "a":

def test_agregar_log():
    abrir_archivo = mock_open()

    with patch("builtins.open", abrir_archivo):
        agregar_log("app.log", "Inicio")

    abrir_archivo.assert_called_once_with("app.log", "a", encoding="utf-8")
    abrir_archivo().write.assert_called_once_with("Inicio\n")

21.8 Simular archivo inexistente

Podemos hacer que open lance FileNotFoundError:

def cargar_configuracion_o_default(ruta):
    try:
        with open(ruta, "r", encoding="utf-8") as archivo:
            return archivo.read()
    except FileNotFoundError:
        return "modo=prod"

Prueba:

def test_cargar_configuracion_o_default_si_no_existe():
    abrir_archivo = mock_open()
    abrir_archivo.side_effect = FileNotFoundError

    with patch("builtins.open", abrir_archivo):
        contenido = cargar_configuracion_o_default("config.txt")

    assert contenido == "modo=prod"

21.9 Leer JSON

Función:

import json


def leer_usuario_json(ruta):
    with open(ruta, "r", encoding="utf-8") as archivo:
        return json.load(archivo)

Prueba:

def test_leer_usuario_json():
    abrir_archivo = mock_open(read_data='{"id": 1, "nombre": "Ana"}')

    with patch("builtins.open", abrir_archivo):
        usuario = leer_usuario_json("usuario.json")

    assert usuario == {"id": 1, "nombre": "Ana"}

El parser JSON trabaja con el objeto de archivo simulado.

21.10 Escribir JSON

Función:

def guardar_usuario_json(ruta, usuario):
    with open(ruta, "w", encoding="utf-8") as archivo:
        json.dump(usuario, archivo)

Prueba verificando que se abrió el archivo:

def test_guardar_usuario_json():
    abrir_archivo = mock_open()
    usuario = {"id": 1, "nombre": "Ana"}

    with patch("builtins.open", abrir_archivo):
        guardar_usuario_json("usuario.json", usuario)

    abrir_archivo.assert_called_once_with("usuario.json", "w", encoding="utf-8")

Si queremos verificar el contenido exacto generado por json.dump, a veces tmp_path resulta más claro.

21.11 Inyectar la función open

Otra opción es recibir la función para abrir archivos como dependencia:

def leer_configuracion(ruta, abrir_archivo=open):
    with abrir_archivo(ruta, "r", encoding="utf-8") as archivo:
        return archivo.read()

La prueba puede pasar mock_open sin usar patch:

def test_leer_configuracion_con_open_inyectado():
    abrir_archivo = mock_open(read_data="modo=test")

    contenido = leer_configuracion("config.txt", abrir_archivo)

    assert contenido == "modo=test"

Esta técnica hace explícita la dependencia, aunque no siempre se usa para funciones muy simples.

21.12 Cuidado con valores por defecto

Usar abrir_archivo=open captura el valor de open al definir la función. Si luego intentas parchear builtins.open, puede que esa función siga usando el valor capturado.

Una alternativa más flexible es:

def leer_configuracion(ruta, abrir_archivo=None):
    if abrir_archivo is None:
        abrir_archivo = open

    with abrir_archivo(ruta, "r", encoding="utf-8") as archivo:
        return archivo.read()

En pruebas, pasamos abrir_archivo. En producción, usa open.

21.13 Cuándo usar tmp_path

tmp_path es una fixture de pytest que crea una carpeta temporal para la prueba. Técnicamente toca el sistema de archivos, pero lo hace en un espacio aislado y controlado.

Es útil cuando quieres probar integración real con archivos sin afectar archivos del proyecto.

mock_open sirve para aislar; tmp_path sirve para probar comportamiento real de archivos en un entorno temporal.

21.14 Ejemplo con tmp_path

Prueba de lectura real en archivo temporal:

def test_leer_configuracion_con_tmp_path(tmp_path):
    ruta = tmp_path / "config.txt"
    ruta.write_text("modo=test", encoding="utf-8")

    contenido = leer_configuracion(ruta)

    assert contenido == "modo=test"

Esta prueba no usa mocks. Verifica que el código funciona con archivos reales, pero dentro de una carpeta temporal.

21.15 Escribir con tmp_path

Ejemplo:

def test_guardar_reporte_con_tmp_path(tmp_path):
    ruta = tmp_path / "reporte.txt"

    guardar_reporte(ruta, "resultado final")

    assert ruta.read_text(encoding="utf-8") == "resultado final"

Para validar el contenido final de un archivo, tmp_path suele ser más directo que inspeccionar llamadas a write.

21.16 Elegir entre mock_open y tmp_path

  • Usa mock_open cuando quieras aislar la prueba del sistema de archivos.
  • Usa mock_open para simular errores como FileNotFoundError.
  • Usa tmp_path cuando quieras verificar lectura y escritura real en archivos temporales.
  • Usa inyección de dependencias si quieres evitar patch y controlar open directamente.

21.17 Ejercicio práctico

Prueba esta función con mock_open:

def contar_usuarios_activos(ruta):
    with open(ruta, "r", encoding="utf-8") as archivo:
        lineas = archivo.readlines()

    return sum(1 for linea in lineas if linea.strip().endswith(",activo"))

Usa contenido con tres usuarios, dos activos y uno inactivo.

21.18 Solución posible del ejercicio

Una solución:

from unittest.mock import mock_open, patch

from usuarios import contar_usuarios_activos


def test_contar_usuarios_activos():
    contenido = (
        "ana,activo\n"
        "luis,inactivo\n"
        "marta,activo\n"
    )
    abrir_archivo = mock_open(read_data=contenido)

    with patch("builtins.open", abrir_archivo):
        cantidad = contar_usuarios_activos("usuarios.csv")

    assert cantidad == 2
    abrir_archivo.assert_called_once_with("usuarios.csv", "r", encoding="utf-8")

21.19 Conclusión

Para probar código que trabaja con archivos podemos reemplazar open con mock_open, inyectar la función de apertura o usar tmp_path para archivos temporales reales. La elección depende de si buscamos aislamiento total o comprobar comportamiento real de lectura y escritura.

En el próximo tema veremos cómo controlar fechas, horas, aleatoriedad e identificadores generados.