PyScan: Adding Tests & GitHub Actions
In my first blogpost about pyScan, I talked about how concurrency saved the day, when it came to performance! But still I did not have a single test for pyScan.
For the longest time ever, I had the preconceived notion that unit tests/actions & CI pipelines were only meant for large enterprise repos. But yesterday, I finally got about to writing a simple test and github actions, and it changed my view completely.
The Test
Here is the test, broken down to explain the logic:
from unittest.mock import MagicMock, patch
import pytest
from pyscan import run_scan, scan_port
pytest is a essential requirement for python code tests, and we use unittest.mock to simulate a connection, rather than using actual socket connections, which can be unreliable (i.e, due to network issues, firewalls, etc)
@pytest.mark.parametrize(
"connect_value, expected_result",
[
(0, 80), # Port is open
(111, None), # Connection refused (closed)
],
)
Instead of writing three different functions to test an open port, a closed port, and a filtered port (which means copying and pasting 90% of the same assertion logic), parametrize lets you write a single test skeleton and feed multiple sets of inputs and expected outputs into it!
(In my previous basic workflow, I did not use it, and so I had to repeat a lot of boilerplate code)
And now, the actual tests themselves!:
@patch("socket.socket")
def test_scan_port_success_or_closed(mock_socket, connect_value, expected_result):
mock_instance = MagicMock()
mock_instance.connect_ex.return_value = connect_value
mock_socket.return_value.__enter__.return_value = mock_instance
result = scan_port("127.0.0.1", 80)
assert result == expected_result
@patch("socket.socket")
def test_scan_port_os_error(mock_socket):
# Simulate an OSError (e.g., network unreachable or interface down)
mock_socket.side_effect = OSError("Network is unreachable")
result = scan_port("127.0.0.1", 80)
assert result is None
@patch("pyscan.scan_port")
def test_run_scan_range(mock_scan_port):
# Simulate ports 20 and 80 being open, others returning None within range 20 to 100
mock_scan_port.side_effect = lambda host, port, timeout=1.0: port if port in [20, 80] else None
open_ports = run_scan("127.0.0.1", start_port=20, end_port=100)
assert sorted(open_ports) == [20, 80]
assert mock_scan_port.call_count == 81
@patch("pyscan.scan_port")
def test_run_scan_invalid_range(mock_scan_port):
# Edge Case: start_port greater than end_port should return an empty list immediately
open_ports = run_scan("127.0.0.1", start_port=100, end_port=50)
assert open_ports == []
mock_scan_port.assert_not_called()
@patch("pyscan.scan_port")
def test_run_scan_boundary_clamping(mock_scan_port):
# Edge Case: Ports out of TCP bounds (e.g., 0 or 70000) should be clamped to 1-65535
mock_scan_port.return_value = None
run_scan("127.0.0.1", start_port=-50, end_port=99999)
# Max range allowed by clamp is 1 to 65535 (total 65535 calls)
assert mock_scan_port.call_count == 65535
What these tests cover:
test_scan_port_success_or_closed: Uses pytest.mark.parametrize to cleanly test both open ports (connect_ex returning 0) and closed/refused ports (111) using sockettest_scan_port_os_error: Ensures that the script handles network-level dropouts or unavailable interfaces gracefully without crashing.test_run_scan_range: Verifies that run_scan correctly iterates through a custom port range and aggregates open ports.test_run_scan_invalid_range: Handles user error gracefully by returning an empty list early if the start_port exceeds the end_port.test_run_scan_boundary_clamping: Tests input sanitization to ensure out-of-bound TCP ports are properly clamped to valid ranges (1to65535).
Running just a simple command: python -m pytest would automatically test the code to see any errors, and resolving them to finally see "All 5 test passed!" was worth it!
Github Actions
Now came the real deal, Github Actions. I read up on basic guides on how to set github actions up, and was able to write up a basic workflow file. See the actual file here!
This file does a few things: It tells github to spin up a new runner, updates pip, then installs dependencies. It then runs ruff check . (ruff is a python linter) and finally runs pytest!
If you've been putting off writing tests for your smaller side projects because you think it's overkill, I'd strongly encourage you to give it a shot. It turns out that writing tests isn't just about catching bugs, because it also saves you a ton of headache later on when refactoring your code! (currently doing that!).
Github Repo: snowlock-dev/PyScan