En el desarrollo de software, es común enfrentarse a situaciones donde las pruebas unitarias no pueden ejecutarse por cambios o desarrollos en curso. En estos casos, comentar el código de las pruebas no es la mejor práctica. Afortunadamente, Python y unittest ofrecen decoradores que nos permiten omitir pruebas temporalmente, sin comprometer el flujo de trabajo ni la integridad del proyecto. Aquí aprenderemos cómo usar decoradores como @skip, @skipIf y @expectedFailure para manejar estos casos de manera eficiente.
¿Cómo utilizar el decorador @skip?
El decorador @skip se utiliza cuando sabemos que una prueba no debería ejecutarse temporalmente. Esto es útil si estamos trabajando en un feature que aún no está completo y, por lo tanto, las pruebas no tienen sentido. Al aplicar @skip, podemos evitar la ejecución de la prueba y aún así tener visibilidad de que está pendiente de ser corregida.
Aplicamos el decorador con una razón clara.
Cuando ejecutamos las pruebas, en el reporte se indicará que la prueba fue saltada.
Ejemplo de uso:
@unittest.skip("Trabajo en progreso, será habilitada nuevamente.")deftest_skip_example(self): self.assertEqual("hola","chau")
¿Cuándo aplicar @skipIf?
El decorador @skipIf es útil cuando queremos omitir una prueba bajo una condición específica. Esto es común cuando nuestras pruebas dependen del entorno, como servidores diferentes o configuraciones específicas.
Requiere una condición y una razón para ser aplicado.
Se ejecutará solo si la condición es verdadera.
Ejemplo de uso:
server ="server_b"@unittest.skipIf(server =="server_a","Saltada porque no estamos en el servidor correcto.")deftest_skipif_example(self): self.assertEqual(1000,100)
¿Qué hace el decorador @expectedFailure?
Este decorador se usa cuando sabemos que una prueba fallará debido a un cambio en la lógica del negocio o un bug conocido, pero queremos mantener la prueba visible en el reporte de pruebas.
Es útil para reflejar fallos esperados sin interferir con el flujo de integración continua.
El reporte mostrará que la prueba falló como se esperaba.
El decorador @skipUnless es valioso cuando queremos ejecutar una prueba solo si se cumple una condición. Un ejemplo clásico es validar si un servicio externo, como una API, está disponible antes de ejecutar la prueba.
Es ideal para escenarios donde dependemos de recursos externos, como API's de terceros.
Ejemplo de uso:
@unittest.skipUnless(api_available(),"API no disponible.")deftest_skipunless_example(self): self.assertEqual(get_currency_rate("USD"),1.0)
¿Cuándo utilizar estos decoradores en desarrollo colaborativo?
El uso de decoradores como @skip, @skipIf, @expectedFailure y @skipUnless en un equipo de desarrollo asegura que las pruebas no interfieran en el flujo de trabajo, mientras mantienen la visibilidad de las pruebas pendientes. Es esencial en entornos de integración continua (CI), donde se busca que las pruebas no bloqueen el desarrollo, pero sin ignorarlas por completo.
Decoradores de Unit Test para Saltar Pruebas y Detectar Fallos
Decoradores de Unit Test para Saltar Pruebas y Detectar Fallos
Me ayudé un poco de ChatGPT para hacer la solicitud de la API, porque nunca había consumido una.
Estuve buscando API's que dieran el tipo de cambio y me encontré con que el Banco de México tiene una. Con base en ella realicé la prueba.
Se generó una función para conseguir el tipo de cambio y otra para definir si la API está disponible.
# ----- Código de las funciones -----import requests #Permite hacer solicitudes HTTP# Se utiliza la API del Banco de México para hacer el cambio de moneda.# Con la siguiente función se obtiene desde la API de Banxico el tipo de cambio:defget_exchange_rate(api): url =f'https://www.banxico.org.mx/SieAPIRest/service/v1/series/SF63528/datos/oportuno?token={api}' response = requests.get(url)if response.status_code ==200: data = response.json()returnfloat(data['bmx']['series'][0]['datos'][0]['dato'])else:print('Error to obtain exchange rate:', response.status_code)returnNone# Con la siguiente función se verifica si la API está dispoible:defis_api_available(api): url =f'https://www.banxico.org.mx/SieAPIRest/service/v1/series/SF63528/datos/oportuno?token={api}' response = requests.get(url)return response.status_code ==200# Aquí comienza el código para solucionar los retos de la claseclassBankAccount:def__init__(self, balance=0, log_file=None): self.balance = balance
self.log_file = log_file
self._log_transaction('Cuenta creada')def_log_transaction(self, message):if self.log_file:withopen(self.log_file,"a")as f: f.write(f"{message}\n")defdeposit(self, amount):if amount >0: self.balance += amount
self._log_transaction(f"Deposited {amount}. New balance: {self.balance}.")return self.balance
defwithdraw(self, amount):if amount >0: self.balance -= amount
self._log_transaction(f"Withdrew {amount}. New balance: {self.balance}.")return self.balance
defget_balance(self): self._log_transaction(f"Checked balance. Current balance {self.balance}.")return self.balance
deftransfer(self, amount):if self.balance >= amount: self.balance = self.balance - amount
self._log_transaction(f"Transfered {amount}. New balance: {self.balance}.")else: self._log_transaction(f"Not transfered {amount}. Insufficient founds: {self.balance}")print("Fondos insuficientes.")return self.balance
# Adicional a lo trabajado en las clases anteriores, con la siguiente función podremos convertir a USD:defconvert_to_usd(self, api): exchange_rate = get_exchange_rate(api)if exchange_rate:return self.balance / exchange_rate
else:returnNone# ----- Código de las pruebas -----import unittest
import os
from src.bank_account import BankAccount
from src.bank_account import is_api_available # Se importa función que se agregó fuera de la clsefrom unittest.mock import patch
classBankAccountTests(unittest.TestCase):# Con setUp lo que se hace es definir desde el inicio los valores que tendrán las funciones.# Es una manera de evitar escribir los valores iniciales desde el inicio, en este caso,todas las variables iniciarán con $1,000# defsetUp(self)->None: self.account = BankAccount(balance=1000, log_file="transaction_log.txt")# Aquí se define la API key: self.api ='e3980208bf01ec653aba9aee3c2d6f70f6ae8b066d2545e379b9e0ef92e9de25'deftearDown(self)->None:if os.path.exists(self.account.log_file): os.remove(self.account.log_file)def_count_lines(self, filename):withopen(filename,"r")as f:returnlen(f.readlines())deftest_deposit(self): new_balance = self.account.deposit(500) self.assertEqual(new_balance,1500,"El balance no es igual")deftest_withdraw(self): new_balance = self.account.withdraw(200) self.assertEqual(new_balance,800,"El balance no es igual")deftest_get_balance(self): self.assertEqual(self.account.get_balance(),1000,"El balance no es igual")deftest_transfer(self): new_balance = self.account.transfer(200) self.assertEqual(new_balance,800,"El balance no es igual")deftest_transaction_log(self): new_balance = self.account.deposit(500) self.assertTrue(os.path.exists("transaction_log.txt"))deftest_count_transactions(self):assert self._count_lines(self.account.log_file)==1 self.account.deposit(500)assert self._count_lines(self.account.log_file)==2# Se agrega conversión a USD, condicionada a disponibilidad de API# Se utiliza skipUnless en caso de que la API no esté disponible.@unittest.skipUnless(is_api_available('e3980208bf01ec653aba9aee3c2d6f70f6ae8b066d2545e379b9e0ef92e9de25'),'API no disponible')@patch('src.bank_account.get_exchange_rate')deftest_convert_to_usd(self, mock_get_exchange_rate): mock_get_exchange_rate.return_value =20# Ejemplo de tipo de cambio usd_balance = self.account.convert_to_usd(self.api)assert usd_balance ==50# 1000/20 = 50
Yo intenté solucionar el reto de la manera más simple posible.
Cree una API alojada en el local host con un diccionario que contiene un currency del dólar fijo, llamada currency_web.py:
Luego, creé otro archivo dentro del módulo de 'src', llamado 'get_currency.py', en el cual intento extraer el valor del currency 'USD' de la API creada anteriormente. Sin embargo, he usado las sentencias 'try' y 'except' para capturar el error de forma adecuada en caso de que la API no esté corriendo
(por razones de seguridad no he puesto mi ip loca, cada quien debe tener la suya)
Finalmente usé el decorador 'skipUnless' en el módulo de 'test_all_asserts.py'. PRimero establecí la variable USD como el valor que me retorna la función del módulo de 'get_currency', y dentro de los parámetros de 'skipUnless' simplemente coloqué debe hacerse la excepción a menos que USD sea un número, es decir, que la API esté corriendo de forma exitosa.
# saltar pruebas@pytest.mark.skipdeftest_deposit_skip():# Esta prueba será omitida result =1000+250assert result ==1250import sys
@pytest.mark.skipif(sys.platform =="win32", reason="No se ejecuta en Windows")deftest_withdra_skip():# Esta prueba solo se ejecutará en plataformas que no sean Windows result =1000-250assert result ==750
Solución al reto:
Ya que era requerido llamar un API, encontré qué APILayer - Best API Marketplace | Reliable, Scalable APIs | APILayer tiene un gran repertorio de APIs entre las cuales están para obtener diferentes currencies. Entonces, después del registro me dieron una api key que guardé en un archivo <u>.env</u>, para luego inicializarla en la clase
Luego, implementé un método que me permitiera conocer si la API está funcionando.
Para que entonces, podamos llamar el método en nuestros tests que requieran las funcionalidades de la API. Por lo tanto, usando el decorador skipUnless podemos validar que si la API está funcionando, entonces ejecute la prueba, de lo contrario, que muestre el mensaje de error.
Si algún aspecto de la explicación no quedó claro, lo pueden dejar saber : D
Lo que hice fue instalar decouple y usar config, cree un archivo .env para evitar que la secret key termine en el repo remoto y use el request de toda la vida con un get. Además de lo anterior use una variable available que cambia a True si la api esta disponible junto con skipUnless con el mensaje "La API no está disponible para pruebas".
skipUnless =
@unittest.skipUnless(condition, reason)
import unittest
from decouple import config
import requests
SERVER ="server_b"API_KEY = config('SECRET_KEY')URL =f'https://v6.exchangerate-api.com/v6/{API_KEY}/latest/USD'response = requests.get(URL)available =Falseif response.status_code ==200: data = response.json()print(data) available =Trueelse:print(f'Error {response.status_code}: {response.text}')classAllAssertsTest(unittest.TestCase):deftest_assert_equal(self): self.assertEqual(10,10) self.assertEqual("Hola","Hola")deftest_assert_true_or_false(self): self.assertTrue(True) self.assertTrue(True)deftest_assert_raises(self):with self.assertRaises(ValueError):int("no soy un número")deftest_assert_in(self): self.assertIn(10,[2,4,5,10]) self.assertNotIn(5,[2,4,10])deftest_assert_dicts(self): user ={"name":"jesus","last_name":"vergara"} self.assertDictEqual({"name":"jesus","last_name":"vergara"}, user
) self.assertSetEqual({1,2,3},{1,2,3})@unittest.skip("Trabajo en progreso, se habilitará nuevamente!")deftest_skip(self): self.assertEqual("hola","chao")@unittest.skipIf(SERVER =="server_b","Saltado porque no estamos en el servidor a")deftest_skip_if(self): self.assertEqual(100,100)@unittest.expectedFailuredeftest_expected_failure(self): self.assertEqual(100,150)@unittest.skipUnless(available,"La API no está disponible para pruebas")deftest_api_response(self): self.assertTrue(available,"La API debería estar disponible")if available:print("La API está disponible.")```import unittestfrom decouple import configimport requests
SERVER ="server\_b"API\_KEY = config('SECRET\_KEY')URL =f'https://v6.exchangerate-api.com/v6/{API\_KEY}/latest/USD'response = requests.get(URL)available = Falseif response.status\_code ==200: data = response.json()print(data) available = Trueelse:print(f'Error {response.status\_code}: {response.text}')classAllAssertsTest(unittest.TestCase):def test\_assert\_equal(self): self.assertEqual(10,10) self.assertEqual("Hola","Hola")  def test\_assert\_true\_or\_false(self): self.assertTrue(True) self.assertTrue(True)  def test\_assert\_raises(self): with self.assertRaises(ValueError): int("no soy un número")  def test\_assert\_in(self): self.assertIn(10, \[2, 4, 5, 10]) self.assertNotIn(5, \[2, 4, 10])  def test\_assert\_dicts(self): user = {"name": "jesus", "last\_name": "vergara"} self.assertDictEqual( {"name": "jesus", "last\_name": "vergara"}, user ) self.assertSetEqual( {1, 2, 3}, {1, 2, 3} )  @unittest.skip("Trabajo en progreso, se habilitará nuevamente!") def test\_skip(self): self.assertEqual("hola", "chao")  @unittest.skipIf(SERVER == "server\_b", "Saltado porque no estamos en el servidor a") def test\_skip\_if(self): self.assertEqual(100, 100)  @unittest.expectedFailure def test\_expected\_failure(self): self.assertEqual(100, 150)  @unittest.skipUnless(available, "La API no está disponible para pruebas") def test\_api\_response(self): self.assertTrue(available, "La API debería estar disponible") if available: print("La API está disponible.")
¡Reto Clase Solución!!
import requests
classBankAccount: def __init__(self, initial_balance=0, currency='USD', log_file=None): self.balance= initial_balance
self.currency= currency
self.log_file= log_file
self._log_transaction(f"Account created with {self.balance} {self.currency}") def _log_transaction(self, message):if self.log_file:withopen(self.log_file,'a')asf: f.write(f"{message} \n") def deposit(self, amount):if amount >0: self.balance+= amount
self._log_transaction(f"Deposited: {amount} {self.currency}. New balance: {self.balance}")else: raise ValueError("Deposit amount must be positive")return self.balance def withdraw(self, amount):if amount > self.balance: raise ValueError("Insufficient funds") elif amount <=0: raise ValueError("Withdrawal amount must be positive")else: self.balance-= amount
self._log_transaction(f"Withdrew: {amount} {self.currency}. New balance: {self.balance}")return self.balance def get_balance(self): self._log_transaction(f"Check balance. Current balance: {self.balance} {self.currency}")return self.balance def transfer(self, amount, target_account):if amount <=0: raise ValueError("Transfer amount must be positive")if amount > self.balance: self._log_transaction("Transfer failed: No balance available") raise ValueError("Insufficient funds for transfer") # Si las monedas son diferentes, convertir
if self.currency!= target_account.currency: rate = self.get_exchange_rate(self.currency, target_account.currency) converted_amount = amount * rate
self._log_transaction(f"Converted {amount} {self.currency} to {converted_amount:.2f} {target_account.currency}")else: converted_amount = amount
self.balance-= amount
target_account.deposit(converted_amount) self._log_transaction(f"Transferred: {amount} {self.currency} to {target_account.currency}. New balance: {self.balance}")return self.balance def get_exchange_rate(self, from_currency, to_currency): response = requests.get(f"http://localhost:5000/rate?from={from_currency}&to={to_currency}")if response.status_code==200:return response.json().get("rate",1.0)else: raise ConnectionError("Exchange rate API not available")
import unittest
import os
importrequestsfrom src.bank_accountimportBankAccountdef is_api_available():try: response = requests.get("http://localhost:5000/rate?from=USD&to=COP", timeout=2)return response.status_code==200 except Exception:returnFalseclassBankAccountCurrencyTests(unittest.TestCase): def setUp(self): self.usd_account=BankAccount(initial_balance=1000, currency='USD', log_file="log_usd.txt") self.cop_account=BankAccount(initial_balance=5000000, currency='COP', log_file="log_cop.txt") def tearDown(self):for f in[self.usd_account.log_file, self.cop_account.log_file]:if os.path.exists(f): os.remove(f) @unittest.skipUnless(is_api_available(),"Exchange rate API not available") def test_transfer_with_currency_conversion(self): initial_cop_balance = self.cop_account.get_balance() self.usd_account.transfer(100, self.cop_account) # Verifies that the balance inUSD has been reduced
self.assertLess(self.usd_account.get_balance(),1000) # Verify that the balance inPOPs increased
self.assertGreater(self.cop_account.get_balance(), initial_cop_balance) # Verify that the conversion was recorded in the log.withopen(self.usd_account.log_file,'r')asf: log = f.read() self.assertIn("Converted", log)
# RunthisasFlask server on localhost:5000from flask importFlask, request, jsonify
app =Flask(__name__)@app.route('/rate')def rate(): from_currency = request.args.get('from') to_currency = request.args.get('to') # Exchange rate simulationo
if from_currency =='USD' and to_currency =='COP':returnjsonify(rate=4000.0) elif from_currency =='COP' and to_currency =='USD':returnjsonify(rate=1/4000.0)returnjsonify(rate=1.0)if __name__ =='__main__': app.run(port=5000)