Give me an example of a miniature codebase that would be a good example to use to teach students about how to write unit tests?
Certainly! A simple calculator program is often a good choice to teach students about writing unit tests, as it involves clear, straightforward logic that's easy to follow and test. Here’s an example of such a codebase in Python along with some basic unit tests using unittest
, a built-in testing framework.
# calculator.py
def add(x, y):
return x + y
def subtract(x, y):
return x - y
def multiply(x, y):
return x * y
def divide(x, y):
if y == 0:
raise ValueError("Cannot divide by zero.")
return x / y
# test_calculator.py
import unittest
from calculator import add, subtract, multiply, divide
class TestCalculator(unittest.TestCase):
def test_add(self):
self.assertEqual(add(5, 3), 8)
self.assertEqual(add(-1, 1), 0)
self.assertEqual(add(-1, -1), -2)
def test_subtract(self):
self.assertEqual(subtract(10, 5), 5)
self.assertEqual(subtract(-1, 1), -2)
self.assertEqual(subtract(-1, -1), 0)
def test_multiply(self):
self.assertEqual(multiply(3, 7), 21)
self.assertEqual(multiply(-1, 3), -3)
self.assertEqual(multiply(0, 3), 0)
def test_divide(self):
self.assertEqual(divide(10, 2), 5)
self.assertRaises(ValueError, divide, 10, 0)
with self.assertRaises(ValueError):
divide(10, 0)
if __name__ == '__main__':
unittest.main()
You can run these tests by executing the test_calculator.py
script. This environment setup ensures students can learn how a function’s logic is translated into a test case and how different scenarios (like edge cases) are handled in unit testing.
- Understanding how to write basic functions.
- Writing unit tests using assertions to check for expected outcomes.
- Testing for exceptions in scenarios that could result in runtime errors.
- Integrating tests within a development workflow to check code before deployment.
No, it needs to be more involved than a calculator because with a calculator, the implementation and the test are the same calculation. I want to show some examples of property-based testing, such as invariants that are not manifest in the implementation, but would be in the tests. Also, I want to show the importance of not over-testing, to allow the codebase to grow in some ways while being fixed in others.
For a more complex example while introducing concepts of property-based testing and maintaining a balance in testing scope, consider a "String Manipulator" codebase. This example involves more complex operations than simple arithmetic, including rotations, reversals, and transformations, providing a nice platform to demonstrate invariants and properties in tests.
# string_manipulator.py
def reverse_string(s):
return s[::-1]
def capitalize_string(s):
return s.capitalize()
def add_prefix(s, prefix):
return f"{prefix}{s}"
def rotate_string(s, n):
n = n % len(s) # Prevent rotating more than string length
return s[n:] + s[:n]
Here, we'll use unittest
along with the hypothesis
library, which is designed for property-based testing in Python.
# test_string_manipulator.py
import unittest
from hypothesis import given, strategies as st
from string_manipulator import reverse_string, capitalize_string, add_prefix, rotate_string
class TestStringManipulator(unittest.TestCase):
@given(st.text())
def test_reverse_invariant(self, s):
# Reversing twice should give the original string
self.assertEqual(reverse_string(reverse_string(s)), s)
@given(st.text())
def test_capitalize_idempotent(self, s):
# Capitalizing twice should be the same as capitalizing once
self.assertEqual(capitalize_string(capitalize_string(s)), capitalize_string(s))
@given(st.text(), st.text())
def test_prefix_addition_length(self, s, prefix):
# The length after adding a prefix should be sum of both lengths
result = add_prefix(s, prefix)
self.assertEqual(len(result), len(s) + len(prefix))
@given(st.text(min_size=1), st.integers()) # Ensuring non-empty string for rotation to be meaningful
def test_rotation_invariant(self, s, n):
# A full rotation (len(s) times) should yield the original string
rotated = rotate_string(s, n)
self.assertEqual(rotate_string(rotated, len(s) - (n % len(s))), s)
if __name__ == '__main__':
unittest.main()
-
Property-Based Testing: Using properties or invariants, such as idempotence ("capitalizing twice is the same as capitalizing once") and specific behaviors after transformations (like reversing or rotating), which might not be directly apparent from just looking at the implementation.
-
Testing Invariants: Ensuring fundamental truths like "reversing a string twice returns the original string" hold, which are crucial in many practical applications beyond the original scope of the function.
-
Scope of Testing: The tests ensure correctness of specific behaviors without over-specifying how the implementation should achieve these results, thus allowing for internal implementation to change as long as the properties hold, illustrating how to avoid over-testing.
-
Usage of Hypothesis: Demonstrates using an external library to generate test cases based on defined strategies, which can be more effective and thorough compared to manually written static tests.
This approach not only covers more complex testing scenarios, but also introduces tools and techniques useful in modern testing practices, reflecting real-world software development complexities.
Maybe a simulation? So that we can use physics invariants, like (approximate) energy conservation?
A simulation-based example does lend itself very well to teaching testing concepts, specifically through the use of physics invariants like energy conservation. We'll consider a basic particle physics simulation where particles in a system interact with each other, and we'll test for conservation of energy and momentum throughout the simulation.
The simulation includes:
Particle
class with properties like mass, position, velocity.Simulation
class that manages multiple particles and updates their state.
import numpy as np
class Particle:
def __init__(self, mass, position, velocity):
self.mass = mass
self.position = np.array(position)
self.velocity = np.array(velocity)
def update_position(self, dt):
self.position += self.velocity * dt
from particle import Particle
class Simulation:
def __init__(self):
self.particles = []
def add_particle(self, particle):
self.particles.append(particle)
def update(self, dt):
for particle in self.particles:
particle.update_position(dt)
def total_energy(self):
# Simplified kinetic energy calculation: 0.5 * m * v^2
return sum(0.5 * p.mass * np.dot(p.velocity, p.velocity) for p in self.particles)
Using property-based testing to ensure that energy and momentum are conserved during simulations without external forces. We use unittest
framework and hypothesis for generating test cases:
import unittest
from hypothesis import given, strategies as st
from particle import Particle
from simulation import Simulation
import numpy as np
class TestSimulation(unittest.TestCase):
@given(st.lists(st.tuples(st.floats(min_value=0.1, max_value=10),
st.lists(st.floats(min_value=-100, max_value=100), min_size=3, max_size=3),
st.lists(st.floats(min_value=-100, max_value=100), min_size=3, max_size=3)),
min_size=1, max_size=10),
st.floats(min_value=0.01, max_value=1))
def test_energy_conservation(self, particles_data, dt):
# Initialize simulation with particles
sim = Simulation()
for mass, position, velocity in particles_data:
sim.add_particle(Particle(mass, position, velocity))
initial_energy = sim.total_energy()
# Small simulation steps to approximate continuity
sim.update(dt)
final_energy = sim.total_energy()
# Check if the initial and final energy are approximately equal
np.testing.assert_allclose(initial_energy, final_energy, atol=1e-5)
if __name__ == '__main__':
unittest.main()
-
Creating a Physics-Based Model: The simplified model considers kinetic energy of particles using their mass and velocity, handling changes in position over time.
-
Property-Based Testing in Physics: We harnessed physics invariants to create robust tests. The test for energy conservation generates random particles with random mass, position, and velocity. It ensures that despite these random configurations, the system adheres to the conservation of energy law after updating.
-
Use of Libraries: Utilized NumPy for numerical operations and Hypothesis for generating complex strategy-based test inputs, particularly useful for testing simulation-type applications.
This setup provides practical insights into testing simulations, emphasizing how well-designed unit tests can validate complex dynamics and invariants in a codebase, using physics rules as a fundamental guideline.