Fixture
A fixture is the fixed, known state a test is run against — the objects, data, and collaborators prepared during the Arrange phase before the behavior under test executes. A good fixture is minimal and explicit, so the reader can see exactly which preconditions matter to the test.
Many frameworks provide a mechanism for sharing fixture setup (for example, pytest fixtures or per-test setup hooks), but the goal is always the same: put the system into a predictable starting state without leaking that state between tests.
Note
Fixtures do not require first-hand support from the underlying testing framework; the idea is more abstract than that. More generally, fixtures are just test-helpers that setup the test in a clear and articulate way to convey the minimal details needed to convey the conditions of the test.Why it matters
A fixture is the premise of every assertion the test makes, so its shape decides how much the test can tell you. When the arrangement carries only the preconditions the behavior actually reads, a failure points straight at a known cause and the test doubles as a specification – the Arrange section is the list of conditions under which the result is expected to hold.
The failure mode is a fixture padded with detail that has nothing to do with the behavior under test. Every extra field and every incidental collaborator is a value the reader has to consider and then rule out, because nothing distinguishes the input the assertion depends on from the decoration around it. The test still passes, but it no longer documents anything – and when it breaks, you cannot tell whether the behavior regressed or an unrelated part of the setup drifted.
Keeping a fixture minimal and explicit is what avoids that. State the preconditions the test relies on where the test can see them, and push the incidental defaults behind a named builder so each test states only what makes its case different.
Example
Consider a pricing rule: Gold-tier customers get 10% off the order subtotal.
enum class Tier { Standard, Gold };
struct Customer {
std::string name;
std::string email;
std::string address;
Tier tier;
int member_since;
};
struct Item {
std::string name;
int price;
};
// Gold-tier customers get 10% off the subtotal.
auto order_total(const Customer& customer, const std::vector<Item>& items)
-> int {
int subtotal = 0;
for (const auto& item : items) {
subtotal += item.price;
}
return customer.tier == Tier::Gold ? subtotal * 9 / 10 : subtotal;
}
package orders
type Tier int
const (
Standard Tier = iota
Gold
)
type Customer struct {
Name string
Email string
Address string
Tier Tier
MemberSince int
}
type Item struct {
Name string
Price int
}
// OrderTotal gives Gold-tier customers 10% off the subtotal.
func OrderTotal(customer Customer, items []Item) int {
subtotal := 0
for _, item := range items {
subtotal += item.Price
}
if customer.Tier == Gold {
return subtotal * 9 / 10
}
return subtotal
}
import enum
from dataclasses import dataclass
class Tier(enum.Enum):
STANDARD = enum.auto()
GOLD = enum.auto()
@dataclass
class Customer:
name: str
email: str
address: str
tier: Tier
member_since: int
@dataclass
class Item:
name: str
price: int
def order_total(customer: Customer, items: list[Item]) -> int:
# Gold-tier customers get 10% off the subtotal.
subtotal = sum(item.price for item in items)
return subtotal * 9 // 10 if customer.tier is Tier.GOLD else subtotal
pub enum Tier {
Standard,
Gold,
}
pub struct Customer {
pub name: String,
pub email: String,
pub address: String,
pub tier: Tier,
pub member_since: u32,
}
pub struct Item {
pub name: String,
pub price: i64,
}
/// Gold-tier customers get 10% off the subtotal.
pub fn order_total(customer: &Customer, items: &[Item]) -> i64 {
let subtotal: i64 = items.iter().map(|item| item.price).sum();
match customer.tier {
Tier::Gold => subtotal * 9 / 10,
Tier::Standard => subtotal,
}
}
Before
A first cut at the test arranges a fully-populated customer and a realistic basket:
TEST_CASE("Gold customers get 10% off") {
// Arrange
Customer customer{
"Ada Lovelace", "ada@example.com", "12 Baker St", Tier::Gold, 2019};
std::vector<Item> items{
{"Mechanical Keyboard", 70},
{"USB-C Cable", 15},
{"Mouse Pad", 15},
};
// Act
int total = order_total(customer, items);
// Assert
REQUIRE(total == 90);
}
func TestOrderTotal_GoldDiscount(t *testing.T) {
t.Parallel()
// Arrange
customer := Customer{
Name: "Ada Lovelace",
Email: "ada@example.com",
Address: "12 Baker St",
Tier: Gold,
MemberSince: 2019,
}
items := []Item{
{Name: "Mechanical Keyboard", Price: 70},
{Name: "USB-C Cable", Price: 15},
{Name: "Mouse Pad", Price: 15},
}
// Act
total := OrderTotal(customer, items)
// Assert
if got, want := total, 90; got != want {
t.Errorf("OrderTotal() = %d, want %d", got, want)
}
}
def test_gold_customer_gets_discount():
# Arrange
customer = Customer(
name="Ada Lovelace",
email="ada@example.com",
address="12 Baker St",
tier=Tier.GOLD,
member_since=2019,
)
items = [
Item(name="Mechanical Keyboard", price=70),
Item(name="USB-C Cable", price=15),
Item(name="Mouse Pad", price=15),
]
# Act
total = order_total(customer, items)
# Assert
assert total == 90
#[test]
fn gold_customer_gets_discount() {
// Arrange
let customer = Customer {
name: "Ada Lovelace".to_string(),
email: "ada@example.com".to_string(),
address: "12 Baker St".to_string(),
tier: Tier::Gold,
member_since: 2019,
};
let items = vec![
Item { name: "Mechanical Keyboard".to_string(), price: 70 },
Item { name: "USB-C Cable".to_string(), price: 15 },
Item { name: "Mouse Pad".to_string(), price: 15 },
];
// Act
let total = order_total(&customer, &items);
// Assert
assert_eq!(total, 90);
}
The test passes, but nothing in it says which inputs the 90 depends on. A
reader has to know that the name, email, address, and membership year are all
irrelevant, and that the three items matter only insofar as their prices sum to
100. The one precondition the behavior actually reads – Tier::Gold – is
buried in the middle of the constructor, and the expected discount is left for
the reader to reverse-engineer from the arithmetic.
After
The fixture strips the arrangement down to the preconditions the behavior reads, and moves the incidental defaults behind a named builder:
// A fixture builder: irrelevant fields get defaults.
auto a_customer(Tier tier) -> Customer {
return {"Test Customer", "test@example.com", "N/A", tier, 0};
}
TEST_CASE("Gold customers get 10% off") {
// Arrange
Customer customer = a_customer(Tier::Gold);
std::vector<Item> items{{"Widget", 100}};
// Act
int total = order_total(customer, items);
// Assert
REQUIRE(total == 90); // 10% off 100
}
// A fixture builder: irrelevant fields get defaults.
func aCustomer(tier Tier) Customer {
return Customer{
Name: "Test Customer",
Email: "test@example.com",
Address: "N/A",
Tier: tier,
MemberSince: 0,
}
}
func TestOrderTotal_GoldDiscount(t *testing.T) {
t.Parallel()
// Arrange
customer := aCustomer(Gold)
items := []Item{{Name: "Widget", Price: 100}}
// Act
total := OrderTotal(customer, items)
// Assert
if got, want := total, 90; got != want { // 10% off 100
t.Errorf("OrderTotal() = %d, want %d", got, want)
}
}
# A fixture builder: irrelevant fields get defaults.
def a_customer(tier: Tier) -> Customer:
return Customer(
name="Test Customer",
email="test@example.com",
address="N/A",
tier=tier,
member_since=0,
)
def test_gold_customer_gets_discount():
# Arrange
customer = a_customer(tier=Tier.GOLD)
items = [Item(name="Widget", price=100)]
# Act
total = order_total(customer, items)
# Assert
assert total == 90 # 10% off 100
// A fixture builder: irrelevant fields get defaults.
fn a_customer(tier: Tier) -> Customer {
Customer {
name: "Test Customer".to_string(),
email: "test@example.com".to_string(),
address: "N/A".to_string(),
tier,
member_since: 0,
}
}
#[test]
fn gold_customer_gets_discount() {
// Arrange
let customer = a_customer(Tier::Gold);
let items = vec![Item { name: "Widget".to_string(), price: 100 }];
// Act
let total = order_total(&customer, &items);
// Assert
assert_eq!(total, 90); // 10% off 100
}
Now the Arrange section reads as the specification of the case: a Gold-tier
customer and a subtotal of 100, and nothing else. a_customer owns the
defaults every test would otherwise repeat, so each test passes only the field
that makes its case different – here, the tier – and the round 100 makes the
expected 90 obvious at a glance. If this test fails, it can only be because the
Gold discount changed, which is exactly the signal a fixture should give.