Fuzz Testing and Property-Based Testing
Fuzz testing discovers bugs by automatically generating diverse, unexpected inputs and observing program behavior. While traditional tests check specific scenarios, fuzzing explores thousands of cases to find edge cases, crashes, and security vulnerabilities.
Overview
Fuzz testing (fuzzing) and property-based testing are complementary approaches that generate test inputs automatically rather than manually specifying them. This automation enables discovery of bugs that developers wouldn't think to test - edge cases, boundary conditions, malformed inputs, and unusual combinations that break assumptions.
Traditional testing specifies exact inputs and expected outputs. You write "given input X, expect output Y." This is effective for known scenarios but misses unexpected cases. You can't write tests for bugs you don't anticipate.
Fuzz testing generates random or semi-random inputs, often intentionally malformed or extreme, then observes for crashes, hangs, memory corruption, or exceptions. Fuzzing excels at finding:
- Security vulnerabilities: Buffer overflows, injection attacks, format string bugs
- Robustness issues: Crashes from unexpected inputs, unhandled exceptions
- Edge cases: Integer overflow, boundary conditions, null/empty inputs
- Resource exhaustion: Memory leaks, infinite loops, excessive allocations
Property-based testing generates diverse inputs but focuses on verifying high-level properties (invariants) rather than specific outputs. Instead of "for input X, output is Y," you specify "for all inputs, property P holds." For example: "reversing a list twice returns the original list" or "serializing then deserializing data returns equivalent data."
Applies to: Spring Boot · Android · iOS
Fuzz testing primarily applies to backend services and native code handling complex input parsing, serialization, and security-critical operations.
Key differences:
| Aspect | Traditional Testing | Property-Based Testing | Fuzz Testing |
|---|---|---|---|
| Input source | Developer-specified | Framework-generated (guided) | Random/mutated |
| Coverage | Known scenarios | Wide range of valid inputs | Extreme, invalid, malformed inputs |
| Assertions | Exact expected output | Properties/invariants hold | No crashes, hangs, or exceptions |
| Best for | Business logic verification | Algorithmic correctness | Security, robustness, parsers |
| Failure analysis | Clear (known input/output) | Automatic shrinking to minimal case | Requires debugging with fuzzer output |
Both approaches complement traditional testing. Use traditional tests for core business logic, property-based testing for algorithms and data transformations, and fuzz testing for input parsing, serialization, and security-critical code. See our Testing Strategy for guidance on when to use each approach.
Use property-based testing when you can define clear invariants and need reproducible tests in your CI pipeline. It integrates seamlessly with existing test suites and provides clear failure messages with minimized inputs.
Use fuzz testing when hunting for security vulnerabilities, testing parsers for malformed inputs, or validating robustness against unexpected data. Fuzzing excels at finding crashes and hangs but requires more setup and produces less readable test cases.
Use both for critical code like payment processing, authentication, or data serialization where both correctness (properties) and robustness (fuzzing) matter.
Core Principles
- Automate Input Generation: Let tools generate diverse inputs instead of manually creating test cases
- Focus on Properties, Not Examples: Define invariants that should always hold rather than specific input/output pairs
- Start Simple, Expand Coverage: Begin with basic properties and primitive types, then add complexity
- Shrink to Minimal Failures: When tests fail, reduce inputs to the smallest reproducible case for debugging
- Integrate Continuously: Run property-based tests in CI; run fuzzing campaigns regularly
- Combine with Traditional Tests: Use both approaches together for comprehensive coverage
Property-Based Testing
Property-based testing verifies that certain properties (invariants) hold across a wide range of automatically generated inputs. Instead of testing specific cases, you define universal truths about your code.
Core Concepts
Properties are statements that should be true for all valid inputs. Common property categories include:
Invariant properties: Outcomes that never change regardless of input
- "Sorting a list produces the same elements"
- "Encrypting then decrypting returns original data"
- "Parsing valid JSON never throws exceptions"
Round-trip properties: Operations that reverse each other
deserialize(serialize(x)) === xdecode(encode(x)) === xfromString(toString(x)) === x
Idempotence properties: Running the operation multiple times has the same effect as once
sort(sort(x)) === sort(x)normalize(normalize(x)) === normalize(x)deduplicate(deduplicate(x)) === deduplicate(x)
Commutativity properties: Order of operations doesn't matter
add(a, b) === add(b, a)merge(x, y) === merge(y, x)(for some merge operations)
Oracle properties: Compare against a simpler, trusted implementation
- "Optimized algorithm produces same result as naive implementation"
- "New caching layer returns same data as direct database query"
Shrinking is the process of reducing a failing input to its minimal form. When a property fails for input [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], the framework automatically tries smaller inputs like [1, 2, 3, 4, 5], then [1, 2], until it finds the smallest input that still fails, making debugging easier.
jqwik for Java
jqwik is a property-based testing framework for Java that integrates with JUnit 5. It generates diverse inputs, executes tests hundreds of times with different values, and automatically shrinks failures to minimal reproducible cases.
Setup
The jqwik library integrates with JUnit 5, allowing property-based tests to run alongside traditional unit tests. Add the dependency and configure the test task to recognize jqwik's test engine.
// build.gradle
dependencies {
testImplementation 'net.jqwik:jqwik:1.8.2'
}
test {
useJUnitPlatform {
includeEngines 'jqwik' // Enable jqwik test engine
}
}
Basic Example
import net.jqwik.api.*;
class StringPropertiesTest {
@Property
void reversingTwiceGivesOriginal(@ForAll String input) {
String reversed = new StringBuilder(input).reverse().toString();
String reversedTwice = new StringBuilder(reversed).reverse().toString();
// Property: reverse(reverse(x)) == x
Assertions.assertEquals(input, reversedTwice);
}
@Property
void lengthIsPreservedWhenReversing(@ForAll String input) {
String reversed = new StringBuilder(input).reverse().toString();
// Property: length doesn't change
Assertions.assertEquals(input.length(), reversed.length());
}
}
By default, jqwik runs each property 1000 times with different generated values. The @ForAll annotation tells jqwik to generate values of that type. When a property fails, jqwik automatically shrinks the input to the simplest failing case - if it fails with a 50-character string, jqwik tries progressively shorter strings until it finds the minimal reproduction.
Real-World Example: Payment Validation
class PaymentValidatorTest {
// Property: validating twice gives same result (idempotent)
@Property
void validationIsIdempotent(
@ForAll @BigRange(min = "0.01", max = "999999.99") BigDecimal amount) {
PaymentValidator validator = new PaymentValidator();
ValidationResult first = validator.validate(amount);
ValidationResult second = validator.validate(amount);
Assertions.assertEquals(first.isValid(), second.isValid());
Assertions.assertEquals(first.errors(), second.errors());
}
// Property: positive amounts are valid
@Property
void positiveAmountsAreValid(
@ForAll @BigRange(min = "0.01", max = "999999.99") BigDecimal amount) {
PaymentValidator validator = new PaymentValidator();
ValidationResult result = validator.validate(amount);
Assertions.assertTrue(result.isValid(),
"Positive amount " + amount + " should be valid");
}
// Property: negative or zero amounts are invalid
@Property
void nonPositiveAmountsAreInvalid(
@ForAll @BigRange(max = "0.00") BigDecimal amount) {
PaymentValidator validator = new PaymentValidator();
ValidationResult result = validator.validate(amount);
Assertions.assertFalse(result.isValid(),
"Non-positive amount " + amount + " should be invalid");
}
// Property: serialization round-trip preserves data
@Property
void serializationRoundTripPreservesData(
@ForAll @BigRange(min = "0.01", max = "999999.99") BigDecimal amount,
@ForAll @StringLength(min = 3, max = 10) String currency) {
Payment payment = new Payment(amount, currency);
ObjectMapper mapper = new ObjectMapper();
try {
String json = mapper.writeValueAsString(payment);
Payment deserialized = mapper.readValue(json, Payment.class);
// Round-trip property
Assertions.assertEquals(payment.getAmount(), deserialized.getAmount());
Assertions.assertEquals(payment.getCurrency(), deserialized.getCurrency());
} catch (JsonProcessingException e) {
Assertions.fail("Serialization should not throw for valid payment: " + e.getMessage());
}
}
}
These properties run 1000 times each with different generated values, testing far more scenarios than manually written tests could cover. The @BigRange and @StringLength annotations constrain generated values to realistic ranges, ensuring tests focus on valid business scenarios rather than extreme edge cases that aren't relevant.
Custom Generators
When built-in generators don't match your domain, create custom generators using Arbitraries:
class PaymentGenerators {
@Provide
Arbitrary<Payment> payments() {
Arbitrary<BigDecimal> amounts = Arbitraries
.bigDecimals()
.between(new BigDecimal("0.01"), new BigDecimal("100000"))
.ofScale(2); // Two decimal places for currency
Arbitrary<String> currencies = Arbitraries.of("USD", "EUR", "GBP", "JPY");
return Combinators.combine(amounts, currencies)
.as(Payment::new);
}
@Property
void paymentAmountIsPositive(@ForAll("payments") Payment payment) {
Assertions.assertTrue(
payment.getAmount().compareTo(BigDecimal.ZERO) > 0,
"Payment amount must be positive"
);
}
@Provide
Arbitrary<AccountTransfer> transfers() {
Arbitrary<String> accountIds = Arbitraries
.strings()
.numeric()
.ofLength(10);
Arbitrary<BigDecimal> amounts = Arbitraries
.bigDecimals()
.between(new BigDecimal("1.00"), new BigDecimal("50000"))
.ofScale(2);
return Combinators.combine(accountIds, accountIds, amounts)
.as(AccountTransfer::new)
.filter(transfer ->
!transfer.getFromAccount().equals(transfer.getToAccount())
); // Ensure source and destination differ
}
}
The @Provide annotation creates reusable generators. Combinators.combine merges multiple generators, and .as() constructs your domain objects. The .filter() method ensures generated values meet constraints - here preventing transfers from an account to itself.
Custom generators enable testing realistic business objects rather than just primitives, making property-based tests more valuable for domain logic validation.
Advanced: Stateful Testing
Property-based testing can validate stateful systems by generating sequences of operations and checking invariants after each step:
class BankAccountStatefulTest {
@Property
void balanceNeverGoesNegative(@ForAll("accountOperations") ActionSequence<BankAccount> actions) {
// Start with an account with initial balance
BankAccount account = new BankAccount(new BigDecimal("1000.00"));
// Apply sequence of operations
actions.run(account);
// Invariant: balance is never negative
Assertions.assertTrue(
account.getBalance().compareTo(BigDecimal.ZERO) >= 0,
"Balance must never be negative"
);
}
@Provide
Arbitrary<ActionSequence<BankAccount>> accountOperations() {
return Arbitraries.sequences(
Arbitraries.oneOf(
depositAction(),
withdrawAction(),
transferAction()
)
);
}
private Arbitrary<Action<BankAccount>> depositAction() {
return Arbitraries.bigDecimals()
.between(new BigDecimal("1.00"), new BigDecimal("1000.00"))
.map(amount -> account -> account.deposit(amount));
}
private Arbitrary<Action<BankAccount>> withdrawAction() {
return Arbitraries.bigDecimals()
.between(new BigDecimal("1.00"), new BigDecimal("500.00"))
.map(amount -> account -> account.withdraw(amount));
}
}
Stateful testing generates sequences like "deposit 100, withdraw 50, deposit 200, withdraw 300" and verifies invariants hold after each operation. This discovers bugs in state transitions that single-operation tests miss - for example, a race condition that only appears after specific operation sequences.
fast-check for TypeScript
fast-check brings property-based testing to TypeScript and JavaScript. It generates diverse test data, runs properties hundreds of times, and shrinks failures to minimal reproducible cases.
Setup
Install fast-check as a development dependency. Create a dedicated npm script to run property-based tests separately from traditional unit tests, making it easy to run them independently or skip them during rapid development iterations.
npm install --save-dev fast-check
// package.json
{
"scripts": {
"test": "jest",
"test:property": "jest --testMatch='**/*.property.test.ts'" // Run only property tests
}
}
Basic Example
import fc from 'fast-check';
describe('String properties', () => {
it('reversing twice returns original', () => {
fc.assert(
fc.property(fc.string(), (input) => {
const reversed = input.split('').reverse().join('');
const reversedTwice = reversed.split('').reverse().join('');
expect(reversedTwice).toBe(input);
})
);
});
it('length is preserved when reversing', () => {
fc.assert(
fc.property(fc.string(), (input) => {
const reversed = input.split('').reverse().join('');
expect(reversed.length).toBe(input.length);
})
);
});
});
fc.assert runs the property 100 times by default (configurable). fc.property defines the test, and fc.string() generates diverse string inputs including empty strings, Unicode characters, and very long strings.
Real-World Example: API Response Handling
describe('Payment API serialization', () => {
// Property: round-trip serialization preserves data
it('serialization round-trip preserves payment data', () => {
fc.assert(
fc.property(
fc.record({
id: fc.uuid(),
amount: fc.double({ min: 0.01, max: 999999.99, noNaN: true }),
currency: fc.constantFrom('USD', 'EUR', 'GBP'),
timestamp: fc.date(),
}),
(payment) => {
const serialized = JSON.stringify(payment);
const deserialized = JSON.parse(serialized);
expect(deserialized.id).toBe(payment.id);
expect(deserialized.amount).toBeCloseTo(payment.amount, 2);
expect(deserialized.currency).toBe(payment.currency);
}
),
{ numRuns: 1000 } // Run 1000 times for high confidence
);
});
// Property: validation is consistent
it('validates payment amounts consistently', () => {
fc.assert(
fc.property(
fc.double({ min: -1000, max: 1000000, noNaN: true }),
(amount) => {
const validator = new PaymentValidator();
// Run validation twice
const result1 = validator.validateAmount(amount);
const result2 = validator.validateAmount(amount);
// Idempotence: same input gives same result
expect(result1.isValid).toBe(result2.isValid);
expect(result1.errors).toEqual(result2.errors);
}
)
);
});
// Property: positive amounts pass validation
it('accepts all positive amounts', () => {
fc.assert(
fc.property(
fc.double({ min: 0.01, max: 999999.99, noNaN: true }),
(amount) => {
const validator = new PaymentValidator();
const result = validator.validateAmount(amount);
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
}
)
);
});
// Property: non-positive amounts fail validation
it('rejects non-positive amounts', () => {
fc.assert(
fc.property(
fc.double({ max: 0, noNaN: true }),
(amount) => {
const validator = new PaymentValidator();
const result = validator.validateAmount(amount);
expect(result.isValid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
}
)
);
});
});
The fc.record generator creates objects with specified properties, perfect for testing domain objects. The { numRuns: 1000 } option increases test iterations for critical code. Combining multiple generators (like fc.uuid(), fc.double(), fc.constantFrom()) creates realistic test data.
Custom Generators
Build domain-specific generators for complex business objects:
// Custom generator for payment transfers
const transferGenerator = fc.record({
fromAccount: fc.stringOf(fc.char(), { minLength: 10, maxLength: 10 }),
toAccount: fc.stringOf(fc.char(), { minLength: 10, maxLength: 10 }),
amount: fc.double({ min: 0.01, max: 50000, noNaN: true }),
currency: fc.constantFrom('USD', 'EUR', 'GBP'),
reference: fc.lorem({ maxCount: 20 }),
}).filter(transfer => transfer.fromAccount !== transfer.toAccount);
describe('Account transfers', () => {
it('transfer amount is preserved', () => {
fc.assert(
fc.property(transferGenerator, (transfer) => {
const processor = new TransferProcessor();
const result = processor.process(transfer);
if (result.success) {
expect(result.debitedAmount).toEqual(transfer.amount);
expect(result.creditedAmount).toEqual(transfer.amount);
}
})
);
});
});
// Generator for sequences of operations (stateful testing)
const accountOperationGenerator = fc.commands([
fc.record({
type: fc.constant('deposit' as const),
amount: fc.double({ min: 1, max: 1000, noNaN: true }),
}),
fc.record({
type: fc.constant('withdraw' as const),
amount: fc.double({ min: 1, max: 500, noNaN: true }),
}),
]);
describe('Account state', () => {
it('balance is always non-negative', () => {
fc.assert(
fc.property(
accountOperationGenerator,
fc.double({ min: 1000, max: 10000, noNaN: true }), // Initial balance
(operations, initialBalance) => {
const account = new BankAccount(initialBalance);
operations.forEach(op => {
if (op.type === 'deposit') {
account.deposit(op.amount);
} else if (op.type === 'withdraw' && account.balance >= op.amount) {
account.withdraw(op.amount);
}
});
expect(account.balance).toBeGreaterThanOrEqual(0);
}
)
);
});
});
The .filter() method ensures generated data meets business rules - here preventing transfers from an account to itself. The fc.commands() generator creates sequences of operations for stateful testing, discovering bugs that only appear after specific operation sequences.
Fuzz Testing
Fuzz testing generates massive volumes of random, mutated, or malformed inputs to discover crashes, hangs, memory corruption, and security vulnerabilities. Unlike property-based testing which verifies invariants, fuzz testing primarily detects when programs fail catastrophically.
When to Use Fuzz Testing
Fuzz testing is particularly effective for:
Parsers and deserializers: JSON, XML, Protocol Buffers, binary formats - any code that processes external data. Malformed inputs can cause crashes, infinite loops, or exploitable buffer overflows.
Input validation: Authentication, payment validation, data sanitization. Fuzzing discovers bypasses where unexpected inputs circumvent security checks.
Serialization/deserialization: Testing round-trip conversion (object → bytes → object) with corrupted data, truncated payloads, or invalid encodings.
File format handlers: Image processing, document parsing, archive extraction. Historically rich sources of security vulnerabilities.
Network protocol implementations: HTTP parsers, WebSocket handlers, API endpoints. Malformed requests can crash servers or enable denial-of-service.
Cryptographic code: Encryption/decryption, signing, hashing. Critical to test edge cases and ensure no crashes leak sensitive data.
Coverage-Guided Fuzzing
Modern fuzzers use coverage-guided fuzzing: they monitor which code paths each input exercises, then mutate inputs that discover new paths. This feedback loop systematically explores the program, finding deep bugs that random testing would miss.
How it works:
- Instrumentation: Compiler adds probes to track which basic blocks execute
- Seed corpus: Start with valid example inputs (sample JSON, test files, etc.)
- Execution: Run program with each input, recording code coverage
- Mutation: Modify inputs that find new code paths (bit flips, byte insertions, splicing)
- Iteration: Repeat millions of times, building corpus of interesting inputs
Why coverage-guided fuzzing works: Random fuzzing struggles with code like if (input == "MAGIC_VALUE_12345") because random chance of generating the exact string is infinitesimal. Coverage-guided fuzzing notices that inputs starting with "M" reach slightly different code, then mutates those to eventually discover "MAGIC_VALUE_12345". This guided search finds bugs in complex parsers that pure random testing would never reach.
Jazzer for Java
Jazzer is a coverage-guided fuzz testing engine for the JVM. It uses libFuzzer-style instrumentation to guide input generation toward discovering new code paths.
Setup
Jazzer requires both a Gradle plugin (for build integration and instrumentation) and a JUnit dependency (for writing fuzz tests). The plugin instruments your code to track coverage, while the JUnit library provides the @FuzzTest annotation and fuzzing infrastructure.
// build.gradle
plugins {
id 'com.code-intelligence.jazzer' version '0.22.0' // Coverage instrumentation
}
dependencies {
testImplementation 'com.code-intelligence:jazzer-junit:0.22.0' // Fuzz test framework
}
Basic Fuzz Test
import com.code_intelligence.jazzer.junit.FuzzTest;
import java.nio.charset.StandardCharsets;
class PaymentParserFuzzTest {
@FuzzTest
void fuzzJsonParsing(byte[] data) {
// Convert random bytes to string
String json = new String(data, StandardCharsets.UTF_8);
try {
// Parser should handle any input without crashing
PaymentParser parser = new PaymentParser();
parser.parse(json);
// If parsing succeeds, verify basic invariants
// (no assertion needed for fuzz tests - just don't crash)
} catch (ParseException e) {
// Expected for invalid JSON - not a bug
} catch (IllegalArgumentException e) {
// Expected for invalid payment data - not a bug
}
// Any other exception (NullPointer, OutOfMemory, etc.) is a BUG
}
@FuzzTest
void fuzzPaymentValidation(byte[] data) {
// Fuzz the payment validator
String input = new String(data, StandardCharsets.UTF_8);
PaymentValidator validator = new PaymentValidator();
try {
// Should never crash regardless of input
ValidationResult result = validator.validate(input);
// If validation passes, result should be usable
if (result.isValid()) {
assert result.getErrors().isEmpty();
} else {
assert !result.getErrors().isEmpty();
}
} catch (IllegalArgumentException e) {
// Expected for completely invalid input
}
}
}
Jazzer generates random byte arrays and feeds them to your fuzz targets. The fuzzer monitors code coverage and mutates inputs that discover new execution paths. Unlike traditional tests, fuzz tests don't assert specific outputs - they verify the code doesn't crash, hang, or throw unexpected exceptions.
Real-World Example: API Request Fuzzing
import com.code_intelligence.jazzer.junit.FuzzTest;
import com.code_intelligence.jazzer.api.FuzzedDataProvider;
class PaymentAPIFuzzTest {
@FuzzTest
void fuzzPaymentRequest(FuzzedDataProvider data) {
// Use FuzzedDataProvider for structured fuzzing
PaymentRequest request = new PaymentRequest();
// Fuzz individual fields with appropriate types
request.setAmount(data.consumeBigDecimal());
request.setCurrency(data.consumeString(3));
request.setAccountFrom(data.consumeString(20));
request.setAccountTo(data.consumeString(20));
request.setDescription(data.consumeRemainingAsString());
PaymentProcessor processor = new PaymentProcessor();
try {
// Process should validate and reject invalid inputs gracefully
ProcessingResult result = processor.process(request);
// Verify invariants if processing succeeds
if (result.isSuccess()) {
assert result.getTransactionId() != null;
assert result.getTimestamp() != null;
}
} catch (ValidationException e) {
// Expected for invalid inputs
} catch (BusinessRuleException e) {
// Expected for rule violations
}
// Crashes, NPEs, or unexpected exceptions are BUGS
}
@FuzzTest
void fuzzJSONDeserialization(byte[] data) {
ObjectMapper mapper = new ObjectMapper();
try {
// Attempt to deserialize fuzzed data
Payment payment = mapper.readValue(data, Payment.class);
// If deserialization succeeds, object should be valid
assert payment != null;
// Round-trip should be consistent
byte[] serialized = mapper.writeValueAsBytes(payment);
Payment roundTrip = mapper.readValue(serialized, Payment.class);
assert payment.equals(roundTrip);
} catch (JsonProcessingException e) {
// Expected for malformed JSON
}
}
@FuzzTest(maxDuration = "10m") // Longer fuzzing campaign
void fuzzPaymentCalculations(FuzzedDataProvider data) {
// Fuzz numerical calculations
BigDecimal amount = new BigDecimal(data.consumeDouble(-1000000, 1000000));
BigDecimal feePercent = new BigDecimal(data.consumeDouble(0, 100));
FeeCalculator calculator = new FeeCalculator();
try {
BigDecimal fee = calculator.calculateFee(amount, feePercent);
// Verify fee is reasonable
assert fee.compareTo(BigDecimal.ZERO) >= 0;
assert fee.compareTo(amount.abs()) <= 0; // Fee can't exceed amount
} catch (ArithmeticException e) {
// Expected for invalid calculations
}
}
}
FuzzedDataProvider enables structured fuzzing by consuming the random byte array as typed values (strings, integers, decimals). This creates more realistic test data than raw byte arrays. The maxDuration parameter controls how long Jazzer runs - longer campaigns find deeper bugs but take more time.
Integration with CI/CD
# .gitlab-ci.yml
jazzer-fuzz:
stage: test
image: eclipse-temurin:21-jdk
script:
# Run fuzz tests for 5 minutes each
- ./gradlew fuzzTest --max-duration=5m
artifacts:
when: on_failure
paths:
- build/fuzz-results/ # Contains crash-inducing inputs
expire_in: 30 days
rules:
- if: '$CI_PIPELINE_SOURCE == "schedule"' # Run on schedule, not every commit
Fuzz testing in CI typically runs on a schedule (nightly or weekly) rather than on every commit, as comprehensive fuzzing takes hours. Failed fuzzing runs produce artifacts containing the exact inputs that caused crashes, enabling developers to reproduce and fix bugs.
fast-check for TypeScript Fuzzing
While fast-check is primarily a property-based testing tool, it can also perform fuzz-style testing by generating extreme, malformed inputs:
import fc from 'fast-check';
describe('Payment parser fuzzing', () => {
it('handles arbitrary strings without crashing', () => {
fc.assert(
fc.property(
fc.string(), // Generates any string including empty, Unicode, very long
(input) => {
const parser = new PaymentParser();
try {
const result = parser.parse(input);
// If parsing succeeds, verify basic structure
if (result) {
expect(result).toHaveProperty('amount');
expect(result).toHaveProperty('currency');
}
} catch (error) {
// Parsing can fail, but should throw expected errors
expect(error).toBeInstanceOf(ParseError);
}
}
),
{ numRuns: 10000 } // Run many times for fuzz-like coverage
);
});
it('handles malformed JSON gracefully', () => {
fc.assert(
fc.property(
fc.string().filter(s => {
try {
JSON.parse(s);
return false; // Valid JSON - filter out
} catch {
return true; // Invalid JSON - include in test
}
}),
(malformedJSON) => {
const parser = new PaymentParser();
// Should not crash on malformed JSON
expect(() => {
parser.parse(malformedJSON);
}).not.toThrow(TypeError); // Type errors indicate bugs
expect(() => {
parser.parse(malformedJSON);
}).not.toThrow(RangeError); // Range errors indicate bugs
}
),
{ numRuns: 5000 }
);
});
it('handles arbitrary byte sequences', () => {
fc.assert(
fc.property(
fc.uint8Array({ minLength: 0, maxLength: 10000 }),
(bytes) => {
// Convert bytes to string (may produce invalid UTF-8)
const decoder = new TextDecoder('utf-8', { fatal: false });
const input = decoder.decode(bytes);
const parser = new PaymentParser();
// Should not crash on any byte sequence
try {
parser.parse(input);
} catch (error) {
// Only expected errors are acceptable
expect(error).toBeInstanceOf(ParseError);
}
}
),
{ numRuns: 10000 }
);
});
});
This approach uses fast-check's generators to create fuzz-like inputs (arbitrary strings, malformed JSON, byte arrays) and verifies the code handles them gracefully. While not as sophisticated as coverage-guided fuzzers like Jazzer or AFL, it's simpler to integrate into existing Jest test suites and provides good coverage for TypeScript applications.
Best Practices
Start with Seed Corpus
Provide example inputs to guide fuzzing toward interesting code paths:
// Jazzer seed corpus
@FuzzTest
void fuzzWithSeeds(byte[] data) {
// Jazzer looks for seed files in:
// src/test/resources/PaymentParserFuzzTestInputs/
// Place sample JSON files there to guide fuzzing
}
// fast-check: use pre-defined examples
it('tests with realistic examples', () => {
const examples = [
'{"amount": 100, "currency": "USD"}',
'{"amount": -50, "currency": "EUR"}',
'{"amount": 0, "currency": "GBP"}',
];
fc.assert(
fc.property(fc.string(), (input) => {
// Test random inputs
}),
{ examples } // Start with these before generating random inputs
);
});
Seed corpora accelerate fuzzing by starting with valid or semi-valid inputs that reach deeper code paths. For a JSON parser, seeds should include various JSON structures (nested objects, arrays, edge cases). Fuzzers mutate seeds to explore nearby inputs, finding bugs faster than pure random generation.
Focus on Critical Code Paths
Don't fuzz everything - prioritize:
- Input parsing: JSON, XML, binary formats, protocol parsing
- Authentication/authorization: Login, token validation, permission checks
- Payment processing: Amount calculations, fee computation, currency conversion
- Data serialization: Encoding/decoding, format conversion
- Cryptographic operations: Encryption, signing, hashing
Set Realistic Timeouts
Fuzz tests can run indefinitely. Configure reasonable limits:
@FuzzTest(maxDuration = "5m") // Stop after 5 minutes
void fuzzPaymentProcessing(FuzzedDataProvider data) {
// ...
}
fc.assert(
fc.property(/* ... */),
{
numRuns: 10000, // Limit iterations
timeout: 5000, // Timeout per test (ms)
}
);
In CI pipelines, run fuzz tests on a schedule (nightly/weekly) with longer durations. During development, use shorter campaigns (1-5 minutes) for quick feedback.
Reproduce and Debug Failures
When fuzzing finds a crash, it saves the crash-inducing input:
# Jazzer crash files
build/fuzz-results/crash-1234567890abcdef
# Reproduce the crash
./gradlew fuzzTest --reproduce=build/fuzz-results/crash-1234567890abcdef
// fast-check reports failing input in test output
// Copy the seed to reproduce:
fc.assert(
fc.property(/* ... */),
{ seed: 1234567890 } // Use seed from failure report
);
Always save crash-inducing inputs in version control as regression tests. After fixing the bug, add a traditional unit test with the exact input that caused the crash to prevent regression.
Combine with Other Testing Approaches
Fuzzing complements but doesn't replace other testing:
Traditional unit tests: Verify specific business logic and edge cases Property-based tests: Check invariants with generated inputs Fuzz tests: Find crashes and security vulnerabilities Mutation testing: Validate test effectiveness Integration tests: Test component interactions
Use all approaches together for comprehensive validation. See our Testing Strategy for guidance on balancing different testing types.
CI/CD Integration
Property-Based Testing in CI
Property-based tests run in CI like traditional tests but with configured iteration counts:
# .gitlab-ci.yml
property-tests-java:
stage: test
image: eclipse-temurin:21-jdk
script:
- ./gradlew test -Pjqwik.tries=1000 # Run 1000 iterations
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main"'
property-tests-typescript:
stage: test
image: node:22
script:
- npm ci
- npm test -- --testPathPattern=".property.test"
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main"'
Property-based tests should run on every commit and MR, just like unit tests. They're deterministic (given the same seed) and fast enough for continuous integration.
Fuzz Testing in CI
Fuzz testing runs continuously or on schedule to discover new bugs over time:
# .gitlab-ci.yml
fuzz-scheduled:
stage: fuzz
image: eclipse-temurin:21-jdk
script:
- ./gradlew fuzzTest --max-duration=2h
artifacts:
when: on_failure
paths:
- build/fuzz-results/
expire_in: 30 days
rules:
- if: '$CI_PIPELINE_SOURCE == "schedule"' # Nightly or weekly
allow_failure: true # Don't block pipeline on fuzz failures
fuzz-quick:
stage: test
image: eclipse-temurin:21-jdk
script:
- ./gradlew fuzzTest --max-duration=2m # Quick smoke test
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
allow_failure: true
Quick fuzz tests (1-5 minutes) run on MRs to catch obvious crashes. Long fuzz campaigns (hours) run on schedules to find deeper bugs. Use allow_failure: true to avoid blocking pipelines while still surfacing issues.
Continuous Fuzzing with OSS-Fuzz
For open-source projects, OSS-Fuzz provides continuous fuzzing infrastructure:
Benefits:
- Runs 24/7 on Google infrastructure
- Automatically files bugs when crashes are found
- Provides detailed crash reports and reproducers
- Integrates with GitHub/GitLab for issue tracking
Setup requires creating a project configuration in the OSS-Fuzz repository. Google's infrastructure then continuously fuzzes your project, finding bugs over weeks or months that short CI runs would miss.
Troubleshooting
Fuzz Tests Timing Out
Symptom: Fuzzer hits timeout without finding new coverage
Causes:
- Infinite loops in code under test
- Very slow code paths
- Fuzzer stuck in unproductive mutation cycle
Solutions:
@FuzzTest(maxDuration = "10m", maxCycles = 100000) // Adjust limits
void fuzzTest(byte[] data) {
// Add timeout to code under test
Callable<Void> task = () -> {
parseInput(data);
return null;
};
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Void> future = executor.submit(task);
try {
future.get(1, TimeUnit.SECONDS); // 1-second timeout per input
} catch (TimeoutException e) {
future.cancel(true);
// Timeout is expected for some inputs
}
}
Property-Based Tests Failing Intermittently
Symptom: Test passes locally but fails in CI, or vice versa
Cause: Non-deterministic behavior (time-based logic, random values, external dependencies)
Solutions:
@Property
void deterministicTest(@ForAll String input) {
// Mock time-dependent behavior
Clock fixedClock = Clock.fixed(Instant.parse("2024-01-01T00:00:00Z"), ZoneId.of("UTC"));
PaymentService service = new PaymentService(fixedClock);
// Test is now deterministic
}
it('uses deterministic behavior', () => {
fc.assert(
fc.property(fc.string(), (input) => {
// Mock randomness
jest.spyOn(Math, 'random').mockReturnValue(0.5);
// Test is now deterministic
})
);
});
Shrinking Takes Too Long
Symptom: When test fails, framework spends minutes shrinking the input
Cause: Complex nested data structures or large inputs
Solutions:
fc.assert(
fc.property(/* ... */),
{
endOnFailure: true, // Stop shrinking on first failure
// Or limit shrinking attempts:
numRuns: 1000,
maxSkipsPerRun: 100,
}
);
// jqwik: configure shrinking
@Property(shrinking = ShrinkingMode.OFF) // Disable shrinking
void fastFailingTest(@ForAll String input) {
// ...
}
For debugging, disable shrinking to see the original failing input immediately. Re-enable shrinking once you understand the failure pattern.
Summary
Key Takeaways:
- Property-Based Testing Generates Inputs: Define properties (invariants) that should hold for all inputs; the framework generates diverse test cases automatically
- Fuzz Testing Finds Crashes: Discover security vulnerabilities, robustness issues, and edge cases by testing with malformed and extreme inputs
- Use jqwik for Java: Integrates with JUnit 5, provides powerful generators, automatically shrinks failures to minimal cases
- Use fast-check for TypeScript: Brings property-based testing to JavaScript/TypeScript with Jest integration
- Use Jazzer for Java Fuzzing: Coverage-guided fuzzing for JVM applications; finds bugs in parsers, validators, and security-critical code
- Focus on Invariants: Properties like round-trip serialization, idempotence, and commutativity reveal bugs that example-based tests miss
- Combine with Traditional Testing: Property-based and fuzz testing complement unit tests, mutation testing, and integration tests
- Run in CI: Property-based tests run on every commit; fuzz tests run on schedules for deep exploration
- Prioritize Critical Code: Focus fuzzing on parsers, input validation, authentication, payment processing, and serialization
- Save Crash Inputs as Regression Tests: When fuzzing finds bugs, add the crash-inducing input as a traditional unit test
For comprehensive testing strategies combining property-based testing, fuzzing, mutation testing, and traditional approaches, see our Testing Strategy guide.
Further Reading
Internal Documentation:
- Testing Strategy - Overall testing approach and test pyramid
- Mutation Testing - Validating test suite quality
- Unit Testing - Traditional unit testing patterns
- Integration Testing - Testing component interactions
- Security Testing - SAST, DAST, and penetration testing
- CI Testing - Continuous integration testing best practices
External Resources:
- jqwik User Guide - Comprehensive jqwik documentation
- fast-check Documentation - Property-based testing for TypeScript
- Jazzer Documentation - JVM fuzzing
- AFL++ Documentation - Advanced fuzzing for C/C++
- Hypothesis Documentation - Python property-based testing
- Fuzzing Book - Comprehensive guide to fuzzing techniques