Snowlock's Blog

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:

  1. 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 socket

  2. test_scan_port_os_error: Ensures that the script handles network-level dropouts or unavailable interfaces gracefully without crashing.

  3. test_run_scan_range: Verifies that run_scan correctly iterates through a custom port range and aggregates open ports.

  4. test_run_scan_invalid_range: Handles user error gracefully by returning an empty list early if the start_port exceeds the end_port.

  5. test_run_scan_boundary_clamping: Tests input sanitization to ensure out-of-bound TCP ports are properly clamped to valid ranges (1 to 65535).

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