Skip to content

Fuzz & Random Testing

Concept

Fuzz testing sends unexpected, random, or malformed data to a system to expose crashes, unhandled exceptions, and behaviour nobody thought to check manually.

Random testing generates inputs without structure. Fuzzing generates inputs with structured mutations designed to trigger edge cases.

Random input → System → Did it crash? → Unexpected output? → Security issue?

What Fuzz Testing Finds

  • Crashes on unusual characters (null bytes, Unicode, emojis, control characters)
  • Unhandled exceptions on edge-case lengths (0, 1, max, max+1)
  • Type errors on unexpected input types
  • Security vulnerabilities: injection, overflow, format string bugs
  • Off-by-one errors that BVA misses if boundary isn't known

Basic Fuzz with pytest

import random
import string

import pytest


def process_input(data: str) -> str:
    if not data:
        raise ValueError("Empty input")
    if len(data) > 1000:
        raise ValueError("Input too long")
    return data.strip().lower()


class TestFuzzInput:
    @pytest.mark.parametrize("_attempt", range(20))
    def test_random_strings_dont_crash(self, _attempt: int) -> None:
        length = random.randint(1, 500)
        data = "".join(random.choices(string.printable, k=length))
        result = process_input(data)
        assert isinstance(result, str)

    def test_unicode_input(self) -> None:
        result = process_input("Тест 日本語 data")
        assert isinstance(result, str)

    def test_whitespace_only(self) -> None:
        result = process_input("   \t\n   ")
        assert result == ""

    def test_boundary_length(self) -> None:
        result = process_input("a" * 1000)
        assert len(result) == 1000

    def test_over_boundary_raises(self) -> None:
        with pytest.raises(ValueError, match="too long"):
            process_input("a" * 1001)

    def test_empty_input_raises(self) -> None:
        with pytest.raises(ValueError, match="Empty"):
            process_input("")

Property-Based Testing with hypothesis

hypothesis is smarter than random — it generates minimal failing examples and shrinks them.

from hypothesis import given, settings, strategies as st


@given(st.text(min_size=0, max_size=1500))
@settings(max_examples=200)
def test_process_input_contract(data: str) -> None:
    if not data or len(data) > 1000:
        with pytest.raises(ValueError):
            process_input(data)
    else:
        result = process_input(data)
        assert result is not None
        assert isinstance(result, str)


@given(st.text(min_size=1, max_size=1000))
def test_valid_input_always_produces_lowercase(data: str) -> None:
    result = process_input(data)
    assert result == result.lower()

Targeted Fuzz Cases

Beyond random, always include known-difficult inputs:

Category Examples
Empty / whitespace "", " ", "\t\n"
Max length "a" * 1000, "a" * 1001
Unicode "日本語", "Ñoño", "مرحبا"
Special chars "'; DROP TABLE--", "<script>", "../../../"
Null bytes "\x00", "data\x00more"
Numbers as string "0", "-1", "9999999999"
Control characters "\r", "\n", "\x1b"
Homoglyphs "аdmin" (Cyrillic а, not Latin a)

Fuzz vs Random vs Property-Based

Approach Input Generation Shrinking Best For
Random parametrize random.choice No Quick smoke coverage
Mutation fuzzing Mutate known inputs No Parsing, protocols
hypothesis Smart strategy-based Yes Logic invariants, regression
Security fuzzers (AFL, libFuzzer) Coverage-guided Yes C/Rust, binary protocols

Risks

Risk Description Mitigation
Non-deterministic failures Random seed changes between runs Log seed; use hypothesis which reproduces failures
Slow tests 1000 random cases slow CI Limit with range(20) or @settings(max_examples=50)
No assertion Only checking "doesn't crash" Add invariant assertions (type, length, format)
Missing important categories Random misses known-dangerous inputs Always add targeted cases alongside random ones