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:
- Automated input generation: Generating diverse test inputs automatically
- Property verification: Testing invariants that should hold for all inputs
- Corpus management: Building and maintaining a corpus of interesting inputs
- Crash reproduction: Automatically saving and replaying failing inputs
- 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:
- Comprehensive testing: Using both property-based testing and fuzzing
- Shared properties: Testing the same invariants with different techniques
- Complementary strengths: Property tests for structured inputs, fuzzing for edge cases
- Seed corpus sharing: Using similar seed values for both approaches
- Focused verification: Testing specific properties with each technique