Prerequisites
- Basic understanding of programming concepts ๐
- Python installation (3.8+) ๐
- VS Code or preferred IDE ๐ป
What you'll learn
- Understand the concept fundamentals ๐ฏ
- Apply the concept in real projects ๐๏ธ
- Debug common issues ๐
- Write clean, Pythonic code โจ
๐ฏ Introduction
Welcome to this exciting tutorial on mutation testing! ๐ Have you ever wondered if your tests are actually testing what they should? Or if they would catch real bugs? Thatโs where mutation testing comes in!
Youโll discover how mutation testing can transform your testing strategy by literally โmutatingโ your code to see if your tests catch the changes. Itโs like hiring a mischievous gremlin ๐น to break your code on purpose! Whether youโre building web applications ๐, data pipelines ๐, or libraries ๐, understanding mutation testing is essential for writing robust, reliable tests.
By the end of this tutorial, youโll be a mutation testing ninja! Letโs dive in! ๐โโ๏ธ
๐ Understanding Mutation Testing
๐ค What is Mutation Testing?
Mutation testing is like playing a game of โspot the differenceโ with your code! ๐ฎ Think of it as a quality inspector for your tests. It makes small changes (mutations) to your code and checks if your tests notice somethingโs wrong.
In Python terms, mutation testing automatically changes your code in small ways (like turning +
into -
or >
into <
) and runs your tests. If your tests still pass when the code is broken, youโve found a weakness in your test suite! This means you can:
- โจ Find gaps in your test coverage
- ๐ Improve test quality, not just quantity
- ๐ก๏ธ Build confidence in your test suite
๐ก Why Use Mutation Testing?
Hereโs why developers love mutation testing:
- Test Quality Assurance ๐: Ensures your tests actually test something
- Find Hidden Bugs ๐ป: Discovers untested edge cases
- Better Coverage ๐: Goes beyond line coverage to behavior coverage
- Refactoring Confidence ๐ง: Know your tests will catch regressions
Real-world example: Imagine testing a shopping cart ๐. Your tests might check if items are added, but do they check if the total is calculated correctly when thereโs a discount? Mutation testing will reveal these gaps!
๐ง Basic Syntax and Usage
๐ Simple Example with mutmut
Letโs start with a friendly example using mutmut
, a popular Python mutation testing tool:
# ๐ First, let's install mutmut
# pip install mutmut
# ๐จ Here's our simple calculator to test
# calculator.py
class Calculator:
def add(self, a, b):
# โ Simple addition
return a + b
def multiply(self, a, b):
# โ๏ธ Simple multiplication
return a * b
def is_positive(self, number):
# ๐ฏ Check if positive
return number > 0
Now letโs write some tests:
# ๐งช test_calculator.py
import pytest
from calculator import Calculator
def test_add():
# ๐ฏ Testing addition
calc = Calculator()
assert calc.add(2, 3) == 5
assert calc.add(-1, 1) == 0
def test_multiply():
# โจ Testing multiplication
calc = Calculator()
assert calc.multiply(3, 4) == 12
def test_is_positive():
# ๐ Testing positive check
calc = Calculator()
assert calc.is_positive(5) == True
assert calc.is_positive(-5) == False
# โ ๏ธ What about zero? Let's see if mutmut catches this!
๐ก Explanation: Notice how our is_positive
test is missing the edge case for zero! Mutation testing will catch this.
๐ฏ Running Mutation Tests
Hereโs how to run mutation testing:
# ๐ Run mutmut on your code
mutmut run --paths-to-mutate calculator.py
# ๐ View the results
mutmut results
# ๐ Show specific mutations that survived
mutmut show 1
๐ก Practical Examples
๐ Example 1: E-commerce Price Calculator
Letโs build something real:
# ๐๏ธ price_calculator.py
class PriceCalculator:
def __init__(self):
self.tax_rate = 0.08 # 8% tax
self.discount_threshold = 100 # $100 for discount
def calculate_total(self, items):
# ๐ฐ Calculate subtotal
subtotal = sum(item['price'] * item['quantity'] for item in items)
# ๐ Apply discount if eligible
if subtotal >= self.discount_threshold:
subtotal *= 0.9 # 10% discount
# ๐ Add tax
total = subtotal * (1 + self.tax_rate)
return round(total, 2)
def validate_item(self, item):
# โ
Validate item data
if item['price'] <= 0:
raise ValueError("Price must be positive! ๐ธ")
if item['quantity'] < 1:
raise ValueError("Quantity must be at least 1! ๐ฆ")
return True
# ๐งช test_price_calculator.py
import pytest
from price_calculator import PriceCalculator
def test_calculate_total_no_discount():
# ๐ Test without discount
calc = PriceCalculator()
items = [
{'price': 20, 'quantity': 2}, # $40
{'price': 30, 'quantity': 1} # $30
]
# Total: $70 + 8% tax = $75.60
assert calc.calculate_total(items) == 75.60
def test_calculate_total_with_discount():
# ๐ Test with discount
calc = PriceCalculator()
items = [
{'price': 50, 'quantity': 3} # $150
]
# Subtotal: $150, Discount: $135, Tax: $145.80
assert calc.calculate_total(items) == 145.80
def test_validate_item():
# โ
Test validation
calc = PriceCalculator()
# Valid item
assert calc.validate_item({'price': 10, 'quantity': 1}) == True
# Invalid price
with pytest.raises(ValueError):
calc.validate_item({'price': 0, 'quantity': 1})
๐ฏ Mutation Testing Results: When we run mutmut, it might find:
- What if we change
>=
to>
in discount threshold? - What if we change
0.9
to0.8
for discount? - What if we remove the
round()
function?
๐ฎ Example 2: Game Score Validator
Letโs test a more complex example:
# ๐ game_scorer.py
class GameScorer:
def __init__(self):
self.max_score = 1000
self.bonus_threshold = 500
self.multiplier_levels = {
'bronze': 1.0,
'silver': 1.5,
'gold': 2.0
}
def calculate_final_score(self, base_score, level, combo_count):
# ๐ฏ Validate input
if base_score < 0 or base_score > self.max_score:
raise ValueError("Invalid base score! ๐ซ")
# ๐ฎ Apply level multiplier
multiplier = self.multiplier_levels.get(level, 1.0)
score = base_score * multiplier
# ๐ฅ Apply combo bonus
if combo_count > 5:
score += combo_count * 10
# ๐ Apply threshold bonus
if score >= self.bonus_threshold:
score *= 1.1
return int(score)
def get_rank(self, score):
# ๐
Determine player rank
if score >= 900:
return "๐ Master"
elif score >= 700:
return "๐ฅ Expert"
elif score >= 500:
return "๐ฅ Advanced"
elif score >= 300:
return "๐ฅ Intermediate"
else:
return "๐ Beginner"
# ๐งช test_game_scorer.py
def test_calculate_final_score():
scorer = GameScorer()
# ๐ฎ Test basic scoring
assert scorer.calculate_final_score(100, 'bronze', 0) == 100
assert scorer.calculate_final_score(100, 'gold', 0) == 200
# ๐ฅ Test combo bonus
assert scorer.calculate_final_score(100, 'bronze', 10) == 200
# ๐ Test threshold bonus
assert scorer.calculate_final_score(500, 'bronze', 0) == 550
def test_get_rank():
scorer = GameScorer()
assert scorer.get_rank(950) == "๐ Master"
assert scorer.get_rank(600) == "๐ฅ Advanced"
assert scorer.get_rank(100) == "๐ Beginner"
๐ Advanced Concepts
๐งโโ๏ธ Using pytest-mutagen
For more advanced mutation testing, try pytest-mutagen
:
# ๐ฏ Install pytest-mutagen
# pip install pytest-mutagen
# ๐ง Advanced example with custom mutations
from mutagen import mutate
class AdvancedCalculator:
@mutate('arithmetic') # ๐จ Mark for arithmetic mutations
def complex_calculation(self, x, y, z):
# ๐งฎ Complex formula
result = (x + y) * z
if result > 100:
result = result - 10
return result / 2
@mutate('comparison') # ๐ Mark for comparison mutations
def validate_range(self, value, min_val, max_val):
# ๐ Range validation
return min_val <= value <= max_val
๐๏ธ Custom Mutation Operators
Create your own mutation operators:
# ๐ Custom mutation configuration
# .mutmut.toml
[mutmut]
paths_to_mutate = ["src/"]
tests_dir = "tests/"
dict_synonyms = ["dict", "OrderedDict"]
# ๐ช Custom mutations for your domain
[mutmut.custom_mutations]
# Change timeout values
timeout_mutations = [
["timeout=30", "timeout=1"],
["timeout=60", "timeout=0.1"]
]
# Change retry counts
retry_mutations = [
["max_retries=3", "max_retries=0"],
["max_retries=5", "max_retries=10"]
]
โ ๏ธ Common Pitfalls and Solutions
๐ฑ Pitfall 1: Testing Only Happy Paths
# โ Wrong way - only testing success cases
def test_divide():
calc = Calculator()
assert calc.divide(10, 2) == 5
# ๐ฅ What about division by zero?
# โ
Correct way - test edge cases too!
def test_divide_complete():
calc = Calculator()
assert calc.divide(10, 2) == 5
assert calc.divide(-10, 2) == -5
assert calc.divide(0, 5) == 0
# ๐ก๏ธ Test error cases
with pytest.raises(ZeroDivisionError):
calc.divide(10, 0)
๐คฏ Pitfall 2: Weak Assertions
# โ Dangerous - assertion too weak
def test_user_creation():
user = create_user("Alice", 25)
assert user is not None # ๐ฐ This passes even if user is wrong!
# โ
Safe - specific assertions
def test_user_creation_strong():
user = create_user("Alice", 25)
assert user.name == "Alice" # โ
Specific check
assert user.age == 25 # โ
Another specific check
assert user.id is not None # โ
Still check existence
assert isinstance(user.id, int) # โ
Type check too!
๐ ๏ธ Best Practices
- ๐ฏ Start Small: Begin with critical business logic
- ๐ Fix Surviving Mutants: Each survivor is a test improvement opportunity
- ๐ก๏ธ Balance Coverage: Aim for 80%+ mutation coverage on critical code
- ๐จ Use in CI/CD: Run mutation tests in your pipeline
- โจ Focus on Quality: Better to have fewer, stronger tests
๐งช Hands-On Exercise
๐ฏ Challenge: Build a Password Validator with Mutation-Tested Suite
Create a robust password validator with comprehensive tests:
๐ Requirements:
- โ Minimum length of 8 characters
- ๐ค At least one uppercase and one lowercase letter
- ๐ข At least one number
- ๐จ At least one special character
- ๐ซ No common passwords (e.g., โpassword123โ)
๐ Bonus Points:
- Add password strength scoring
- Implement custom mutation tests
- Achieve 100% mutation coverage
๐ก Solution
๐ Click to see solution
# ๐ password_validator.py
import re
class PasswordValidator:
def __init__(self):
self.min_length = 8
self.common_passwords = {
'password123', 'qwerty123', 'admin123',
'letmein', 'welcome123'
}
def validate(self, password):
# ๐ฏ Check all requirements
errors = []
# ๐ Length check
if len(password) < self.min_length:
errors.append("Too short! Need 8+ characters ๐")
# ๐ค Uppercase check
if not re.search(r'[A-Z]', password):
errors.append("Need at least one uppercase letter ๐ค")
# ๐ก Lowercase check
if not re.search(r'[a-z]', password):
errors.append("Need at least one lowercase letter ๐ก")
# ๐ข Number check
if not re.search(r'\d', password):
errors.append("Need at least one number ๐ข")
# ๐จ Special character check
if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
errors.append("Need at least one special character ๐จ")
# ๐ซ Common password check
if password.lower() in self.common_passwords:
errors.append("That's too common! Be creative ๐ญ")
return len(errors) == 0, errors
def calculate_strength(self, password):
# ๐ช Calculate password strength
score = 0
# Length bonus
score += min(len(password) * 2, 20)
# Character variety bonus
if re.search(r'[A-Z]', password):
score += 10
if re.search(r'[a-z]', password):
score += 10
if re.search(r'\d', password):
score += 10
if re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
score += 20
# Complexity bonus
if len(set(password)) > 10:
score += 10
# Determine strength level
if score >= 70:
return "๐ช Strong", score
elif score >= 50:
return "๐ Good", score
elif score >= 30:
return "๐ Fair", score
else:
return "๐ Weak", score
# ๐งช test_password_validator.py
import pytest
from password_validator import PasswordValidator
class TestPasswordValidator:
def setup_method(self):
self.validator = PasswordValidator()
def test_valid_passwords(self):
# โ
Test valid passwords
valid_passwords = [
"MyP@ssw0rd!",
"Str0ng&Secure",
"C0mpl3x!Pass"
]
for password in valid_passwords:
is_valid, errors = self.validator.validate(password)
assert is_valid == True
assert len(errors) == 0
def test_invalid_passwords(self):
# โ Test various invalid cases
test_cases = [
("short", ["Too short!", "uppercase", "number", "special"]),
("nouppercase123!", ["uppercase"]),
("NOLOWERCASE123!", ["lowercase"]),
("NoNumbers!", ["number"]),
("NoSpecial123", ["special"]),
("password123", ["uppercase", "special", "common"])
]
for password, expected_issues in test_cases:
is_valid, errors = self.validator.validate(password)
assert is_valid == False
# Check that expected issues are found
for issue in expected_issues:
assert any(issue in error for error in errors)
def test_edge_cases(self):
# ๐ Test edge cases
# Exactly 8 characters
is_valid, _ = self.validator.validate("Pass@12!")
assert is_valid == True
# Just under 8 characters
is_valid, _ = self.validator.validate("Pass@1!")
assert is_valid == False
# Empty password
is_valid, errors = self.validator.validate("")
assert is_valid == False
assert len(errors) > 0
def test_strength_calculation(self):
# ๐ช Test strength scoring
test_cases = [
("weak", "๐ Weak"),
("Medium123", "๐ Fair"),
("G00dP@ssword", "๐ Good"),
("Sup3r$tr0ng!P@ssw0rd", "๐ช Strong")
]
for password, expected_level in test_cases:
level, score = self.validator.calculate_strength(password)
assert expected_level in level
# ๐ Run mutation testing
# mutmut run --paths-to-mutate password_validator.py
# mutmut results
๐ Key Takeaways
Youโve learned so much! Hereโs what you can now do:
- โ Understand mutation testing and why it matters ๐ช
- โ Use tools like mutmut to test your tests ๐ก๏ธ
- โ Identify weak test cases that need improvement ๐ฏ
- โ Write stronger assertions that catch real bugs ๐
- โ Build confidence in your test suite! ๐
Remember: Mutation testing isnโt about achieving 100% scoresโitโs about finding the tests that matter and making them stronger! ๐ค
๐ค Next Steps
Congratulations! ๐ Youโve mastered mutation testing!
Hereโs what to do next:
- ๐ป Try mutation testing on your current project
- ๐๏ธ Set up mutation testing in your CI/CD pipeline
- ๐ Learn about property-based testing (our next tutorial!)
- ๐ Share your mutation testing victories with your team!
Remember: The best test suite is one that actually catches bugs. Keep mutating, keep improving, and most importantly, have fun! ๐
Happy testing! ๐๐งชโจ