18  Writing Tests in Python

If you ask me, tests are little executable promises. They describe what you believe your code should do, then give Python a way to tell you when that promise has been broken.

For offensive-security tooling, tests are especially helpful around parsing, formatting, network boundaries, and error handling: the boring edges where a tool often fails before it ever reaches the exciting part.

The point isn’t to make writing cool hacker tools boring, but tests do give you a fast way to ask, “did I just break the thing I thought was working?” Future-you deserves that kindness.

Types of Tests

Different tests answer different questions:

  • Unit tests check one small piece of code in isolation, such as a parser, formatter, validator, or helper function.
  • Integration tests check that two or more pieces work together, such as your scanner calling a HTTP client and interpreting the response.
  • Functional tests check a complete feature from the user’s point of view, without necessarily driving the whole deployed system.
  • End-to-end tests drive the whole path through the system, usually as close to production as practical.
  • Performance tests check speed, load, memory use, throughput, or other resource behaviour.
  • Smoke tests are quick checks that the important paths are alive before you trust a larger run.

Today we’re going to focus on unit tests and integration tests. They give you a lot of confidence early, and they’re small enough to write while the code is still fresh in your head.

A Small Testing Manifesto

When you’re creating tools that will eventually interact with networks, filesystems, APIs, or client environments, tests give you a controlled way to understand how that tool behaves before it gets anywhere near a production system.

Testing matters because a tool that floods a production service, mutates real data, or changes a client environment isn’t harmless. Even when you are running it in an authorised context, you’re still responsible for its blast radius. If you’re representing yourself, your team, or your employer, you need to care about what actions your tool takes, how you verified those actions, and whether your confidence came from evidence rather than the idea that “it should work”.

Some people might argue that “real hackers don’t care about that”, maybe, but it’s also a thought-terminating cliché and not a very useful one. Whether you call yourself a security engineer, penetration tester, ethical hacker, or something else entirely, accountability and responsibility matters when talking about your work. Know what your tool does, keep your tests controlled, and make sure it logs enough detail to diagnose runtime problems or explain any impact it has on a client environment.

A Tiny Function Worth Testing

This function takes raw wordlist lines and returns clean subdomain labels. It strips whitespace, skips empty lines, ignores comments, and keeps the order from the original wordlist.

def normalise_wordlist(lines):
    words = []
    for line in lines:
        word = line.strip()
        if not word or word.startswith("#"):
            continue
        words.append(word)
    return words


sample = [" www\n", "", "# comment", "mail\n"]
print(normalise_wordlist(sample))
['www', 'mail']

Unit Tests With unittest

Python ships with a testing framework called unittest. A unittest test usually lives inside a class that inherits from unittest.TestCase. Each test method starts with test_, and each assertion says what must be true.

pytest is still a useful test runner, and this book’s repository uses python -m pytest for validation. One nice thing about pytest is that it can run many unittest test cases too, so learning unittest doesn’t trap you in one runner forever.

import unittest


class WordlistTests(unittest.TestCase):
    def test_removes_blank_lines_and_comments(self):
        lines = [" www\n", "", "# comment", "mail\n"]

        self.assertEqual(normalise_wordlist(lines), ["www", "mail"])

    def test_keeps_duplicate_looking_entries(self):
        lines = ["api", " api ", "API"]

        self.assertEqual(normalise_wordlist(lines), ["api", "api", "API"])


suite = unittest.defaultTestLoader.loadTestsFromTestCase(WordlistTests)
result = unittest.TextTestRunner(verbosity=2).run(suite)
assert result.wasSuccessful()
test_keeps_duplicate_looking_entries (__main__.WordlistTests.test_keeps_duplicate_looking_entries) ... 
ok

test_removes_blank_lines_and_comments (__main__.WordlistTests.test_removes_blank_lines_and_comments) ... 
ok



----------------------------------------------------------------------

Ran 2 tests in 0.004s



OK

Testing Exceptions

Sometimes the right behaviour is an error. assertRaises() lets you state that explicitly: this input should fail, and it should fail in this specific way.

def build_url(subdomain, domain, scheme="https"):
    if not subdomain:
        raise ValueError("subdomain is required")
    if not domain:
        raise ValueError("domain is required")
    return f"{scheme}://{subdomain}.{domain}"


class UrlBuilderTests(unittest.TestCase):
    def test_builds_https_url_by_default(self):
        self.assertEqual(
            build_url("www", "example.test"),
            "https://www.example.test",
        )

    def test_rejects_empty_subdomain(self):
        with self.assertRaises(ValueError):
            build_url("", "example.test")


suite = unittest.defaultTestLoader.loadTestsFromTestCase(UrlBuilderTests)
result = unittest.TextTestRunner(verbosity=2).run(suite)
assert result.wasSuccessful()
test_builds_https_url_by_default (__main__.UrlBuilderTests.test_builds_https_url_by_default) ... 
ok

test_rejects_empty_subdomain (__main__.UrlBuilderTests.test_rejects_empty_subdomain) ... 
ok



----------------------------------------------------------------------

Ran 2 tests in 0.002s



OK

Unit Tests for Binary Helpers

Unit tests are especially good for protocol helpers. You don’t need a live server to test whether a TLS record header is parsed correctly; you need a known byte string and a clear expected result.

from fixtures.local_targets import hex_preview, parse_tls_record_header


class TlsHelperTests(unittest.TestCase):
    def test_parses_tls_record_header(self):
        header = b"\x16\x03\x02\x00\x04"

        self.assertEqual(parse_tls_record_header(header), (22, 0x0302, 4))

    def test_formats_hex_preview(self):
        self.assertEqual(hex_preview(b"\x18\x03\x02"), "0000: 18 03 02")


suite = unittest.defaultTestLoader.loadTestsFromTestCase(TlsHelperTests)
result = unittest.TextTestRunner(verbosity=2).run(suite)
assert result.wasSuccessful()
test_formats_hex_preview (__main__.TlsHelperTests.test_formats_hex_preview) ... 
ok

test_parses_tls_record_header (__main__.TlsHelperTests.test_parses_tls_record_header) ... 
ok



----------------------------------------------------------------------

Ran 2 tests in 0.003s



OK

Integration Tests Without Touching the Internet

Integration tests check how pieces fit together. For a security tool, the risky part often comes from the test reaching across the network, not from the assertion at the end.

The safer pattern is to test against fixtures, fakes, or local services where you control the inputs and the consequences. In this book, fake_requests_get() acts like a small slice of requests.get() without ever leaving your machine.

import requests

from fixtures.local_targets import fake_requests_get


def check_subdomain(subdomain, domain, http_get=fake_requests_get):
    url = build_url(subdomain, domain)
    response = http_get(url)
    return response.status_code == 200


class SubdomainIntegrationTests(unittest.TestCase):
    def test_finds_known_fixture_subdomain(self):
        self.assertTrue(check_subdomain("www", "example.test"))

    def test_unknown_fixture_target_raises_connection_error(self):
        with self.assertRaises(requests.exceptions.ConnectionError):
            check_subdomain("missing", "example.test")


suite = unittest.defaultTestLoader.loadTestsFromTestCase(SubdomainIntegrationTests)
result = unittest.TextTestRunner(verbosity=2).run(suite)
assert result.wasSuccessful()
test_finds_known_fixture_subdomain (__main__.SubdomainIntegrationTests.test_finds_known_fixture_subdomain) ... 
ok

test_unknown_fixture_target_raises_connection_error (__main__.SubdomainIntegrationTests.test_unknown_fixture_target_raises_connection_error) ... 
ok



----------------------------------------------------------------------

Ran 2 tests in 0.005s



OK

The same idea works for socket-shaped code. If a fixture behaves like the object your function expects, you can test parsing and control flow without sending packets to a real host.

from fixtures.local_targets import (
    HeartbleedFixtureSocket,
    TLS_HEARTBEAT_CONTENT_TYPE,
    heartbeat_request,
    parse_tls_record_header,
    recvall,
    tls_client_hello,
)


def read_fixture_heartbeat(sock):
    sock.send(tls_client_hello())
    handshake_header = sock.recv(5)
    _content_type, _version, handshake_length = parse_tls_record_header(handshake_header)
    recvall(sock, handshake_length)

    sock.send(heartbeat_request())
    heartbeat_header = sock.recv(5)
    content_type, _version, heartbeat_length = parse_tls_record_header(heartbeat_header)
    payload = recvall(sock, heartbeat_length)
    return content_type, payload


class HeartbleedFixtureIntegrationTests(unittest.TestCase):
    def test_reads_heartbeat_fixture_response(self):
        sock = HeartbleedFixtureSocket(leak=b"fixture secret")

        content_type, payload = read_fixture_heartbeat(sock)

        self.assertEqual(content_type, TLS_HEARTBEAT_CONTENT_TYPE)
        self.assertTrue(payload.endswith(b"fixture secret"))


suite = unittest.defaultTestLoader.loadTestsFromTestCase(HeartbleedFixtureIntegrationTests)
result = unittest.TextTestRunner(verbosity=2).run(suite)
assert result.wasSuccessful()
test_reads_heartbeat_fixture_response (__main__.HeartbleedFixtureIntegrationTests.test_reads_heartbeat_fixture_response) ... 
ok



----------------------------------------------------------------------

Ran 1 test in 0.002s



OK

Recording External Calls With VCR.py

Sometimes you really do need to test code that talks to a live HTTP API. VCR.py can record an HTTP interaction into a cassette file, then replay that response during later test runs.

The pattern looks roughly like this:

import vcr


@vcr.use_cassette("fixtures/cassettes/github-user.yaml")
def test_fetches_github_user():
    response = requests.get("https://api.github.com/user")
    assert response.status_code == 200

VCR.py is useful because it can make HTTP-dependent tests repeatable, but you need to be mindful that the cassette may contain secrets or sensitive information that isn’t suitable for test cases. So always make sure you choose safe record modes, review what is in the cassette, and make sure you’re allowed to call the live service in the first place.

Checkpoint

  1. Write a unittest.TestCase for normalise_wordlist() that covers blank lines, comments, whitespace, and duplicate-looking input such as api, api, and API.
  2. Write unit tests for a URL-building helper, including the normal https://www.example.test case and at least one invalid input case.
  3. Write unit tests for parse_tls_record_header() and hex_preview() using deterministic byte strings.
  4. Write an integration-style test that uses fake_requests_get() against known .example.test fixture hosts.
  5. Write an integration-style test that uses HeartbleedFixtureSocket to exercise socket-shaped code without touching the network.
  6. Optional stretch: sketch where VCR.py would fit if your tool needed to call a real API during one carefully controlled recording run.

Run the tests, then deliberately break one expected value and run them again. If everything went the way we expected, Python should complain in a way that points you back to the broken promise.

Extension Activity

Add one fixture-backed integration test for a failure path, not just a successful response. For example, assert that an unknown subdomain raises or is handled cleanly without touching the internet.

References