How to Turn Business Rules into Valid Test Data
How to Turn Business Rules into Valid Test Data
Most test data strategies fail at the same place: the gap between what the business says and what the database accepts. A product owner describes a discount rule—"15% off for loyalty members on Tuesdays"—and the QA team generates rows with discount_pct = 15 and day = 'Tuesday'. The test passes. Then production hits a case where the member joined last week, the order spans midnight UTC, and the discount engine applies 0% because the loyalty tier hasn't been recalculated yet.
The rule wasn't wrong. The test data just didn't represent the boundary where the rule actually lives.
This guide walks through a repeatable process for translating business rules into test data that exercises real logic, not just happy paths. It covers prerequisite analysis, implementation patterns, validation techniques, and the failure modes that show up when you skip steps.
The Core Problem: Rules Are Not Constraints
Business rules are behavioral specifications. Constraints are structural limitations. Test data generators—whether homegrown scripts, commercial tools, or QA3's free test data generator at /tools/test-data-generator—operate on constraints. They know NOT NULL, CHECK (age >= 18), and foreign key references. They do not know that "a customer cannot have two active subscriptions" unless that rule is encoded as a unique partial index or application-level validation.
The translation layer is your job.
When this layer is missing, three symptoms appear:
| Symptom | Root Cause |
|---|---|
| Tests pass but production bugs escape | Test data satisfies schema but violates business invariants |
| Data setup takes longer than test execution | Teams manually craft edge cases because generators can't express them |
| "Flaky" data-dependent tests | Implicit assumptions about rule evaluation order or timing |
The fix isn't better generators. It's a disciplined workflow for modeling rules as data shapes.
Prerequisites: What You Need Before Generating Anything
1. Rule Inventory (Not Requirements)
Don't start with the requirements document. Start with the executed rules. Pull from:
- Decision tables in the rules engine (Drools, Easy Rules, custom DSL)
- Validation services called from API endpoints
- Database triggers and check constraints that encode business logic
- Domain service unit tests that assert rule behavior
Create a lightweight catalog. For each rule, capture:
Rule ID: DISC-003
Description: Loyalty members receive 15% discount on Tuesdays
Trigger: Order placed
Conditions:
- customer.loyalty_tier IN ('GOLD', 'PLATINUM')
- order.placed_at.day_of_week == TUESDAY (store timezone)
- customer.loyalty_start_date <= order.placed_at - 30 days
Actions:
- Apply 15% discount to eligible line items
- Set discount_code = 'LOYALTY_TUE_15'
Exceptions:
- Stackable with promo codes? NO
- Applies to gift cards? NO
Minimum viable catalog fields: Rule ID, Trigger, Preconditions, Postconditions, Mutually Exclusive Rules, Data Dependencies.
2. Data Dependency Graph
Rules read and write entities. Map them:
Customer (1) ──< Order (N) ──< OrderLine (N)
│ │
└── LoyaltyTier ◄──────────────┘ (derived, recalculated nightly)
Identify:
- Derived fields (loyalty_tier, credit_score, risk_rating)
- Temporal dependencies (nightly batch, event-driven recalculation)
- Cross-entity invariants (one active subscription per customer)
3. Environment Reality Check
| Question | Why It Matters |
|---|---|
| Does the test DB run the nightly loyalty recalculation job? | If not, loyalty_tier is stale |
| Are triggers enabled in test? | Some teams disable them for speed |
| Do API integration tests hit the same validation services as prod? | Bypassing services = missing rule coverage |
| What timezone does the DB use? | "Tuesday" means different hours in UTC vs store TZ |
Checklist: Environment Parity
- Nightly/recurring jobs run in test (or are mockable)
- Triggers and constraints enabled
- Validation services reachable and unmocked for data-gen tests
- Timezone configuration matches production
- Reference data (SKUs, stores, currencies) is current
Decision Criteria: How to Implement Each Rule
Not every rule deserves the same treatment. Classify each rule by enforcement point and complexity.
Enforcement Point Taxonomy
| Enforcement Point | Test Data Strategy | Example |
|---|---|---|
| Database constraint (PK, FK, CHECK, unique index) | Generator handles automatically; verify constraint exists | CHECK (discount_pct BETWEEN 0 AND 100) |
| Application validation (service layer) | Generate inputs that should pass; separately generate invalid inputs for negative tests | validateOrder() throws if loyalty_tier missing |
| Rules engine (decision table, DSL) | Generate fact combinations covering each rule row + boundaries | 12 rule rows → 12+ test cases |
| Derived/computed field (batch job, event handler) | Pre-compute expected value; load both source and derived fields | loyalty_tier calculated from lifetime_spend |
| Cross-entity invariant (no duplicate active subscription) | Generator must coordinate across tables; often requires custom logic | Unique partial index on subscription(customer_id) WHERE status='ACTIVE' |
Complexity Tiers
| Tier | Characteristics | Implementation Approach |
|---|---|---|
| Tier 1: Structural | Single-table, column-level, no cross-row logic | Declarative config in generator (nullability, ranges, enums, regex) |
| Tier 2: Relational | Foreign keys, cardinality, simple cross-table checks | Generator with referential integrity + custom SQL for invariants |
| Tier 3: Behavioral | Time-dependent, derived fields, rule-engine evaluation, multi-step workflows | Code-first: write TypeScript/Python/Java fixtures that orchestrate setup, execute rules, verify postconditions |
Decision rule: If a rule requires executing logic to know the valid output (e.g., "discount = f(customer, order, calendar)"), it's Tier 3. Stop configuring. Start coding.
Workflow: From Rule Catalog to Executable Test Data
Step 1: Decompose Rules into Test Scenarios
For each rule, derive positive, boundary, and negative scenarios. Use a structured template:
Rule: DISC-003 (Loyalty Tuesday 15%)
Scenarios:
POS-01: Gold member, joined 60 days ago, order Tuesday 10:00 store TZ → 15% applied
POS-02: Platinum member, joined 30 days ago exactly, order Tuesday 23:59 store TZ → 15% applied
BND-01: Gold member, joined 29 days ago, order Tuesday → 0% (30-day rule)
BND-02: Gold member, joined 60 days ago, order Monday 23:59 store TZ → 0% (day rule)
BND-03: Silver member (not eligible tier), joined 60 days ago, order Tuesday → 0% (tier rule)
NEG-01: Gold member, joined 60 days ago, order Tuesday, gift card line item → 0% on gift card only
NEG-02: Gold member, joined 60 days ago, order Tuesday, promo code applied → 0% (non-stackable)
Output: A scenario table per rule. This is your test plan. The data generation serves it.
Step 2: Map Scenarios to Entity States
For each scenario, define the exact database state required. Include derived fields.
Scenario: POS-01
Entities:
Customer:
id: 'cust_001'
loyalty_tier: 'GOLD' -- derived, but we set it directly for test speed
loyalty_start_date: '2024-01-01' -- 60 days before order
timezone: 'America/Chicago'
Order:
id: 'ord_001'
customer_id: 'cust_001'
placed_at: '2024-03-05 10:00:00-05:00' -- Tuesday, store TZ
status: 'PLACED'
OrderLine:
- id: 'line_001', order_id: 'ord_001', sku: 'SKU123', unit_price: 100.00, qty: 1, is_gift_card: false
Expected Output:
OrderLine.discount_pct: 15
OrderLine.discount_code: 'LOYALTY_TUE_15'
Order.total_discount: 15.00
Key insight: For Tier 3 rules, pre-compute the expected derived state and load it directly. Don't rely on the nightly batch in your test setup—it adds latency and flakiness. Load loyalty_tier = 'GOLD' even if it's normally derived. Add a separate test that verifies the derivation logic using the batch job.
Step 3: Choose Generation Strategy per Tier
| Tier | Tooling Approach | Example |
|---|---|---|
| Tier 1 | Declarative config (YAML/JSON) in any generator | email: {type: string, format: email, nullable: false} |
| Tier 2 | Generator with SQL hooks for cross-table invariants | Custom postGenerate hook that enforces unique active subscription |
| Tier 3 | Code-first fixtures (TypeScript, Python, Java) | async function createLoyaltyTuesdayScenario(): Promise<TestDataSet> |
Code-first fixture skeleton (TypeScript):
interface LoyaltyTuesdayFixture {
customer: CustomerRow;
order: OrderRow;
lines: OrderLineRow[];
expected: { discountPct: number; discountCode: string };
}
async function buildLoyaltyTuesdayFixture(
overrides: Partial<LoyaltyTuesdayFixture> = {}
): Promise<LoyaltyTuesdayFixture> {
const storeTz = 'America/Chicago';
const tuesday = Temporal.PlainDate.from('2024-03-05'); // a Tuesday
const joinedDate = tuesday.subtract({ days: 60 });
const customer: CustomerRow = {
id: `cust_${uuid()}`,
loyalty_tier: 'GOLD',
loyalty_start_date: joinedDate.toString(),
timezone: storeTz,
...overrides.customer,
};
const placedAt = tuesday.toPlainDateTime({ hour: 10, minute: 0 })
.toZonedDateTime(storeTz).toInstant();
const order: OrderRow = {
id: `ord_${uuid()}`,
customer_id: customer.id,
placed_at: placedAt.toString(),
status: 'PLACED',
...overrides.order,
};
const lines: OrderLineRow[] = [{
id: `line_${uuid()}`,
order_id: order.id,
sku: 'SKU123',
unit_price: 100.00,
qty: 1,
is_gift_card: false,
...overrides.lines?.[0],
}];
return {
customer,
order,
lines,
expected: { discountPct: 15, discountCode: 'LOYALTY_TUE_15' },
...overrides,
};
}
Why code-first?
- Version controllable, reviewable, refactorable
- Can call actual validation services to verify the generated data passes
- Handles temporal logic, timezone math, and cross-entity coordination
- Debuggable with breakpoints
Step 4: Validate Generated Data Against Rules
Generation ≠ validity. Add a verification phase that runs after data load and before test execution.
async function verifyFixture(fixture: LoyaltyTuesdayFixture, db: DbClient): Promise<VerificationResult> {
// 1. Schema-level (already enforced by DB)
// 2. Application validation
const validation = await apiClient.post('/orders/validate', {
customerId: fixture.customer.id,
lines: fixture.lines.map(l => ({ sku: l.sku, qty: l.qty })),
});
if (!validation.valid) {
return { ok: false, reason: `Validation failed: ${validation.errors.join(', ')}` };
}
// 3. Rules engine evaluation (if accessible)
const discountPreview = await apiClient.post('/discounts/preview', {
customerId: fixture.customer.id,
orderLines: fixture.lines,
placedAt: fixture.order.placed_at,
});
if (discountPreview.discountPct !== fixture.expected.discountPct) {
return {
ok: false,
reason: `Rule mismatch: expected ${fixture.expected.discountPct}%, got ${discountPreview.discountPct}%`
};
}
// 4. Cross-entity invariants
const activeSubs = await db.query(
`SELECT count(*) FROM subscription WHERE customer_id = $1 AND status = 'ACTIVE'`,
[fixture.customer.id]
);
if (activeSubs[0].count > 1) {
return { ok: false, reason: 'Invariant violation: multiple active subscriptions' };
}
return { ok: true };
}
Run this in CI for every fixture. Fail the build if verification fails. This catches:
- Drift between fixture code and deployed validation logic
- Environment parity gaps (e.g., test DB missing a trigger)
- Timezone/configuration mismatches
Step 5: Parameterize for Combinatorial Coverage
Once the fixture works, expose parameters for the dimensions that matter:
interface LoyaltyTuesdayParams {
tier?: 'GOLD' | 'PLATINUM' | 'SILVER';
daysSinceJoin?: number;
dayOfWeek?: 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | ...;
hourOfDay?: number; // 0-23 in store TZ
lineItems?: Array<{ sku: string; isGiftCard: boolean; hasPromo: boolean }>;
}
async function buildLoyaltyTuesdayFixture(
params: LoyaltyTuesdayParams = {}
): Promise<LoyaltyTuesdayFixture> {
// ... use params with sensible defaults
}
Now your test suite can iterate:
const testMatrix = [
{ tier: 'GOLD', daysSinceJoin: 60, dayOfWeek: 'TUESDAY', hourOfDay: 10, expectDiscount: 15 },
{ tier: 'GOLD', daysSinceJoin: 29, dayOfWeek: 'TUESDAY', hourOfDay: 10, expectDiscount: 0 },
{ tier: 'SILVER', daysSinceJoin: 60, dayOfWeek: 'TUESDAY', hourOfDay: 10, expectDiscount: 0 },
{ tier: 'GOLD', daysSinceJoin: 60, dayOfWeek: 'MONDAY', hourOfDay: 23, expectDiscount: 0 },
// ... combinatorial explosion managed by test framework
];
for (const tc of testMatrix) {
test(`DISC-003: ${JSON.stringify(tc)}`, async () => {
const fixture = await buildLoyaltyTuesdayFixture(tc);
await loadFixture(fixture);
await verifyFixture(fixture, db);
const result = await placeOrder(fixture.order, fixture.lines);
expect(result.discountPct).toBe(tc.expectDiscount);
});
}
Worked Example: Subscription Upgrade Rules
Let's walk a complete rule set through the workflow.
Business Rules (Extracted from Decision Table)
| Rule ID | Condition | Action |
|---|---|---|
| SUB-001 | Customer has BASIC plan, requests UPGRADE to PRO, payment_method valid | Create PRO subscription, cancel BASIC at period_end, prorate credit |
| SUB-002 | Customer has BASIC plan, requests UPGRADE to PRO, payment_method invalid | Reject with PAYMENT_REQUIRED |
| SUB-003 | Customer has PRO plan, requests UPGRADE to ENTERPRISE, seat_count >= 5 | Create ENTERPRISE subscription, cancel PRO immediately, no proration |
| SUB-004 | Customer has PRO plan, requests UPGRADE to ENTERPRISE, seat_count < 5 | Reject with MIN_SEATS_REQUIRED |
| SUB-005 | Customer has any |
Read more
How to Measure Test Data Quality Before a Test Run
A step-by-step guide for “How to Measure Test Data Quality Before a Test Run,” covering prerequisites, implementation choices, validation, and common failure modes.
Test Data Generation Mistakes That Make Tests Flaky
A practical risk review of “Test Data Generation Mistakes That Make Tests Flaky,” with warning signs, safeguards, and fixes for real QA workflows.
Test Data Factories vs Fixtures vs Seed Scripts
A buyer-focused guide to “Test Data Factories vs Fixtures vs Seed Scripts,” with concrete selection criteria, trade-offs, and an evaluation path QA teams can use.