As developers, we often face the challenge of testing functions with complex business logic and numerous parameters. How can we be sure we've covered all the tricky edge cases and interactions between different inputs? Manually writing these tests is tedious, and a simple brute-force approach can lead to a combinatorial explosion of test cases.
This is where a smarter approach, like using a Genetic Algorithm (GA), can be a game-changer. In this article, I'll walk through a project that uses a GA to automatically generate a concise and effective set of test cases for a complex e-commerce pricing function, explaining the core logic step-by-step.
The System Under Test: A Complex Pricing Function
To demonstrate the power of the GA, we need a sufficiently complex function. In my experiment, I used a Python function that calculates the final price of a product. It involves various rules: percentage discounts, flat-rate discounts, "Buy One, Get One" (BOGO) offers, membership-level bonuses, and promotional codes.
Here is the signature and some of the validation logic:
def calculate_final_price(
base_price,
discount_type,
discount_value,
membership_level,
cart_total,
promo_code,
product_category
):
# --- A few example validation checks ---
if base_price <= 0:
raise ValueError("Invalid base price.")
if discount_type == 'flat' and discount_value >= base_price:
raise ValueError("Flat discount too high.")
if promo_code == 'SAVE10' and cart_total < 100:
raise ValueError("Promo code SAVE10 requires $100+ cart.")
if discount_type == 'bogo' and product_category not in ['apparel', 'accessories']:
raise ValueError("BOGO only valid for apparel and accessories.")
# ... and many more ...
final_price = base_price
# ... apply discounts based on logic ...
return round(final_price, 2)
Generating test cases for this is difficult. A 'bogo' discount is only valid for 'apparel', and the 'SAVE10' promo code requires a cart_total over $100. A random approach would generate mostly invalid inputs that raise ValueError, failing to test the actual pricing logic. Our goal is to find inputs that pass these checks.
Evolving Valid Test Cases with a Genetic Algorithm
Instead of generating inputs blindly, we can use a GA to "evolve" a population of test cases. The algorithm learns the function's rules and produces a set of "fit" individuals that successfully exercise the logic. Let's break down the code.
Step 1: Defining "Fitness"
First, we need to quantify how "good" a test case is. In our scenario, a test case is good if it's valid—meaning it doesn't cause the function to raise an exception. We create a fitness function that rewards these valid inputs.
def fitness(test_case):
try:
# Attempt to run the function with the given test case
calculate_final_price(*test_case)
# If it succeeds, the test case is valid and "fit"
return 10
except ValueError as e:
# If it fails, the test case is invalid and "unfit"
return 1
This is the core of our "learning" process. By giving a higher score (10) to test cases that run successfully, we tell the algorithm which individuals should be prioritized for creating the next generation.
Interestingly, if we flip the fitness logic by setting a higher score for errors, we get output test cases that are the best at failing!
Step 2: The Genetic Operators - Crossover and Mutation
Genetic Algorithms are inspired by evolution. We need mechanisms for "reproduction" (crossover) and random change (mutation).
Crossover combines two successful parent test cases to create a new "child." Our implementation uses uniform crossover: for each parameter, it randomly chooses the value from one of the two parents. This allows good traits (valid parameter combinations) from different parents to be mixed.
def crossover(p1, p2):
# For each parameter, randomly choose the value from parent 1 or parent 2
return [random.choice(x) for x in zip(p1, p2)]
Mutation introduces new genetic material into the population, preventing it from getting stuck. Our mutate function takes a test case and replaces one of its parameters with a new, completely random value. This helps explore new possibilities that crossover alone might not discover.
def mutate(tc):
# Pick a random parameter index to change
i = random.randint(0, len(tc)-1)
# Replace it with a new, fully random value for that parameter type
tc[i] = random_test_case()[i]
return tc
Step 3: The evolve Loop - Simulating Natural Selection
The evolve function orchestrates the entire process, running the simulation for a set number of generations.
def evolve(pop_size=50, generations=10):
# 1. Initialize a random population
population = [random_test_case() for _ in range(pop_size)]
for _ in range(generations):
# 2. Score each individual in the population
scored = [(fitness(tc), tc) for tc in population]
# 3. Sort by fitness (fittest first)
scored.sort(reverse=True)
# 4. Select the top 50% (elitism) to be the parents
population = [tc for _, tc in scored[:pop_size//2]]
# 5. Generate new children to replenish the population
children = []
while len(children) < pop_size:
# Pick two random parents from the elite group
p1, p2 = random.sample(population, 2)
child = crossover(p1, p2)
# 30% chance to mutate the child
if random.random() < 0.3:
child = mutate(child)
if child not in children:
children.append(child)
population = children
# Finally, return the 10 fittest individuals found
return sorted([(fitness(tc), tc) for tc in population], reverse=True)[:10]
Over several generations, the population gets progressively better. Unfit individuals that cause errors are weeded out, while fit individuals are combined and mutated to discover new, valid test cases that explore the function's different logical paths.
The Result: A Curated Set of Intelligent Tests
After the evolution is complete, we get a small, curated list of high-quality test cases. These aren't just random inputs; they are diverse, valid scenarios that the GA discovered on its own. They represent a smart, automatically generated test suite for our complex function.
This project demonstrates that GAs can be a powerful tool for search-based software testing. By intelligently navigating the vast search space of possible inputs, they can deliver a concise suite of tests that are tailored to the specific logic of the code.
The full implementation can be found in the Jupyter Notebook on GitHub. Dive in and see the evolution in action!
Top comments (0)