# tests/test_decorators.pyimportpytestfromunittest.mockimportMagicMock,patchfromsrc.decoratorsimportretryclassTestRetryDecorator:"""Test retry decorator behavior."""deftest_succeeds_on_first_attempt(self):"""Function succeeds immediately—no retries needed."""@retry(max_attempts=3)defsucceeds():return"success"result=succeeds()assertresult=="success"deftest_retries_on_transient_failure(self):"""Function fails twice, succeeds on third—should return success."""attempts=[]@retry(max_attempts=3,delay=0)# delay=0 speeds up testdeffails_twice():attempts.append(1)iflen(attempts)<3:raiseConnectionError("Transient failure")return"success"result=fails_twice()assertresult=="success"assertlen(attempts)==3# Verify it actually retrieddeftest_raises_after_max_attempts(self):"""After max attempts, exception should be raised."""@retry(max_attempts=2,delay=0)defalways_fails():raiseConnectionError("Persistent failure")withpytest.raises(ConnectionError,match="Persistent failure"):always_fails()deftest_exponential_backoff(self):"""Verify backoff multiplier is applied correctly."""attempts=[]@retry(max_attempts=3,delay=1,backoff=2,delay_override=0)deffails_twice():attempts.append(time.time()ifattemptselse0)iflen(attempts)<3:raiseConnectionError()return"success"# For precise timing tests, use @patch to mock time.sleepwithpatch('time.sleep')asmock_sleep:@retry(max_attempts=3,delay=1,backoff=2)deffails_twice_with_sleep():iflen(mock_sleep.call_count)<2:raiseConnectionError()return"success"fails_twice_with_sleep()# Verify sleep called with correct delays: 1s, then 2sassertmock_sleep.call_count==2assertmock_sleep.call_args_list[0][0][0]==1# First delayassertmock_sleep.call_args_list[1][0][0]==2# Second delaydeftest_catches_only_specified_exceptions(self):"""Retry only catches specified exceptions, not others."""@retry(max_attempts=3,exceptions=(ConnectionError,))defraises_value_error():raiseValueError("Not caught by retry")withpytest.raises(ValueError):raises_value_error()deftest_preserves_function_metadata(self):"""@retry should preserve original function name and docstring."""@retry()defmy_function():"""My docstring."""passassertmy_function.__name__=="my_function"assert"docstring"inmy_function.__doc__
# src/device_ops.pyfromnetmikoimportConnectHandlerdefconfigure_interface(device,interface,ip_address,netmask):"""Configure interface with IP address."""commands=[f"interface {interface}",f"ip address {ip_address}{netmask}","no shutdown","exit"]device.send_command("configure terminal")forcmdincommands:device.send_command(cmd)device.send_command("end")returnTruedefverify_interface_config(device,interface,expected_ip):"""Verify interface has expected IP."""output=device.send_command(f"show ip interface brief | include {interface}")returnexpected_ipinoutput
# tests/test_device_ops.pyimportpytestfromunittest.mockimportMagicMock,callfromsrc.device_opsimportconfigure_interface,verify_interface_configclassTestDeviceOperations:"""Test device configuration without real devices."""@pytest.fixturedefmock_device(self):"""Create a mock Netmiko device."""device=MagicMock()device.send_command=MagicMock(return_value="OK")returndevicedeftest_configure_interface_sends_correct_commands(self,mock_device):"""Verify correct CLI commands are sent in correct order."""configure_interface(mock_device,interface="Gi0/0/1",ip_address="10.0.0.1",netmask="255.255.255.0")# Verify commands sent in correct orderexpected_calls=[call("configure terminal"),call("interface Gi0/0/1"),call("ip address 10.0.0.1 255.255.255.0"),call("no shutdown"),call("exit"),call("end"),]assertmock_device.send_command.call_args_list==expected_callsdeftest_configure_interface_returns_true_on_success(self,mock_device):"""Function should return True indicating success."""result=configure_interface(mock_device,interface="Gi0/0/1",ip_address="10.0.0.1",netmask="255.255.255.0")assertresultisTruedeftest_configure_interface_handles_device_error(self,mock_device):"""If device fails, exception should propagate."""mock_device.send_command.side_effect=Exception("Device unreachable")withpytest.raises(Exception,match="Device unreachable"):configure_interface(mock_device,interface="Gi0/0/1",ip_address="10.0.0.1",netmask="255.255.255.0")deftest_verify_interface_config_detects_correct_ip(self,mock_device):"""When interface has correct IP, should return True."""mock_device.send_command.return_value="Gi0/0/1 YES manual up 10.0.0.1"result=verify_interface_config(mock_device,interface="Gi0/0/1",expected_ip="10.0.0.1")assertresultisTruedeftest_verify_interface_config_detects_wrong_ip(self,mock_device):"""When interface has different IP, should return False."""mock_device.send_command.return_value="Gi0/0/1 YES manual up 192.168.1.1"result=verify_interface_config(mock_device,interface="Gi0/0/1",expected_ip="10.0.0.1")assertresultisFalse
# tests/test_integration.pyimportpytestfromunittest.mockimportMagicMock,patchfromsrc.decoratorsimportretryfromsrc.device_opsimportconfigure_interfaceclassTestDecoratorIntegration:"""Test decorators + business logic together."""deftest_retry_recovers_from_transient_failure(self):"""Verify retry decorator works with real business logic."""mock_device=MagicMock()# Device fails first time, succeeds second timemock_device.send_command.side_effect=[Exception("Device timeout"),# First call fails"OK",# configure terminal"OK",# interface command"OK",# ip address command"OK",# no shutdown"OK",# exit"OK",# end]@retry(max_attempts=3,delay=0)defconfigure_with_retry(device,interface,ip):returnconfigure_interface(device,interface,ip,"255.255.255.0")# Should succeed on second retryresult=configure_with_retry(mock_device,interface="Gi0/0/1",ip="10.0.0.1")assertresultisTruedeftest_retry_gives_up_after_max_attempts(self):"""If all retries fail, exception should propagate."""mock_device=MagicMock()mock_device.send_command.side_effect=Exception("Device down")@retry(max_attempts=2,delay=0)defconfigure_with_retry(device,interface,ip):returnconfigure_interface(device,interface,ip,"255.255.255.0")withpytest.raises(Exception,match="Device down"):configure_with_retry(mock_device,interface="Gi0/0/1",ip="10.0.0.1")@patch('src.decorators.logging.Logger')deftest_audit_logging_decorator_logs_attempts(self,mock_logger,mock_device):"""Verify audit logging captures operations."""fromsrc.decoratorsimportlog_auditmock_device=MagicMock()mock_device.send_command.return_value="OK"@log_audit(action="configure")defconfigure_with_logging(device,interface):device.send_command(f"interface {interface}")returnTrueconfigure_with_logging(mock_device,"Gi0/0/1")# Verify something was logged (implementation details depend on your logging)assertmock_logger.info.calledormock_logger.debug.called
# tests/conftest.py (shared across all tests)importpytestfromunittest.mockimportMagicMock@pytest.fixturedefmock_device():"""Reusable mock Netmiko device."""device=MagicMock()device.send_command=MagicMock(return_value="OK")returndevice@pytest.fixturedefmock_device_with_output():"""Mock device that returns realistic output."""device=MagicMock()device.send_command=MagicMock(side_effect={"show ip route":"10.0.0.0/8 via 192.168.1.1","show running-config":"hostname R1\nip domain-name example.com","show interfaces brief":"Gi0/0/1 YES manual up 10.0.0.1",}.get)returndevice@pytest.fixturedefmock_device_fails():"""Mock device that fails to connect."""device=MagicMock()device.send_command.side_effect=Exception("Connection timeout")returndevice
# ❌ BAD - Tests implementation detailsdeftest_retry():@retry()deffunc():return"success"assertfunc.__name__=="wrapper"# Tests internal name# ✅ GOOD - Tests behaviordeftest_retry():@retry()deffunc():return"success"assertfunc()=="success"# Tests what user cares about
# ❌ BAD - Multiple assertions make it hard to know what faileddeftest_configure():result=configure_interface(...)assertresultisTrueassertmock_device.send_command.calledassertmock_device.send_command.call_count==6# ✅ GOOD - Separate tests for separate concernsdeftest_configure_returns_true():result=configure_interface(...)assertresultisTruedeftest_configure_sends_commands():configure_interface(...)assertmock_device.send_command.called
# ❌ BAD - Vaguedeftest_retry():pass# ✅ GOOD - Describes scenario and expected outcomedeftest_retry_recovers_from_transient_failure():passdeftest_retry_raises_after_max_attempts_exhausted():pass
# ❌ BAD - Mocking your own code@patch('src.device_ops.configure_interface')deftest_something(mock_configure):# This defeats the purpose of testing# ✅ GOOD - Mock Netmiko (external), test your code@patch('netmiko.ConnectHandler')deftest_configure():# Test your logic with real mocking