Choosing and Combining Techniques: From Requirements to a Test List, Aligning with Acceptance Criteria and the Limits of AI-Generated Test Cases
The shape of the requirement picks the technique: equivalence partitioning and boundary value analysis for ranges, decision tables for combinations of conditions, state transitions for outcomes that depend on history; combined and deduplicated, they leave one test list. For Bookstore's institutional partial shipment story, five techniques produced 27 conditions, the list came down to 19, and the row where three techniques met caught the bug. A prompt and review checklist for AI test cases.
Version note: The code in this post was built and run with .NET 10, xUnit.net v3 4.0.3 and Microsoft.Extensions.TimeProvider.Testing 11.0.0.
Guess First
In Bookstore, the online bookstore used as the shared example across this site's series, the story the Requirements Analysis series left for the second release is up next: partial shipment for institutional orders. After her meeting with the warehouse and accounting, Selin has closed the open questions. The rules on the back of the story:
- If the institution chooses partial shipment when stock is short, the books in stock are shipped right away, and the missing ones follow in additional shipments as stock arrives.
- An order is split into at most three shipments; the third shipment must complete the order.
- The institution pays the shipping fee once, with the first shipment; Bookstore covers the fee for additional shipments.
- Each shipment is invoiced only for the books shipped.
- If books are still unshipped 30 days after the order date, the back office can cancel the rest and refund its amount.
- The minimum of 20 books applies to the order, not to the shipment.
Emre's code:
namespace Bookstore.Ordering;
public enum FulfillmentState
{
Paid,
PartiallyShipped,
Shipped,
ClosedWithCancellation,
}
public sealed record InstitutionalOrderLine(string Isbn, int Quantity, decimal UnitPrice);
public sealed record Shipment(int Number, IReadOnlyDictionary<string, int> Items, decimal InvoiceAmount, decimal ShippingFee);
public sealed class InstitutionalFulfillment(
IReadOnlyList<InstitutionalOrderLine> lines, decimal shippingFee, DateTimeOffset orderedAt, TimeProvider clock)
{
public const int MaxShipments = 3;
public static readonly TimeSpan BackorderWindow = TimeSpan.FromDays(30);
private readonly Dictionary<string, int> _shipped = lines.ToDictionary(line => line.Isbn, _ => 0);
private readonly List<Shipment> _shipments = [];
public FulfillmentState State { get; private set; } = FulfillmentState.Paid;
public IReadOnlyList<Shipment> Shipments => _shipments;
public int Remaining(string isbn) => lines.Single(line => line.Isbn == isbn).Quantity - _shipped[isbn];
public Shipment Ship(IReadOnlyDictionary<string, int> items)
{
if (State is not (FulfillmentState.Paid or FulfillmentState.PartiallyShipped))
{
throw new InvalidOperationException($"Cannot ship an order that is {State}.");
}
if (items.Count == 0)
{
throw new ArgumentException("A shipment must contain at least one book.", nameof(items));
}
foreach ((string isbn, int quantity) in items)
{
if (!_shipped.ContainsKey(isbn))
{
throw new ArgumentException($"{isbn} is not in this order.", nameof(items));
}
if (quantity <= 0 || quantity > Remaining(isbn))
{
throw new ArgumentException($"Quantity for {isbn} must be between 1 and {Remaining(isbn)}.", nameof(items));
}
}
int number = _shipments.Count + 1;
bool completesOrder = items.Values.Sum() == lines.Sum(line => line.Quantity);
if (number == MaxShipments && !completesOrder)
{
throw new InvalidOperationException($"Shipment {MaxShipments} must complete the order.");
}
foreach ((string isbn, int quantity) in items)
{
_shipped[isbn] += quantity;
}
decimal invoice = items.Sum(item => item.Value * lines.Single(line => line.Isbn == item.Key).UnitPrice);
Shipment shipment = new(number, items, invoice, number == 1 ? shippingFee : 0m);
_shipments.Add(shipment);
State = completesOrder ? FulfillmentState.Shipped : FulfillmentState.PartiallyShipped;
return shipment;
}
public decimal CancelRemaining()
{
if (State is not (FulfillmentState.Paid or FulfillmentState.PartiallyShipped))
{
throw new InvalidOperationException($"Cannot cancel the rest of an order that is {State}.");
}
if (clock.GetUtcNow() - orderedAt < BackorderWindow)
{
throw new InvalidOperationException("Remaining books can be cancelled only after 30 days.");
}
decimal refund = lines.Sum(line => (line.Quantity - _shipped[line.Isbn]) * line.UnitPrice);
State = FulfillmentState.ClosedWithCancellation;
return refund;
}
}
You could apply all seven techniques from this series to this story. Which ones would you pick, and how many tests would you write? There's a bug in the code; which technique finds it?
The Answer: The Bug Sits Where Two Techniques Meet
completesOrder only checks whether this shipment is the whole order; it doesn't count what was shipped before. The result shows up in two places: an order that ships the Dunes in the first shipment and the Emmas in the second never becomes Shipped and stays PartiallyShipped; and an order split into three shipments gets a "must complete the order" error on the third shipment, even though that shipment does exactly that.
The test that finds this bug is where state transition testing's transition from PartiallyShipped to Shipped and boundary value analysis's "the entire remaining quantity" boundary value meet in the same test. The decision table's "the second shipment completes the order" rule is the same test as well. Three techniques produce the same test case. That's the real subject of this post: removing duplicates when you combine techniques, and deciding which techniques actually pay off for a story.
A Decision Guide: Which Technique for Which Requirement?
The shape of the requirement drives the choice of technique. Looking at the story's sentences and finding the question each one asks largely picks the technique:
| What's in the requirement? | Technique | Post in the series |
|---|---|---|
| Value ranges, types, formats | Equivalence partitioning | 02 |
| Boundaries of ordered ranges: quantity, amount, date, length | Boundary value analysis | 03 |
| The outcome is set by a combination of several conditions | Decision table testing | 04 |
| The outcome depends on history: state, events, order | State transition testing | 05 |
| Many parameters that look independent | Pairwise testing | 06 |
| The code may have branches the requirement doesn't mention | Looking for missing tests with branch coverage | 07 |
| Known defect patterns, a vague or new area | Error guessing, checklists, exploratory testing | 07 |
The table is a mapping, not a sequence. Most stories fall into several rows at once. The partial shipment story has value ranges, the shipped quantity; a combination of conditions, which shipment it is and whether it completes the order; an outcome that depends on history, the order's state; and a time limit, 30 days. It doesn't have many independent parameters: no need for pairwise. While the code is still being written there's no need for branch coverage either; that's for looking for missing branches after the tests are written.
Splitting the Story into Techniques
We turn each rule sentence into test conditions of the technique that fits it best:
The first and fifth sentences describe the order's life cycle. As in the fifth post, we extract the states and events: Paid, PartiallyShipped, Shipped and ClosedWithCancellation; the events are Ship and CancelRemaining, and CancelRemaining's guard is "at least 30 days after the order date". Ship can go from Paid and PartiallyShipped to two targets: to Shipped if the shipment completes the order, to PartiallyShipped if it doesn't. CancelRemaining goes from the same two states to ClosedWithCancellation. That's six valid transitions in total; four invalid cells remain in the Shipped and ClosedWithCancellation rows, and the false side of the guard is a separate test condition.
The second and third sentences are a decision table: the conditions are "which shipment" (1, 2, 3) and "does it complete the order"; the actions are accepting the shipment, the order's new state and the shipping fee. Six rules, one of which is "rejected".
For the shipped quantity, equivalence partitioning and boundary value analysis: the invalid partitions are 0 and below, more than what's left, and an ISBN that isn't in the order; the boundaries are 0 and 1, the remainder and the remainder plus one. For the 30-day guard, one tick before 30 days and exactly day 30.
The fourth sentence is a single test condition: the invoice contains only the shipped books. The sixth sentence doesn't call for a new test; the minimum of 20 has been in InstitutionalPricing's tests since the first post. Here it's enough to see a first shipment of 12 books being accepted, and that's already on the list.
Two items come from the error guessing list: an empty shipment, and a shipment that exceeds the remainder on one line of the order while being valid on the other. In total, the techniques produced 27 test conditions.
Removing Duplicates: One Test List
Put the 27 conditions side by side and most of them turn out to be the same test. The decision table's "the first shipment completes the order" rule and the state transition from Paid to Shipped are the same test. The "second shipment completes" rule, the transition from PartiallyShipped to Shipped and the "entire remainder" boundary are the same test too. Merged, the list comes down to 19 test conditions:
| ID | Test | Technique |
|---|---|---|
T1 | The first shipment is the whole order: Shipped, the shipping fee is charged | Decision table, state transition |
T2 | The first shipment is partial, 12 Dune: PartiallyShipped, the shipping fee is charged | Decision table, state transition |
T3 | A one-book shipment is accepted | Boundary value analysis, lower bound |
T4 | The second shipment is the entire remainder: Shipped, no shipping fee | Decision table, state transition, boundary value analysis, upper bound |
T5 | The second shipment is partial: stays PartiallyShipped | Decision table, state transition |
T6 | The third shipment completes the order: Shipped | Decision table |
T7 | The third shipment doesn't complete the order: rejected | Decision table |
T8–T9 | Quantity 0 and remainder plus one: rejected | Equivalence partitioning, boundary value analysis |
T10 | A quantity above the remainder on the other line: rejected | Error guessing |
T11 | An ISBN that isn't in the order: rejected | Equivalence partitioning |
T12 | An empty shipment: rejected | Error guessing |
T13 | The invoice covers only the shipped books | Equivalence partitioning |
T14–T15 | Cancelling the rest: rejected one tick before 30 days, refunded on day 30 | State transition with guard, boundary value analysis |
T16–T19 | Ship and CancelRemaining are rejected in Shipped and ClosedWithCancellation | State transition, invalid |
The technique column is the most valuable part of the list. When someone wants to delete a test, it tells you which coverage item would be left uncovered: delete T4 and a decision table rule, a transition and a boundary value all lose their test.
From the List to Code
The list turns directly into TheoryData. The six rows where the decision table and state transitions meet are a single Theory; each row's label is its ID on the list:
using Microsoft.Extensions.Time.Testing;
namespace Bookstore.Ordering.Tests;
public sealed class InstitutionalFulfillmentTests
{
private const string Dune = "9780441013593";
private const string Emma = "9780141439518";
private static readonly Dictionary<string, string> Isbns = new() { ["Dune"] = Dune, ["Emma"] = Emma, ["Hobbit"] = "9780547928227" };
private static readonly DateTimeOffset OrderedAt = new(2026, 9, 1, 9, 0, 0, TimeSpan.Zero);
private readonly FakeTimeProvider _clock = new(OrderedAt);
// Nevin Hanım's school: 12 Dune (150 TRY) and 10 Emma (95 TRY), 22 books in total; shipping 49.90 TRY
private InstitutionalFulfillment NewOrder() =>
new([new(Dune, 12, 150m), new(Emma, 10, 95m)], 49.90m, OrderedAt, _clock);
// "Dune:12 Emma:10" -> { Dune ISBN: 12, Emma ISBN: 10 }
private static Dictionary<string, int> Items(string shipment) =>
shipment.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Select(item => item.Split(':'))
.ToDictionary(parts => Isbns[parts[0]], parts => int.Parse(parts[1]));
// T1-T6: decision table (shipment number x completes the order) and state transitions together
public static IEnumerable<TheoryDataRow<string[], FulfillmentState, decimal[]>> ShipmentRules =>
[
new(["Dune:12 Emma:10"], FulfillmentState.Shipped, [49.90m]) { Label = "T1 first shipment completes" },
new(["Dune:12"], FulfillmentState.PartiallyShipped, [49.90m]) { Label = "T2 first shipment is partial" },
new(["Dune:1"], FulfillmentState.PartiallyShipped, [49.90m]) { Label = "T3 one book (lower boundary)" },
new(["Dune:12", "Emma:10"], FulfillmentState.Shipped, [49.90m, 0m]) { Label = "T4 second completes (exact remainder)" },
new(["Dune:6", "Emma:10"], FulfillmentState.PartiallyShipped, [49.90m, 0m]) { Label = "T5 second is partial" },
new(["Dune:6", "Dune:3", "Dune:3 Emma:10"], FulfillmentState.Shipped, [49.90m, 0m, 0m]) { Label = "T6 third completes" },
];
[Theory]
[MemberData(nameof(ShipmentRules))]
public void Shipments_move_the_order_and_only_the_first_one_is_charged(
string[] shipments, FulfillmentState expectedState, decimal[] expectedFees)
{
InstitutionalFulfillment order = NewOrder();
foreach (string shipment in shipments)
{
order.Ship(Items(shipment));
}
Assert.Equal(expectedState, order.State);
Assert.Equal(expectedFees, order.Shipments.Select(s => s.ShippingFee));
}
// T7: the decision table's only "no" rule
[Fact]
public void Third_shipment_that_does_not_complete_the_order_is_rejected()
{
InstitutionalFulfillment order = NewOrder();
order.Ship(Items("Dune:6"));
order.Ship(Items("Dune:3"));
Assert.Throws<InvalidOperationException>(() => order.Ship(Items("Dune:3")));
Assert.Equal(FulfillmentState.PartiallyShipped, order.State);
Assert.Equal(2, order.Shipments.Count);
}
// T8-T12: equivalence partitioning and boundary value analysis; one invalid value per test
[Theory]
[InlineData("Dune:0")]
[InlineData("Dune:13")]
[InlineData("Emma:11")]
[InlineData("Hobbit:1")]
[InlineData("")]
public void Invalid_shipment_is_rejected_and_nothing_is_shipped(string shipment)
{
InstitutionalFulfillment order = NewOrder();
Assert.Throws<ArgumentException>(() => order.Ship(Items(shipment)));
Assert.Equal(FulfillmentState.Paid, order.State);
Assert.Empty(order.Shipments);
}
// T14-T15: guard [at least 30 days after the order date]; the boundary and just before it
[Fact]
public void Remaining_books_cannot_be_cancelled_before_30_days()
{
InstitutionalFulfillment order = NewOrder();
order.Ship(Items("Dune:12"));
_clock.Advance(InstitutionalFulfillment.BackorderWindow - TimeSpan.FromTicks(1));
Assert.Throws<InvalidOperationException>(() => order.CancelRemaining());
Assert.Equal(FulfillmentState.PartiallyShipped, order.State);
}
[Fact]
public void Remaining_books_are_cancelled_and_refunded_after_30_days()
{
InstitutionalFulfillment order = NewOrder();
order.Ship(Items("Dune:12"));
_clock.Advance(InstitutionalFulfillment.BackorderWindow);
decimal refund = order.CancelRemaining();
Assert.Equal(950m, refund);
Assert.Equal(FulfillmentState.ClosedWithCancellation, order.State);
}
}
T13, the invoice, and T16–T19, the invalid transitions in final states, are three more Facts in the same class; the full version is in the snapshot at the end of the post. The Items helper method builds shipments from a readable string, just like the table: looking at the row "Dune:6", "Dune:3" and "Dune:3 Emma:10" and seeing three shipments is easier than reading three separate dictionary initializers. Against the old code:
failed Bookstore.Ordering.Tests.InstitutionalFulfillmentTests.Shipments_move_the_order_and_only_the_first_one_is_charged [T6 third completes] (0ms)
Xunit.MicrosoftTestingPlatform.XunitException: System.InvalidOperationException : Shipment 3 must complete the order.
failed Bookstore.Ordering.Tests.InstitutionalFulfillmentTests.Shipments_move_the_order_and_only_the_first_one_is_charged [T4 second completes (exact remainder)] (0ms)
Assert.Equal() Failure: Values differ
Expected: Shipped
Actual: PartiallyShipped
Test run summary: Failed!
total: 17
failed: 2
succeeded: 15
skipped: 0
duration: 408ms
Two of the seventeen tests are red, and both are rows where more than one technique meets. The fix is to ask "does it complete the order?" while also counting what was shipped before:
bool completesOrder = lines.All(line => _shipped[line.Isbn] + items.GetValueOrDefault(line.Isbn) == line.Quantity);
Test run summary: Passed!
total: 166
failed: 0
succeeded: 166
skipped: 0
duration: 465ms
Example Mapping, the TDD Test List and Test Levels
This list isn't a new document. In the example map from the Requirements Analysis series' "Acceptance Criteria and Example Mapping" post, every blue card was a rule and every green card an example. Every row in the test list is a green card: T4 is exactly the example "12 Dune were shipped, then 10 Emma; the order is complete, and there's no shipping fee on the second shipment". The techniques make systematic the examples found by intuition in an example mapping session; the example "the third shipment completes the order", which nobody thought of in the session, came out of the decision table.
The test list in the TDD series' "What Is TDD?" post is the same list. Kent Beck's Canon TDD starts by writing a list of behaviors to test and picks one item from the list on each cycle. Building that list with techniques answers TDD's "which test next?" question up front; TDD then consumes the list in the order the code actually needs.
The list also spreads across test levels. All nineteen conditions are at the unit level; the domain logic can be tested without a database or HTTP. Two things are left for the integration level: that the invoice and the shipment record are written to the database correctly, and that the refund on day 30 really reaches the payment provider. The E2E level gets a single scenario: Nevin Hanım chooses partial shipment, the first parcel goes out, the second goes out once stock arrives, and the order shows as complete on the orders page. That scenario's Gherkin is written as in the Requirements Analysis series' Gherkin post; moving nineteen conditions to E2E would turn tests that take milliseconds at the unit level into minutes.
Asking AI for Test Cases
Giving a story to an LLM and saying "write the test cases" is common now. The Testing with Generative AI syllabus that ISTQB published in 2025 makes this the subject of a separate certification. The prompt structure the syllabus recommends has six parts: role, context, instruction, input data, constraints and output format. The same syllabus covers recognizing and mitigating hallucinations, reasoning errors and biases in LLM output as a section of its own; the responsibility for evaluating the results stays with the tester.
This series' techniques fill in the instruction and constraint parts of the prompt. Asking explicitly for the technique and the coverage goal instead of "write the test cases" makes the output reviewable:
Role: You are a test designer for an online bookstore's back office.
Context: Institutional orders can be shipped in parts when stock is short.
Instruction: Derive test cases for the rules below with these techniques:
- equivalence partitioning: list every valid and invalid partition; one invalid value per test
- boundary value analysis: test both sides of every boundary (2-value)
- decision table: list every combination of "shipment number" and "completes the order"
- state transition: build the state table, cover every valid transition and every invalid cell
Input data: <the six rules of the story>
Constraints: Don't invent rules that aren't in the input. If a rule is ambiguous, list it as a question instead of guessing an expected result.
Output format: a table with columns ID, technique, coverage item, input, expected result.
When you compare the output with your own list, the technique and coverage item columns pay off; you can see row by row which item is missing and which is extra. The review checklist comes from this series' techniques:
- Is every rule in the story tied to at least one test? Is a rule that isn't in the story being tested?
- Is every invalid partition in a separate test, or are there two invalid values in the same test?
- Does every boundary have both sides: 0 and 1, the remainder and the remainder plus one, before 30 days and day 30?
- Are the empty cells of the state table tested, or are there only valid transitions?
- Do the expected results come from the story, or are they plausible values that aren't in the story?
- Are there rows that test the same coverage item twice?
- For an ambiguous rule, was a question produced instead of a guess?
The fifth item is the most critical. If the story had no answer to "what happens if the third shipment doesn't complete the order?", it would be easy to make up a plausible expected result, and that's the fastest way to copy a bug into a test. A test list produced by AI is a draft; the list built with techniques is the measure for checking it.
Production Notes
Keep the test list with the story. Seeing the list's IDs in test names or labels in a pull request lets the reviewer answer "is this rule tested?" without reading the code.
Rebuild the list when a rule changes. If Selin raises the shipment limit to four tomorrow, the decision table gets new columns and the state table a new path. Adding a row to the old list means skipping the new rule's intersections with the others; as in the fourth post, redo the full table first and then collapse it.
Use coverage as a final check. Once the list is built from the requirement and the tests pass, the branch coverage report shows whether the code does something the story doesn't say. If the report isn't at 100 percent, the missing branch is either a rule nobody wrote into the story or dead code; both are worth a conversation.
Write production bugs back into the list. If a bug came from somewhere the tests didn't catch, the answer to "which technique would have found this?" matters as much as the test added to the list. The answer comes back as a new item on the error guessing list or the checklist.
Common Pitfalls
Applying every technique to every story. Pairwise has nothing to do in the partial shipment story; its parameters are few and depend on each other. Running seven techniques for every story means the same test written under seven different names and weeks of test design. Looking at the shape of the requirement and picking two or three techniques is enough.
Leaving test cases in a document. If the table stays on a Confluence page, it drifts away from the code at the first change. The list only becomes something that actually runs when it turns into TheoryData rows and test names.
Not removing duplicates. Writing 27 conditions as 27 separate tests produces a test suite that tests the same behavior three times, with all three turning red on a single change. A merged list means fewer tests and a clearer failure message.
Accepting AI's list without review. A table that looks fluent and complete may contain expected results that aren't in the story. The list shouldn't go into code before the review checklist has been applied to every row.
Confusing the number of tests with quality. As we saw in the series' first post, the measure isn't the number of tests but the coverage items covered. Seventeen tests cover nineteen conditions coming from five different techniques.
A Small Experiment
★☆☆ Delete T4 from the list and from the code. Which coverage items are left untested? Which other test was catching the bug in the old code?
★★☆ Add a rule to the story: "Books going to the same campus ship in the same shipment." Which technique or techniques produce new test conditions? Update the list, remove duplicates and write the new tests.
★★★ Run this post's prompt with an LLM you use. Evaluate the output with the review checklist: how many rows match an ID on the list, how many are missing, how many test something that isn't in the story? Add the gaps you find to the constraints part of the prompt and try again.
The series ends here. Over eight posts we moved from choosing test cases by intuition to deriving them systematically: from the requirement to test conditions, from there to coverage items, and finally to a running xUnit test. The techniques don't slow test writing down; they speed it up, because they tell you up front what to write and what not to. When the next story arrives, let the first question be not "how many tests should I write?" but "what shape is this requirement, and which technique fits it?".