Property-Based and Fuzz Testing

Traditional unit tests verify specific inputs and outputs, but property-based and fuzz testing take a more comprehensive approach by testing properties and invariants across a wide range of inputs.

Property-Based Testing with Rapid

Property-based testing verifies that your code satisfies certain properties for all valid inputs:

package property

import (
	"strings"
	"testing"
	"unicode"

	"pgregory.net/rapid"
)

// PasswordValidator validates password strength
type PasswordValidator struct {
	MinLength      int
	RequireUpper   bool
	RequireLower   bool
	RequireNumber  bool
	RequireSpecial bool
}

// NewPasswordValidator creates a new password validator with default settings
func NewPasswordValidator() *PasswordValidator {
	return &PasswordValidator{
		MinLength:      8,
		RequireUpper:   true,
		RequireLower:   true,
		RequireNumber:  true,
		RequireSpecial: true,
	}
}

// Validate checks if a password meets the requirements
func (v *PasswordValidator) Validate(password string) (bool, []string) {
	var issues []string

	if len(password) < v.MinLength {
		issues = append(issues, "password too short")
	}

	if v.RequireUpper && !containsUpper(password) {
		issues = append(issues, "missing uppercase letter")
	}

	if v.RequireLower && !containsLower(password) {
		issues = append(issues, "missing lowercase letter")
	}

	if v.RequireNumber && !containsNumber(password) {
		issues = append(issues, "missing number")
	}

	if v.RequireSpecial && !containsSpecial(password) {
		issues = append(issues, "missing special character")
	}

	return len(issues) == 0, issues
}

// Helper functions
func containsUpper(s string) bool {
	for _, r := range s {
		if unicode.IsUpper(r) {
			return true
		}
	}
	return false
}

func containsLower(s string) bool {
	for _, r := range s {
		if unicode.IsLower(r) {
			return true
		}
	}
	return false
}

func containsNumber(s string) bool {
	for _, r := range s {
		if unicode.IsNumber(r) {
			return true
		}
	}
	return false
}

func containsSpecial(s string) bool {
	for _, r := range s {
		if !unicode.IsLetter(r) && !unicode.IsNumber(r) && !unicode.IsSpace(r) {
			return true
		}
	}
	return false
}

// TestPasswordValidatorProperties demonstrates property-based testing func TestPasswordValidatorProperties(t *testing.T) { validator := NewPasswordValidator()

// Property: A valid password should pass validation
t.Run("valid password passes", func(t *testing.T) {
	rapid.Check(t, func(t *rapid.T) {
		// Generate a valid password
		length := rapid.IntRange(8, 100).Draw(t, "length")
		hasUpper := true
		hasLower := true
		hasNumber := true
		hasSpecial := true

		password := generatePassword(t, length, hasUpper, hasLower, hasNumber, hasSpecial)

		// Verify the property
		valid, issues := validator.Validate(password)
		if !valid {
			t.Fatalf("Expected valid password, but got issues: %v for password: %s", issues, password)
		}
	})
})

// Property: A password missing uppercase should fail validation
t.Run("password without uppercase fails", func(t *testing.T) {
	rapid.Check(t, func(t *rapid.T) {
		// Generate a password without uppercase
		length := rapid.IntRange(8, 100).Draw(t, "length")
		hasUpper := false
		hasLower := true
		hasNumber := true
		hasSpecial := true

		password := generatePassword(t, length, hasUpper, hasLower, hasNumber, hasSpecial)

		// Verify the property
		valid, issues := validator.Validate(password)
		if valid {
			t.Fatalf("Expected invalid password, but it passed validation: %s", password)
		}
		if !containsIssue(issues, "missing uppercase") {
			t.Fatalf("Expected 'missing uppercase' issue, but got: %v", issues)
		}
	})
})

// Property: A password missing lowercase should fail validation
t.Run("password without lowercase fails", func(t *rapid.T) {
	rapid.Check(t, func(t *rapid.T) {
		// Generate a password without lowercase
		length := rapid.IntRange(8, 100).Draw(t, "length")
		hasUpper := true
		hasLower := false
		hasNumber := true
		hasSpecial := true

		password := generatePassword(t, length, hasUpper, hasLower, hasNumber, hasSpecial)

		// Verify the property
		valid, issues := validator.Validate(password)
		if valid {
			t.Fatalf("Expected invalid password, but it passed validation: %s", password)
		}
		if !containsIssue(issues, "missing lowercase") {
			t.Fatalf("Expected 'missing lowercase' issue, but got: %v", issues)
		}
	})
})

// Property: A password shorter than minimum length should fail validation
t.Run("short password fails", func(t *rapid.T) {
	rapid.Check(t, func(t *rapid.T) {
		// Generate a short password
		length := rapid.IntRange(1, 7).Draw(t, "length")
		hasUpper := true
		hasLower := true
		hasNumber := true
		hasSpecial := true

		password := generatePassword(t, length, hasUpper, hasLower, hasNumber, hasSpecial)

		// Verify the property
		valid, issues := validator.Validate(password)
		if valid {
			t.Fatalf("Expected invalid password, but it passed validation: %s", password)
		}
		if !containsIssue(issues, "too short") {
			t.Fatalf("Expected 'too short' issue, but got: %v", issues)
		}
	})
})

// Property: Validation requirements should be configurable
t.Run("configurable validator", func(t *rapid.T) {
	rapid.Check(t, func(t *rapid.T) {
		// Create a custom validator
		customValidator := &PasswordValidator{
			MinLength:      rapid.IntRange(5, 15).Draw(t, "minLength"),
			RequireUpper:   rapid.Bool().Draw(t, "requireUpper"),
			RequireLower:   rapid.Bool().Draw(t, "requireLower"),
			RequireNumber:  rapid.Bool().Draw(t, "requireNumber"),
			RequireSpecial: rapid.Bool().Draw(t, "requireSpecial"),
		}

		// Generate a password that meets all possible requirements
		password := generatePassword(t, 20, true, true, true, true)

		// Verify the property
		valid, _ := customValidator.Validate(password)
		if !valid && len(password) >= customValidator.MinLength {
			t.Fatalf("Expected valid password for relaxed validator, but it failed: %s", password)
		}
	})
})

}

// Helper function to generate passwords with specific characteristics func generatePassword(t *rapid.T, length int, hasUpper, hasLower, hasNumber, hasSpecial bool) string { var chars []rune var builder strings.Builder

// Ensure we have at least one of each required character type
if hasUpper {
	chars = append(chars, rapid.RuneFrom([]rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ")).Draw(t, "upper"))
}
if hasLower {
	chars = append(chars, rapid.RuneFrom([]rune("abcdefghijklmnopqrstuvwxyz")).Draw(t, "lower"))
}
if hasNumber {
	chars = append(chars, rapid.RuneFrom([]rune("0123456789")).Draw(t, "number"))
}
if hasSpecial {
	chars = append(chars, rapid.RuneFrom([]rune("!@#$%^&*()_+-=[]{}|;:,.<>?")).Draw(t, "special"))
}

// Fill the rest with allowed characters
for len(chars) < length {
	var char rune
	switch rapid.IntRange(0, 3).Draw(t, "charType") {
	case 0:
		if hasUpper {
			char = rapid.RuneFrom([]rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ")).Draw(t, "fillUpper")
		}
	case 1:
		if hasLower {
			char = rapid.RuneFrom([]rune("abcdefghijklmnopqrstuvwxyz")).Draw(t, "fillLower")
		}
	case 2:
		if hasNumber {
			char = rapid.RuneFrom([]rune("0123456789")).Draw(t, "fillNumber")
		}
	case 3:
		if hasSpecial {
			char = rapid.RuneFrom([]rune("!@#$%^&*()_+-=[]{}|;:,.<>?")).Draw(t, "fillSpecial")
		}
	}
	if char != 0 {
		chars = append(chars, char)
	}
}

// Shuffle the characters
rapid.Shuffle(t, chars)

// Build the password
for _, c := range chars {
	builder.WriteRune(c)
}

return builder.String()

}

// Helper function to check if an issue is in the list func containsIssue(issues []string, substring string) bool { for _, issue := range issues { if strings.Contains(issue, substring) { return true } } return false }


This property-based test demonstrates:

1. **Property verification**: Testing that code satisfies general properties
2. **Random input generation**: Creating diverse test inputs automatically
3. **Shrinking**: Finding the simplest failing case when a test fails
4. **Comprehensive coverage**: Testing edge cases that might be missed in traditional tests
5. **Configurable generators**: Creating custom input generators for domain-specific testing

#### Fuzz Testing with Go's Built-in Fuzzer

Go 1.18+ includes built-in support for fuzz testing:

```go
package fuzz

import (
	"testing"
	"unicode/utf8"
)

// ReverseString reverses a UTF-8 string
func ReverseString(s string) string {
	// Convert string to runes to handle multi-byte characters correctly
	runes := []rune(s)
	
	// Reverse the runes
	for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
		runes[i], runes[j] = runes[j], runes[i]
	}
	
	// Convert back to string
	return string(runes)
}

// TestReverseString is a traditional unit test
func TestReverseString(t *testing.T) {
	testCases := []struct {
		name     string
		input    string
		expected string
	}{
		{"empty string", "", ""},
		{"single character", "a", "a"},
		{"simple string", "hello", "olleh"},
		{"with spaces", "hello world", "dlrow olleh"},
		{"palindrome", "racecar", "racecar"},
		{"unicode", "你好世界", "界世好你"},
		{"mixed characters", "hello世界", "界世olleh"},
	}
	
	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			result := ReverseString(tc.input)
			if result != tc.expected {
				t.Errorf("ReverseString(%q) = %q, expected %q", tc.input, result, tc.expected)
			}
		})
	}
}

// FuzzReverseString is a fuzz test that verifies properties of string reversal
func FuzzReverseString(f *testing.F) {
	// Seed corpus with some interesting values
	seeds := []string{"", "a", "ab", "hello", "hello world", "racecar", "你好世界", "hello世界"}
	for _, seed := range seeds {
		f.Add(seed)
	}
	
	// Fuzz test function
	f.Fuzz(func(t *testing.T, s string) {
		// Skip invalid UTF-8 strings
		if !utf8.ValidString(s) {
			t.Skip("Skipping invalid UTF-8 string")
		}
		
		// Property 1: Reversing twice should return the original string
		reversed := ReverseString(s)
		doubleReversed := ReverseString(reversed)
		if s != doubleReversed {
			t.Errorf("ReverseString(ReverseString(%q)) = %q, expected %q", s, doubleReversed, s)
		}
		
		// Property 2: Length should be preserved
		if len([]rune(s)) != len([]rune(reversed)) {
			t.Errorf("len([]rune(%q)) = %d, len([]rune(%q)) = %d", s, len([]rune(s)), reversed, len([]rune(reversed)))
		}
		
		// Property 3: Character frequency should be preserved
		if !sameCharFrequency(s, reversed) {
			t.Errorf("Character frequency differs: %q vs %q", s, reversed)
		}
	})
}

// Helper function to check if two strings have the same character frequency
func sameCharFrequency(s1, s2 string) bool {
	freq1 := make(map[rune]int)
	freq2 := make(map[rune]int)
	
	for _, r := range s1 {
		freq1[r]++
	}
	
	for _, r := range s2 {
		freq2[r]++
	}
	
	if len(freq1) != len(freq2) {
		return false
	}
	
	for r, count := range freq1 {
		if freq2[r] != count {
			return false
		}
	}
	
	return true
}

To run the fuzz test:

# Run the fuzz test for a short time
go test -fuzz=FuzzReverseString -fuzztime=10s

# Run the fuzz test with a specific seed
go test -fuzz=FuzzReverseString -fuzztime=10s -seed=1234

# Run the fuzz test with a specific corpus entry
go test -run=FuzzReverseString/12345

This fuzz testing approach demonstrates:

  1. Automated input generation: Generating diverse test inputs automatically
  2. Property verification: Testing invariants that should hold for all inputs
  3. Corpus management: Building and maintaining a corpus of interesting inputs
  4. Crash reproduction: Automatically saving and replaying failing inputs
  5. Integration with Go tooling: Using built-in Go fuzzing support

Combining Property-Based Testing with Fuzzing

For maximum coverage, combine property-based testing with fuzzing:

package combined

import (
	"fmt"
	"testing"
	"unicode/utf8"

	"pgregory.net/rapid"
)

// JSONEscaper escapes special characters in JSON strings
type JSONEscaper struct {
	EscapeUnicode bool
}

// Escape escapes special characters in a string for JSON
func (e *JSONEscaper) Escape(s string) string {
	var result []rune
	
	for _, r := range s {
		switch r {
		case '"':
			result = append(result, '\\', '"')
		case '\\':
			result = append(result, '\\', '\\')
		case '/':
			result = append(result, '\\', '/')
		case '\b':
			result = append(result, '\\', 'b')
		case '\f':
			result = append(result, '\\', 'f')
		case '\n':
			result = append(result, '\\', 'n')
		case '\r':
			result = append(result, '\\', 'r')
		case '\t':
			result = append(result, '\\', 't')
		default:
			if e.EscapeUnicode && r > 127 {
				// Escape as \uXXXX
				result = append(result, []rune(fmt.Sprintf("\\u%04x", r))...)
			} else {
				result = append(result, r)
			}
		}
	}
	
	return string(result)
}

// Unescape unescapes a JSON string
func (e *JSONEscaper) Unescape(s string) (string, error) {
	var result []rune
	runes := []rune(s)
	
	for i := 0; i < len(runes); i++ {
		if runes[i] == '\\' && i+1 < len(runes) {
			i++
			switch runes[i] {
			case '"':
				result = append(result, '"')
			case '\\':
				result = append(result, '\\')
			case '/':
				result = append(result, '/')
			case 'b':
				result = append(result, '\b')
			case 'f':
				result = append(result, '\f')
			case 'n':
				result = append(result, '\n')
			case 'r':
				result = append(result, '\r')
			case 't':
				result = append(result, '\t')
			case 'u':
				if i+4 < len(runes) {
					var codePoint int
					_, err := fmt.Sscanf(string(runes[i+1:i+5]), "%04x", &codePoint)
					if err != nil {
						return "", fmt.Errorf("invalid unicode escape: %s", string(runes[i-1:i+5]))
					}
					result = append(result, rune(codePoint))
					i += 4
				} else {
					return "", fmt.Errorf("incomplete unicode escape")
				}
			default:
				return "", fmt.Errorf("invalid escape sequence: \\%c", runes[i])
			}
		} else {
			result = append(result, runes[i])
		}
	}
	
	return string(result), nil
}

// TestJSONEscaperProperties demonstrates property-based testing
func TestJSONEscaperProperties(t *testing.T) {
	t.Run("escape-unescape roundtrip", func(t *testing.T) {
		rapid.Check(t, func(t *rapid.T) {
			// Generate a valid UTF-8 string
			s := rapid.StringN(0, 100, -1).Filter(utf8.ValidString).Draw(t, "input")
			
			// Create escaper
			escapeUnicode := rapid.Bool().Draw(t, "escapeUnicode")
			escaper := &JSONEscaper{EscapeUnicode: escapeUnicode}
			
			// Escape and then unescape
			escaped := escaper.Escape(s)
			unescaped, err := escaper.Unescape(escaped)
			
			// Verify properties
			if err != nil {
				t.Fatalf("Failed to unescape: %v", err)
			}
			if unescaped != s {
				t.Fatalf("Roundtrip failed: %q -> %q -> %q", s, escaped, unescaped)
			}
		})
	})
}

// FuzzJSONEscaper is a fuzz test for the JSONEscaper
func FuzzJSONEscaper(f *testing.F) {
	// Seed corpus
	seeds := []string{"", "hello", "\"quoted\"", "line\nbreak", "tab\there", "unicode: 你好"}
	for _, seed := range seeds {
		f.Add(seed, true)  // With Unicode escaping
		f.Add(seed, false) // Without Unicode escaping
	}
	
	f.Fuzz(func(t *testing.T, s string, escapeUnicode bool) {
		// Skip invalid UTF-8
		if !utf8.ValidString(s) {
			t.Skip("Invalid UTF-8")
		}
		
		escaper := &JSONEscaper{EscapeUnicode: escapeUnicode}
		
		// Test escape-unescape roundtrip
		escaped := escaper.Escape(s)
		unescaped, err := escaper.Unescape(escaped)
		
		if err != nil {
			t.Fatalf("Failed to unescape: %v", err)
		}
		if unescaped != s {
			t.Fatalf("Roundtrip failed: %q -> %q -> %q", s, escaped, unescaped)
		}
		
		// Verify that escaped string doesn't contain unescaped special characters
		for _, r := range escaped {
			switch r {
			case '"', '\b', '\f', '\n', '\r', '\t':
				t.Fatalf("Escaped string contains unescaped special character: %q", escaped)
			}
		}
		
		// Verify Unicode escaping behavior
		if escapeUnicode {
			for _, r := range s {
				if r > 127 {
					// Check that the escaped string contains the Unicode escape sequence
					escapeSeq := fmt.Sprintf("\\u%04x", r)
					if !contains(escaped, escapeSeq) {
						t.Fatalf("Unicode character %q not properly escaped in %q", r, escaped)
					}
				}
			}
		}
	})
}

// Helper function to check if a string contains a substring
func contains(s, substr string) bool {
	for i := 0; i <= len(s)-len(substr); i++ {
		if s[i:i+len(substr)] == substr {
			return true
		}
	}
	return false
}

This combined approach demonstrates:

  1. Comprehensive testing: Using both property-based testing and fuzzing
  2. Shared properties: Testing the same invariants with different techniques
  3. Complementary strengths: Property tests for structured inputs, fuzzing for edge cases
  4. Seed corpus sharing: Using similar seed values for both approaches
  5. Focused verification: Testing specific properties with each technique