IDS Guía de estudio

Semana 4 · Módulo 5

Modularización, funciones y ámbito

Hasta ahora cada programa era una sola secuencia. Esta semana lo partes en funciones: piezas con nombre, que reciben datos, devuelven un resultado y se pueden probar por separado. Es el paso que convierte un script en software mantenible.

Pregunta orientadora¿Cómo convierto un script de 200 líneas en piezas que puedo reutilizar, probar y explicar una por una?

Lecturas
PCC 8 · PCC 11.1
Proyecto
Preparar Fase 3
Evaluación
Control 2: la próxima semana

Cómo usar esta guía

Antes de clasePCC 8 completo y la sección 11.1. Luego el checkpoint.
Durante el laboratorioEl taller es de refactorización: usa el caso guiado como modelo.
Después de claseInstala pytest y ejecuta de verdad las pruebas. Luego los ejercicios.
EvaluacionesControl 2 la próxima semana y Parcial en la semana 6.
  • Pregunta
  • →
  • Exploración
  • →
  • Implementación
  • →
  • Resultado
  • →
  • Interpretación
  • →
  • Verificación

Qué puedes usar esta semana

Sí, desde ahora

  • Todo lo de las semanas 1 a 3
  • def, return, None
  • Argumentos posicionales, nombrados y valores por defecto
  • Retornar varios valores: return base, iva y desempacar base, iva = f()
  • Docstrings """..."""
  • Variables locales y globales (y por qué evitar las globales)
  • Recursividad elemental
  • import, from ... import ..., as
  • assert y pytest

Todavía no

  • Pasar listas a funciones y modificarlas → semana 5 (PCC 8 tiene esas secciones: léelas, pero se practican en S5)
  • *args y **kwargs (PCC 8): lectura opcional; usan tuplas y diccionarios
  • try/except → semana 7
  • Clases → semana 8

Retornar varios valores produce en realidad una tupla; las tuplas se estudian en la semana 5. Por ahora basta con desempacar.

Lecturas de preparación

Reading checkpoint

¿Qué vale resultado?

def calcular_iva(monto):
    print(monto * 0.13)

resultado = calcular_iva(100)

Imprimir no es devolver. Sin return, toda función devuelve None. Si luego haces resultado + 100, obtienes TypeError. Regla: las funciones de cálculo devuelven; imprimir es trabajo de otra parte del programa.

¿Cuál definición es inválida?

Los parámetros con valor por defecto van después de los obligatorios; si no, SyntaxError.

Con def cuota(monto, tasa, meses=12), ¿cuál llamada es válida y hace lo esperado?

Con argumentos nombrados el orden no importa. Pero un posicional no puede ir después de un nombrado (SyntaxError), faltar un obligatorio o sobrar uno da TypeError.

¿Qué imprime este código?

iva = 0.13

def cambiar_iva():
    iva = 0.15

cambiar_iva()
print(iva)

Asignar dentro de una función crea una variable local nueva, aunque se llame igual. La global no cambia. Existe la palabra global para forzarlo, pero en código profesional casi nunca se usa: se pasan parámetros y se devuelven resultados.

¿Qué le falta a una función recursiva que termina con RecursionError?

Cada llamada debe acercarse al caso base (p. ej. anios - 1 hasta llegar a 0). Si nunca lo alcanza, Python se detiene tras unas 1000 llamadas anidadas.

¿Qué hace assert calcular_total(10, 4) == 45.2 si la función es correcta?

assert solo “habla” cuando la condición es falsa: entonces lanza AssertionError. Silencio = prueba superada.

¿Qué archivos y funciones ejecuta pytest automáticamente?

Por convención de nombres. Si tu función se llama prueba_total(), pytest no la encuentra y dice “no tests ran”.

¿Cuál es una función pura?

Mismos argumentos → mismo resultado, siempre, sin efectos secundarios. Son las más fáciles de probar: por eso el laboratorio pide “funciones puras con pruebas”.

Tienes facturacion.py con la función calcular_total. Desde main.py en la misma carpeta, ¿qué es correcto?

El módulo se importa sin .py. Con import facturacion se llama facturacion.calcular_total(...); con from facturacion import calcular_total, directamente calcular_total(...).

De script plano a funciones

En palabras simples

Una función es una receta con nombre. En vez de explicar cómo hacer café cada vez, dices “haz café” y listo. Refactorizar es tomar pasos repetidos y guardarlos en una receta: si la receta cambia, la cambias en un solo lugar.

Caso: tres clientes, tres copias del mismo cálculo

Pregunta

Alguien resolvió la facturación de tres clientes copiando y pegando el cálculo. Funciona… casi. ¿Qué tiene de malo y cómo se arregla?

# factura_plana.py — el "código espagueti" en pequeño
subtotal_1 = 12.50 * 3
if subtotal_1 >= 50:
    subtotal_1 = subtotal_1 * 0.9
total_1 = subtotal_1 * 1.13
print(f"Cliente Ana:   ${total_1:.2f}")

subtotal_2 = 8.00 * 5
if subtotal_2 >= 50:
    subtotal_2 = subtotal_2 * 0.9
total_2 = subtotal_2 * 1.13
print(f"Cliente Luis:  ${total_2:.2f}")

subtotal_3 = 6.25 * 8
if subtotal_3 > 50:
    subtotal_3 = subtotal_3 * 0.9
total_3 = subtotal_3 * 1.13
print(f"Cliente Marta: ${total_3:.2f}")
Cliente Ana:   $42.37
Cliente Luis:  $45.20
Cliente Marta: $56.50
Exploración
  • Error escondido: en la tercera copia dice > 50 en lugar de >= 50. Marta compró exactamente $50 y se quedó sin descuento: pagó $56.50 en vez de $50.85.
  • Números mágicos: 0.9, 1.13, 50 repetidos. Si cambia el IVA, hay que encontrar y cambiar cada uno.
  • No se puede probar el cálculo sin ejecutar todo el script y leer lo que imprime.
  • Receta de refactorización: (1) identifica lo que se repite, (2) nombra cada paso con un verbo, (3) conviértelo en función con parámetros, (4) reemplaza las copias por llamadas, (5) comprueba que el resultado no cambió (salvo el error que corriges).
Implementación
TASA_IVA = 0.13
TASA_DESCUENTO = 0.10
MINIMO_DESCUENTO = 50


def calcular_subtotal(precio_unitario, cantidad):
    """Devuelve el precio unitario multiplicado por la cantidad."""
    return precio_unitario * cantidad


def aplicar_descuento(subtotal):
    """Aplica el descuento por volumen si el subtotal llega al mínimo."""
    if subtotal >= MINIMO_DESCUENTO:
        return subtotal * (1 - TASA_DESCUENTO)
    return subtotal


def calcular_total(precio_unitario, cantidad):
    """Calcula el total a pagar con descuento por volumen e IVA.

    Args:
        precio_unitario: precio de una unidad, en dólares.
        cantidad: número de unidades (entero no negativo).

    Returns:
        Total a pagar, redondeado a 2 decimales.
    """
    subtotal = calcular_subtotal(precio_unitario, cantidad)
    base = aplicar_descuento(subtotal)
    return round(base * (1 + TASA_IVA), 2)


print(f"Cliente Ana:   ${calcular_total(12.50, 3):.2f}")
print(f"Cliente Luis:  ${calcular_total(8.00, 5):.2f}")
print(f"Cliente Marta: ${calcular_total(6.25, 8):.2f}")
Resultado
Cliente Ana:   $42.37
Cliente Luis:  $45.20
Cliente Marta: $50.85
Interpretación

Marta ahora recibe su descuento: la regla vive en un solo lugar y no puede quedar distinta entre clientes. Añadir un cuarto cliente es una línea. Cada función hace una cosa y su nombre lo dice.

Ana y Luis pagan lo mismo que antes: una refactorización no debe cambiar resultados, salvo el error que corriges a propósito. Por eso se guarda la salida del script original y se compara.

Verificación

Cada función se puede probar sola: aplicar_descuento(50) debe dar 45, aplicar_descuento(49.99) debe dar 49.99. Eso es exactamente lo que harán las pruebas automáticas.

Una función recibe argumentos, los asigna a sus parámetros, calcula y con return devuelve 50.85 a la variable total; print solo muestra en pantalla.
Los argumentos entran, el return devuelve el resultado a quien llamó. Un print solo lo muestra.
Anatomía de una función
def nombre_en_verbo(parametro_1, parametro_2=valor_por_defecto):
    """Una línea que dice QUÉ hace (no cómo)."""
    # cuerpo: usa solo sus parámetros y constantes
    return resultado

Parámetro: el nombre en la definición (precio_unitario). Argumento: el valor en la llamada (12.50). Nombres en verbo: calcular_, aplicar_, validar_, es_/tiene_ para las que devuelven bool.

Parámetros, valores por defecto y retorno

En palabras simples

Los parámetros son los ingredientes que recibe la receta. Un valor por defecto es “si no me dices, uso 12 meses”. return es entregarte el plato; print solo te enseña una foto del plato: no te lo puedes comer (no lo puedes usar después).

Caso: la cuota mensual de un préstamo

Pregunta

Una cooperativa quiere calcular la cuota fija mensual de un préstamo (sistema francés). La mayoría de préstamos son a 12 meses, pero puede cambiar. También hay préstamos promocionales al 0 %.

Exploración
  • Fórmula: cuota = monto × i / (1 − (1 + i)^−n), con i = tasa anual / 12 y n = meses.
  • meses tiene un valor típico → valor por defecto meses=12.
  • Caso borde: con tasa 0 la fórmula divide 0 entre 0. Se trata aparte: cuota = monto / meses.
Implementación
def calcular_cuota(monto, tasa_anual, meses=12):
    """Calcula la cuota mensual fija de un préstamo (sistema francés).

    Args:
        monto: capital prestado, en dólares.
        tasa_anual: tasa nominal anual como fracción (0.12 = 12 %).
        meses: plazo en meses; 12 por defecto.

    Returns:
        Cuota mensual redondeada a 2 decimales.
    """
    if tasa_anual == 0:
        return round(monto / meses, 2)
    i = tasa_anual / 12
    cuota = monto * i / (1 - (1 + i) ** -meses)
    return round(cuota, 2)


def desglosar_total(total_con_iva, tasa=0.13):
    """Separa un total con IVA en base imponible e IVA."""
    base = round(total_con_iva / (1 + tasa), 2)
    iva = round(total_con_iva - base, 2)
    return base, iva


print(calcular_cuota(5000, 0.12))                       # posicionales, meses por defecto
print(calcular_cuota(5000, 0.12, 24))                   # meses explícito
print(calcular_cuota(meses=36, monto=10000, tasa_anual=0.18))  # nombrados
print(calcular_cuota(5000, 0))                          # caso borde: tasa 0

base, iva = desglosar_total(113)
print(f"Base: {base}, IVA: {iva}")
Resultado
444.24
235.37
361.52
416.67
Base: 100.0, IVA: 13.0
Interpretación

Alargar el plazo de 12 a 24 meses baja la cuota de $444.24 a $235.37, pero el total de intereses sube de $330.88 (444.24 × 12 − 5000) a $648.88. La función permite comparar escenarios con una línea cada uno: eso es valor para quien decide.

desglosar_total devuelve dos valores separados por coma, y la llamada los recibe en dos variables. (Técnicamente devuelve una tupla; la estudias la próxima semana.)

Verificación
  • Con tasa 0, la cuota × meses debe ser exactamente el monto: 416.67 × 12 ≈ 5000.04 (el centavo sale del redondeo).
  • La cuota × meses siempre debe ser mayor que el monto si la tasa es positiva.
  • Compara con el simulador de un banco o con la función PAGO de Excel: =PAGO(12%/12; 12; -5000).

Ámbito: variables locales y globales

En palabras simples

Lo que pasa en la cocina se queda en la cocina: las variables creadas dentro de una función son locales y desaparecen cuando la función termina, aunque se llamen igual que otra de afuera.

Cada llamada a una función tiene su propio espacio de variables. Lo que se crea adentro muere al terminar la función y no choca con variables de afuera que se llamen igual.

descuento = 0.05                 # global


def precio_final(precio):
    descuento = 0.10             # local: es OTRA variable
    return precio * (1 - descuento)


a = precio_final(200)
b = 200 * (1 - descuento)       # aquí se usa la global
print(a, b, descuento)
180.0 190.0 0.05
El espacio global contiene descuento igual a 0.05; la llamada a precio_final tiene su propio espacio local con precio 200 y otro descuento de 0.10 que desaparece al terminar.
Cada llamada tiene su propio espacio de variables; se crea al llamar y desaparece al terminar.
El error de modificar una global
total_ventas = 0

def registrar_venta(monto):
    total_ventas = total_ventas + monto   # ¿cuál total_ventas?

registrar_venta(100)
UnboundLocalError: cannot access local variable 'total_ventas' where it is not associated with a value

Como la función asigna total_ventas, Python la considera local en toda la función, y a la derecha del = todavía no tiene valor. La solución correcta no es global, sino recibir y devolver:

def registrar_venta(total_actual, monto):
    """Devuelve el nuevo total después de registrar una venta."""
    return total_actual + monto

total_ventas = 0
total_ventas = registrar_venta(total_ventas, 100)
total_ventas = registrar_venta(total_ventas, 50)
print(total_ventas)
150

Recursividad elemental

En palabras simples

La recursión es como las muñecas rusas: para abrir la grande, abres la siguiente, y así hasta la más pequeña, que ya no tiene nada adentro (el caso base). Luego vas cerrando de adentro hacia afuera.

Caso: el valor futuro, definido en términos de sí mismo

Pregunta

El valor de una inversión dentro de n años es el valor dentro de n − 1 años, multiplicado por (1 + tasa). Y dentro de 0 años es el capital. ¿Puede una función expresar eso literalmente?

Exploración

Toda función recursiva tiene dos partes: el caso base (se responde sin llamarse otra vez: 0 años → el capital) y el caso recursivo (se llama con un problema más pequeño: anios - 1).

Implementación
def valor_futuro(capital, tasa, anios):
    """Valor futuro con interés compuesto anual, calculado recursivamente."""
    if anios == 0:                                   # caso base
        return capital
    anterior = valor_futuro(capital, tasa, anios - 1)  # caso recursivo
    return round(anterior * (1 + tasa), 2)


print(valor_futuro(1000, 0.10, 3))
Resultado
1331.0
Interpretación

La llamada con 3 años “espera” a la de 2, que espera a la de 1, que espera a la de 0. La de 0 responde 1000 y las respuestas regresan multiplicándose: 1100 → 1210 → 1331. Esa pila de llamadas en espera es lo que trazas en la traza 2.

Pila de llamadas de valor_futuro: la llamada con 3 años llama a la de 2, 1 y 0; la de 0 es el caso base y devuelve 1000, y las respuestas regresan como 1100, 1210 y 1331.
La última llamada en abrirse (el caso base) es la primera en responder.
Verificación

1000 × 1.1³ = 1331 ✔ (y coincide con el bucle de la semana 3). ¿Qué pasa con anios = -1? Nunca llega a 0 → RecursionError. Una versión defensiva valida anios >= 0 antes.

¿Recursión o bucle? Todo lo recursivo se puede escribir con un bucle. En Python, para cálculos repetitivos simples como este, el bucle suele ser más claro y eficiente. La recursión brilla con estructuras que se contienen a sí mismas (carpetas dentro de carpetas, categorías dentro de categorías). Aquí la estudias para entender la pila de llamadas.

Módulos: separar el código en archivos

En palabras simples

Un módulo es un archivo de recetas. Lo escribes una vez y cualquier otro archivo lo puede usar con import, igual que sacar un libro de la biblioteca.

Un módulo es un archivo .py cuyas funciones se pueden importar desde otro. Es el principio de la arquitectura que exige el proyecto (modelos.py, servicios.py, main.py…).

El módulo facturacion.py contiene las funciones; main.py y test_facturacion.py las importan.
Un módulo con la lógica; el programa principal y las pruebas lo importan.
"""Cálculos de facturación de la Distribuidora La Ceiba."""

TASA_IVA = 0.13
TASA_DESCUENTO = 0.10
MINIMO_DESCUENTO = 50


def calcular_subtotal(precio_unitario, cantidad):
    """Devuelve el precio unitario multiplicado por la cantidad."""
    return precio_unitario * cantidad


def aplicar_descuento(subtotal):
    """Aplica el descuento por volumen si el subtotal llega al mínimo."""
    if subtotal >= MINIMO_DESCUENTO:
        return subtotal * (1 - TASA_DESCUENTO)
    return subtotal


def calcular_total(precio_unitario, cantidad):
    """Calcula el total a pagar con descuento por volumen e IVA."""
    subtotal = calcular_subtotal(precio_unitario, cantidad)
    base = aplicar_descuento(subtotal)
    return round(base * (1 + TASA_IVA), 2)
from facturacion import calcular_total
import facturacion as fac

print(calcular_total(12.50, 4))
print(fac.TASA_IVA)
50.85
0.13
FormaCómo se llama despuésCuándo
import facturacionfacturacion.calcular_total(...)deja claro de dónde viene cada función
from facturacion import calcular_totalcalcular_total(...)usas pocas funciones del módulo
import facturacion as facfac.calcular_total(...)nombres largos
from facturacion import *todo sin prefijoevítalo: no se sabe qué entró ni de dónde
Adelanto opcional · if __name__ == "__main__":

Si facturacion.py tuviera print(...) sueltos al final, se ejecutarían también al importarlo. El patrón if __name__ == "__main__": hace que ese bloque corra solo cuando ejecutas el archivo directamente. Lo verás en proyectos reales; no es necesario para el Control 2.

Pruebas: assert y pytest

En palabras simples

Una prueba es una pregunta con respuesta conocida que le haces a tu función: “si te doy 50, ¿me devuelves 45?”. Si algún día responde otra cosa, la prueba grita. Es un detector de humo para tu código.

Caso: que nadie vuelva a romper el descuento de Marta

Pregunta

Corregimos el error de > vs >=. ¿Cómo garantizamos que, si alguien lo vuelve a introducir dentro de tres semanas, lo sepamos en segundos?

Exploración

Una prueba unitaria llama a una función con una entrada conocida y compara con el resultado esperado. Las mejores pruebas atacan los casos borde de la matriz: justo en la frontera, justo debajo, cero.

Paso 1: con assert (pruebas “artesanales”, sin instalar nada):

from facturacion import aplicar_descuento, calcular_total

assert aplicar_descuento(49.99) == 49.99, "debajo del mínimo no hay descuento"
assert calcular_total(10, 4) == 45.2
assert calcular_total(30, 2) == 61.02
print("Todas las pruebas pasaron")
Todas las pruebas pasaron
Implementación con pytest

Instálalo una vez con python -m pip install pytest. Crea el archivo de pruebas junto a facturacion.py:

import pytest

from facturacion import aplicar_descuento, calcular_total


def test_sin_descuento_bajo_el_minimo():
    assert calcular_total(10, 4) == 45.2


def test_descuento_en_la_frontera_exacta():
    assert aplicar_descuento(50) == pytest.approx(45)


def test_justo_debajo_de_la_frontera():
    assert aplicar_descuento(49.99) == 49.99


def test_descuento_sobre_el_minimo():
    assert calcular_total(30, 2) == 61.02


def test_cantidad_cero():
    assert calcular_total(12.50, 0) == 0

Y en la terminal, desde esa carpeta: python -m pytest

Resultado
collected 5 items

test_facturacion.py .....                                   [100%]

============================== 5 passed in 0.01s ==============================

Ahora sabotea a propósito: cambia >= por > en aplicar_descuento y vuelve a correr.

test_facturacion.py .F...                                   [100%]

___________________ test_descuento_en_la_frontera_exacta ___________________

    def test_descuento_en_la_frontera_exacta():
>       assert aplicar_descuento(50) == pytest.approx(45)
E       assert 50 == 45 ± 4.5e-05

FAILED test_facturacion.py::test_descuento_en_la_frontera_exacta
1 failed, 4 passed in 0.02s
Interpretación

Cada punto es una prueba que pasó; la F es la que falló, y pytest dice cuál, en qué línea, y qué valor obtuvo (50) frente al esperado (45). La prueba de la frontera exacta detectó el error en segundos. Las otras cuatro pasaron, lo cual demuestra por qué probar solo casos “normales” no basta.

pytest.approx compara flotantes con una tolerancia, porque 50 * 0.9 podría no ser exactamente 45 en binario.

Verificación: ¿las pruebas prueban algo?

Una prueba que nunca falla no vale nada. La técnica del sabotaje (romper el código a propósito y ver que la prueba falla) es la forma de comprobar que tus pruebas sí vigilan la regla. Hazlo con cada prueba nueva.

Tablas de traza

Traza 1 · Llamadas con argumentos por defecto y nombrados

def calcular_total(subtotal, tasa_iva=0.13, descuento=0):
    return round(subtotal * (1 - descuento) * (1 + tasa_iva), 2)
Llamadasubtotaltasa_ivadescuentoDevuelve
calcular_total(100)
calcular_total(100, 0)
calcular_total(100, descuento=0.1)
calcular_total(descuento=0.5, subtotal=40)
calcular_total(100, 0.13, 0.2)

Traza 2 · La pila de llamadas recursivas

Para valor_futuro(1000, 0.10, 3). Las llamadas se abren de arriba hacia abajo y devuelven de abajo hacia arriba. En “¿caso base?” escribe sí o no.

Llamadaanios¿caso base?Devuelve
1.ª3
2.ª
3.ª
4.ª
¿Qué llamada termina primero?

La 4.ª (la última en abrirse) es la primera en terminar. Una pila funciona así: lo último que entra es lo primero que sale. La 1.ª llamada es la última en terminar y su resultado es el que recibe quien llamó.

Traza 3 · Ámbito

Usa ambito.py.

PreguntaValor
descuento dentro de precio_final
a
b
descuento global al final

Ejercicios

E1 · Refactorizar una planilla

refactorización · ★★★

Este script calcula el salario neto de dos empleados. Tasas simplificadas con fines didácticos: horas extra pagadas al doble de la tarifa por hora (salario / 240); cotización de salud 3 % del bruto con tope de $30; pensión 7.25 % del bruto.

salario_1 = 600
horas_extra_1 = 10
bruto_1 = salario_1 + (salario_1 / 240) * 2 * horas_extra_1
salud_1 = bruto_1 * 0.03
if salud_1 > 30:
    salud_1 = 30
neto_1 = bruto_1 - salud_1 - bruto_1 * 0.0725
print(f"Empleado 1: neto ${neto_1:.2f}")

salario_2 = 1500
horas_extra_2 = 4
bruto_2 = salario_2 + (salario_2 / 240) * 2 * horas_extra_2
salud_2 = bruto_2 * 0.03
if salud_2 > 30:
    salud_2 = 30
neto_2 = bruto_2 - salud_2 - bruto_2 * 0.0725
print(f"Empleado 2: neto ${neto_2:.2f}")

Refactorízalo en funciones puras con docstrings (al menos: pago de horas extra, cotización de salud, pensión y salario neto con horas_extra=0 por defecto). Los resultados no deben cambiar, y si cambian, debes poder explicar por qué.

Pista

Primero anota la salida del script original: es tu “prueba de regresión”. Después de refactorizar, la salida debe ser idéntica. Para el tope, min(a, b) devuelve el menor de dos valores.

Solución
HORAS_MES = 240
RECARGO_EXTRA = 2          # horas extra al doble
TASA_SALUD = 0.03
TOPE_SALUD = 30.00
TASA_PENSION = 0.0725


def pago_horas_extra(salario, horas_extra):
    """Pago por horas extra: tarifa por hora x recargo x horas."""
    return round(salario / HORAS_MES * RECARGO_EXTRA * horas_extra, 2)


def cotizacion_salud(bruto):
    """Cotización de salud: porcentaje del bruto con un tope máximo."""
    return min(round(bruto * TASA_SALUD, 2), TOPE_SALUD)


def cotizacion_pension(bruto):
    """Cotización de pensión: porcentaje fijo del bruto."""
    return round(bruto * TASA_PENSION, 2)


def salario_neto(salario, horas_extra=0):
    """Salario neto mensual después de horas extra y cotizaciones."""
    bruto = salario + pago_horas_extra(salario, horas_extra)
    return round(bruto - cotizacion_salud(bruto) - cotizacion_pension(bruto), 2)


print(f"Empleado 1: neto ${salario_neto(600, 10):.2f}")
print(f"Empleado 2: neto ${salario_neto(1500, 4):.2f}")
Empleado 1: neto $583.38
Empleado 2: neto $1407.63

Verificación: el script original imprime $583.38 y $1407.62; el refactorizado, $583.38 y $1407.63. ¿Error? No: la pensión del empleado 2 es 1550 × 7.25 % = $112.375. El original resta ese valor con todos sus decimales; la versión nueva redondea cada rubro a centavos ($112.37) antes de restar, como en una boleta de pago real. Una refactorización puede cambiar un centavo si cambia dónde se redondea: decide la política, documéntala en el README y escribe una prueba que la fije.

E2 · Cinco pruebas para la planilla

pytest · ★★

Escribe test_planilla.py con al menos 5 pruebas para las funciones de E1. Deben incluir: el tope de salud (un salario que lo supera y uno que queda justo debajo), cero horas extra, y un caso con horas extra. Aplica el sabotaje: comprueba que al menos una prueba falla si quitas el tope.

Solución
from planilla import (cotizacion_pension, cotizacion_salud,
                      pago_horas_extra, salario_neto)


def test_salud_bajo_el_tope():
    assert cotizacion_salud(600) == 18.00


def test_salud_justo_en_el_tope():
    assert cotizacion_salud(1000) == 30.00


def test_salud_sobre_el_tope():
    assert cotizacion_salud(1500) == 30.00


def test_sin_horas_extra():
    assert pago_horas_extra(600, 0) == 0


def test_horas_extra_al_doble():
    assert pago_horas_extra(600, 10) == 50.00


def test_pension():
    assert cotizacion_pension(1200) == 87.00


def test_neto_con_horas_extra():
    assert salario_neto(600, 10) == 583.38

Por qué estos casos: 1000 × 3 % = 30 es exactamente el tope (frontera); 1500 lo supera; 600 queda debajo. Si borras el min(...), test_salud_sobre_el_tope falla (daría 45). Ese sabotaje demuestra que la prueba vigila la regla.

E3 · Comparador de plazos

funciones + bucle · ★★

Usando calcular_cuota, escribe una función intereses_totales(monto, tasa_anual, meses=12) que devuelva cuánto se paga de intereses. Luego, con un bucle, muestra una tabla para $5,000 al 12 % con plazos de 12, 24, 36 y 48 meses.

Solución
def calcular_cuota(monto, tasa_anual, meses=12):
    """Cuota mensual fija (sistema francés), redondeada a centavos."""
    if tasa_anual == 0:
        return round(monto / meses, 2)
    i = tasa_anual / 12
    return round(monto * i / (1 - (1 + i) ** -meses), 2)


def intereses_totales(monto, tasa_anual, meses=12):
    """Total de intereses pagados durante todo el préstamo."""
    return round(calcular_cuota(monto, tasa_anual, meses) * meses - monto, 2)


print("Meses    Cuota   Intereses")
for meses in range(12, 49, 12):
    cuota = calcular_cuota(5000, 0.12, meses)
    interes = intereses_totales(5000, 0.12, meses)
    print(f"{meses:>5} {cuota:>8.2f} {interes:>10.2f}")
Meses    Cuota   Intereses
   12   444.24     330.88
   24   235.37     648.88
   36   166.07     978.52
   48   131.67    1320.16

Interpretación: cuadruplicar el plazo reduce la cuota a menos de un tercio, pero multiplica los intereses por 4. La decisión depende de la liquidez mensual del cliente, y el programa hace visible el costo.

Verificación: range(12, 49, 12) produce 12, 24, 36, 48 (el 49 no se incluye). Con tasa 0 los intereses deben ser ≈ 0.

E4 · Meta de ahorro recursiva

recursividad · ★★

Escribe meses_para_meta(saldo, aporte, meta) recursiva que diga cuántos aportes mensuales hacen falta para llegar a la meta (sin intereses). Ejemplos: (0, 150, 1000) → 7; (1000, 150, 1000) → 0; (900, 150, 1000) → 1. ¿Qué pasa si el aporte es 0?

Solución
def meses_para_meta(saldo, aporte, meta):
    """Número de aportes mensuales para que el saldo alcance la meta."""
    if saldo >= meta:                  # caso base: ya llegó
        return 0
    return 1 + meses_para_meta(saldo + aporte, aporte, meta)


assert meses_para_meta(0, 150, 1000) == 7
assert meses_para_meta(1000, 150, 1000) == 0
assert meses_para_meta(900, 150, 1000) == 1
print("Pruebas superadas")
Pruebas superadas

Caso borde: con aporte 0 (o negativo) el saldo nunca se acerca a la meta → RecursionError. Quien llama debe validar aporte > 0 antes, o la función debe devolver una señal de “imposible”. Esta es la versión recursiva del bucle infinito de la semana 3.

E5 · Auditoría: funciones que “funcionan”

auditoría · ★★★

Un asistente de IA generó este módulo. Ninguna línea tiene error de sintaxis. Encuentra al menos cuatro problemas y di con qué llamada concreta se manifiesta cada uno.

TASA_IVA = 0.13
total_general = 0


def aplicar_iva(monto):
    """Devuelve el monto con IVA."""
    resultado = monto * TASA_IVA
    print(resultado)


def calcular_descuento(monto, porcentaje=10):
    """Devuelve el descuento aplicable."""
    if monto > 100:
        return monto * porcentaje / 100


def registrar_venta(monto):
    """Suma la venta al total general."""
    total_general = total_general + monto
Pista

Prueba: aplicar_iva(100) + 5, calcular_descuento(50), registrar_venta(10). Y compara el docstring de aplicar_iva con lo que calcula.

Solución
  1. aplicar_iva imprime y no devuelve: aplicar_iva(100) + 5 → TypeError (None + 5).
  2. Calcula otra cosa: el docstring dice “monto con IVA” pero calcula solo el IVA (13 en vez de 113). La documentación y el código se contradicen.
  3. Falta un camino de retorno: calcular_descuento(50) devuelve None en lugar de 0.
  4. Global modificada: registrar_venta(10) → UnboundLocalError.
  5. (Extra) porcentaje=10 es un número mágico; y “> 100” ¿incluye el 100? Debe estar en la regla de negocio.
TASA_IVA = 0.13
PORCENTAJE_DESCUENTO = 10
MINIMO_DESCUENTO = 100


def aplicar_iva(monto):
    """Devuelve el monto con IVA incluido."""
    return round(monto * (1 + TASA_IVA), 2)


def calcular_descuento(monto, porcentaje=PORCENTAJE_DESCUENTO):
    """Devuelve el descuento en dólares (0 si no aplica)."""
    if monto > MINIMO_DESCUENTO:
        return monto * porcentaje / 100
    return 0


def registrar_venta(total_actual, monto):
    """Devuelve el nuevo total general tras sumar la venta."""
    return total_actual + monto


assert aplicar_iva(100) == 113
assert calcular_descuento(50) == 0
assert calcular_descuento(200) == 20
assert registrar_venta(0, 10) == 10
print("Módulo corregido: pruebas superadas")
Módulo corregido: pruebas superadas

Método de auditoría: no se lee el código “a ver si se ve bien”; se llama cada función con casos elegidos (normal, frontera, fuera de rango) y se compara con lo que promete el docstring.

Práctica intensiva

Primero ejercicios rápidos sobre llamadas a funciones (se corrigen solos), luego problemas tipo examen. Todos incluyen pruebas: así se entrega en el proyecto.

Ejercicios rápidos · ¿Qué devuelve?

Escribe el valor que produce la última línea. Si no devuelve nada, escribe None.

CódigoTu respuesta
def f(x, y=2):
    return x ** y
f(3)
def f(x, y=2):
    return x ** y
f(2, 3)
def f(x, y=2):
    return x ** y
f(y=1, x=5)
def g(n):
    if n <= 1:
        return 1
    return n * g(n - 1)
g(4)
def h(a):
    a = a + 1
    return a
x = 5
h(x)
x
def p():
    print("hola")
r = p()
r
def dos():
    return 3, 4
a, b = dos()
a + b
def c(n):
    if n == 0:
        return 0
    return 1 + c(n // 10)
c(4821)

Problemas tipo examen

P1 · Precio de lista: margen, IVA y redondeo comercial

composición de funciones + pruebas · ★★★

Una tienda fija precios así: parte del costo, aplica un margen sobre el precio (30 % por defecto: el 30 % de lo que paga el cliente es ganancia), suma IVA y sube al siguiente precio terminado en .99. Escribe una función para cada paso y una que las combine. Incluye pruebas con assert.

Tu programa debe cumplir estos casos de prueba:

LlamadaEsperado
precio_con_margen(70, 0.30)100.00 (no 91: el margen es sobre el precio)
redondear_99(12.40)12.99
redondear_99(12.995)13.99
precio_de_lista(70)113.99
precio_de_lista(70, margen=0.40)131.99
Pista

Margen vs. recargo: recargo del 30 % sobre el costo da 70 × 1.30 = 91; margen del 30 % sobre el precio es 70 / (1 − 0.30) = 100. Son distintos y es un error frecuente en negocios. Para el redondeo, int(12.4) da 12.

Solución
def precio_con_margen(costo, margen):
    """Precio para que el margen sea una fracción del PRECIO (no del costo)."""
    return costo / (1 - margen)


def agregar_iva(precio, tasa=0.13):
    """Precio con IVA incluido."""
    return precio * (1 + tasa)


def redondear_99(precio):
    """Sube al siguiente precio terminado en .99 (12.40 -> 12.99, 12.995 -> 13.99)."""
    entero = int(precio)
    if precio <= entero + 0.99:
        return entero + 0.99
    return entero + 1.99


def precio_de_lista(costo, margen=0.30):
    """Costo -> margen -> IVA -> redondeo comercial."""
    return round(redondear_99(agregar_iva(precio_con_margen(costo, margen))), 2)


assert round(precio_con_margen(70, 0.30), 2) == 100.00
assert redondear_99(12.40) == 12.99
assert redondear_99(12.995) == 13.99
assert redondear_99(12.0) == 12.99
assert precio_de_lista(70) == 113.99
print("Precio de lista de un producto de $70:", precio_de_lista(70))
print("Con margen de 40 %:", precio_de_lista(70, margen=0.40))
Precio de lista de un producto de $70: 113.99
Con margen de 40 %: 131.99

Diseño: cada función hace una sola cosa y se prueba sola. Si mañana el IVA cambia, solo cambia el valor por defecto de agregar_iva.

P2 · Dígito verificador de un código de cliente

recursividad + aritmética · ★★★

Para detectar errores de digitación, cada código de cliente termina en un dígito verificador: el que hace que la suma de todos los dígitos sea múltiplo de 10. Escribe suma_digitos(n) recursiva (con % y //), digito_verificador(base) y es_codigo_valido(codigo).

Tu programa debe cumplir estos casos de prueba:

LlamadaEsperado
suma_digitos(4821)15
digito_verificador(4821)5 → código 48215
es_codigo_valido(48215)True
es_codigo_valido(48216)False (error de digitación)
digito_verificador(55)0
Pista

n % 10 es el último dígito y n // 10 es el número sin su último dígito. Caso base: un número menor que 10 es su propia suma.

Solución
def suma_digitos(n):
    """Suma los dígitos de un entero no negativo (recursiva)."""
    if n < 10:                          # caso base: un solo dígito
        return n
    return n % 10 + suma_digitos(n // 10)


def digito_verificador(base):
    """Dígito que hace que la suma total sea múltiplo de 10."""
    return (10 - suma_digitos(base) % 10) % 10


def es_codigo_valido(codigo):
    """El último dígito debe ser el verificador del resto."""
    return digito_verificador(codigo // 10) == codigo % 10


assert suma_digitos(4821) == 15
assert digito_verificador(4821) == 5          # código completo: 48215
assert es_codigo_valido(48215)
assert not es_codigo_valido(48216)            # error de digitación en el último dígito
assert not es_codigo_valido(49215)            # error de digitación en otro dígito
assert es_codigo_valido(42815)                # ¡intercambio de dígitos NO detectado!
assert digito_verificador(55) == 0            # 5 + 5 = 10, ya es múltiplo
print("Código para el cliente 4821:", 4821 * 10 + digito_verificador(4821))
Código para el cliente 4821: 48215

Lección de auditoría: la prueba con 42815 muestra una debilidad real. Sumar dígitos detecta un dígito mal escrito, pero no dos dígitos intercambiados (la suma no cambia). Por eso los sistemas reales, como las tarjetas de crédito, usan algoritmos que ponderan la posición de cada dígito. Una prueba que documenta un límite también es valiosa.

P3 · La tarifa de envío como función probada

refactorización + casos borde · ★★

Convierte el envio.py de la semana 2 en una función tarifa_envio(peso_kg, zona="urbana") que devuelva la tarifa o None si el paquete no se puede enviar. Escribe 6 pruebas de frontera.

Tu programa debe cumplir estos casos de prueba:

LlamadaEsperado
tarifa_envio(1)3.5
tarifa_envio(1.01, "rural")8.5
tarifa_envio(5, zona="rural")8.5
tarifa_envio(20)12.0
tarifa_envio(20.01)None
tarifa_envio(0)None
Pista

Usa una cláusula de guarda: lo primero que hace la función es descartar los pesos imposibles con return None. Compara con is None.

Solución
RECARGO_RURAL = 2.50


def tarifa_envio(peso_kg, zona="urbana"):
    """Tarifa de envío; devuelve None si el paquete no se puede enviar."""
    if peso_kg <= 0 or peso_kg > 20:
        return None                      # cláusula de guarda
    if peso_kg <= 1:
        tarifa = 3.50
    elif peso_kg <= 5:
        tarifa = 6.00
    else:
        tarifa = 12.00
    if zona == "rural":
        tarifa += RECARGO_RURAL
    return tarifa


# Pruebas de frontera (las mismas de la traza 2 de la semana 2)
assert tarifa_envio(1) == 3.50
assert tarifa_envio(1.01, "rural") == 8.50
assert tarifa_envio(5, zona="rural") == 8.50
assert tarifa_envio(20) == 12.00
assert tarifa_envio(20.01) is None
assert tarifa_envio(0) is None
print("6 pruebas superadas")
6 pruebas superadas

¿Por qué None y no un mensaje? Porque quien llama decide qué mostrar (consola, archivo, web). La función solo calcula. En la semana 7 verás otra opción: lanzar una excepción.

Proyecto integrador · Preparar la Fase 3

La Fase 3 (semana 5) pide código funcional organizado en funciones con parámetros, retornos y docstrings, y colecciones en memoria. Esta semana construyen las funciones.

Tareas sugeridas para el equipo

Preguntas de defensa

¿Por qué esta función devuelve su resultado en vez de imprimirlo?

Para poder reutilizarla (otra función usa el resultado), probarla con assert y cambiar la presentación (consola, archivo, reporte) sin tocar el cálculo.

¿Cuáles de sus funciones son puras y cuáles no? ¿Por qué?

Puras: los cálculos (mismos argumentos → mismo resultado, sin efectos). No puras: las que leen del teclado, imprimen o (desde la semana 7) leen/escriben archivos. Diseñan el sistema para que la lógica de negocio sea pura y quede en el centro.

¿Por qué no usan variables globales?

Cualquier función podría modificarlas, lo que hace imposible saber quién cambió un valor y rompe las pruebas aisladas. Solo usamos constantes globales (en MAYÚSCULAS) que no cambian.

¿Qué comprueba esta prueba y qué pasaría si la regla tuviera un error en la frontera?

Señala la regla, el valor frontera y demuestra (sabotaje) que la prueba falla si se cambia >= por >.

Modifiquen en vivo: agreguen un parámetro opcional a esta función.

En la sustentación pueden pedirles cambios en vivo. Practica: añade un parámetro con valor por defecto para no romper las llamadas existentes, y corre las pruebas para demostrarlo.

Rumbo al Control 2 y al Parcial

Control 2 en la semana 5 y Examen Parcial en la semana 6 (individuales, sin IA). De esta semana, asegúrate de poder:

Fuentes

Las tasas de la planilla (E1) son simplificadas y con fines didácticos; no constituyen asesoría legal ni reflejan necesariamente la normativa vigente.