Add round 2: split multi-step OrderBook tests (example created by participant)

- Add OrderBookDomain: in-memory order book with Pending →
  WaitingForPayment → Fulfilling → Delivered state machine, payment
  window based on passed-in timestamps, cancellation rules
- Add OrderBookTests: 7 integration tests in given/when/then/when/then
  anti-pattern style as starting material for the exercise
- Learning hour: add round 2 where participants create the example
  themselves before showing it to the agent (no refactored test given)
- Fix sln: OrderTests Debug config was mapped to Release
This commit is contained in:
Your Name
2026-09-03 13:50:38 +00:00
parent 3b7e880717
commit e8748ee697
7 changed files with 292 additions and 5 deletions
+13 -3
View File
@@ -6,6 +6,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderDomain", "src/OrderDom
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderTests", "tests/OrderTests/OrderTests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderTests", "tests/OrderTests/OrderTests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderBookDomain", "src/OrderBookDomain/OrderBookDomain.csproj", "{C3D4E5F6-A7B8-9012-CDEF-123456789012}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderBookTests", "tests/OrderBookTests/OrderBookTests.csproj", "{D4E5F6A7-B8C9-0123-DEFA-234567890123}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -18,8 +22,14 @@ Global
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Deploy.0 = Debug|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Release|Any CPU {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Release|Any CPU {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Deploy.0 = Release|Any CPU {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
{D4E5F6A7-B8C9-0123-DEFA-234567890123}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D4E5F6A7-B8C9-0123-DEFA-234567890123}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D4E5F6A7-B8C9-0123-DEFA-234567890123}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal
+48 -2
View File
@@ -15,12 +15,14 @@ You've described what you want to a coding agent, and it didn't get it right. Or
## Learning Goals ## Learning Goals
* Use a concrete code example (show, don't tell) to guide a coding agent in propagating a refactoring pattern across a test suite. * Use a concrete code example (show, don't tell) to guide a coding agent in propagating a refactoring pattern across a test suite.
* Produce your own example before delegating: refactor a multi-step test into focused tests yourself, then show the result to the agent as the pattern.
## Session Outline ## Session Outline
* 5 min connect: Frustrating agent conversations * 5 min connect: Frustrating agent conversations
* 5 min concept: LLMs are pattern matchers * 5 min concept: LLMs are pattern matchers
* 35 min concrete practice: Refactor tests using a custom matcher example * 20 min concrete practice: Round 1 — refactor tests using a custom matcher example
* 15 min concrete practice: Round 2 — split multi-step tests using an example you create
* 5 min conclusion: Share back and recognize the pattern in your own code * 5 min conclusion: Share back and recognize the pattern in your own code
## Connect — Frustrating Agent Conversations ## Connect — Frustrating Agent Conversations
@@ -80,7 +82,7 @@ The agent sees the concrete pattern and replicates it. No description needed.
**Examples beat descriptions.** One concrete example is worth a paragraph of instructions. This works for any refactoring pattern — splitting tests, introducing custom matchers, extracting methods, renaming for clarity. Show the agent what "done" looks like. **Examples beat descriptions.** One concrete example is worth a paragraph of instructions. This works for any refactoring pattern — splitting tests, introducing custom matchers, extracting methods, renaming for clarity. Show the agent what "done" looks like.
## Concrete Practice — Refactor Tests with a Custom Matcher ## Concrete Practice — Round 1 — Refactor Tests with a Custom Matcher (20 min)
### Setup ### Setup
@@ -136,6 +138,49 @@ Run the tests after the agent makes changes to verify nothing broke.
- The `exercises/show-dont-tell/` project (included in this repo) - The `exercises/show-dont-tell/` project (included in this repo)
- .NET 10 SDK - .NET 10 SDK
## Concrete Practice — Round 2 — Split Multi-Step Tests (15 min)
This round has a twist: **no refactored example is provided.** Creating the example is the exercise.
### Setup
In the same solution, open the `OrderBookTests` project. The domain is an `OrderBook` with a small state machine:
* `OpenOrder(customer)` creates a `Pending` order
* `FinalizeOrder(id, at)``WaitingForPayment` (the timestamp is passed in — no global clock)
* `PayOrder(id, paidAt)``Fulfilling`, unless payment arrives more than 14 days after finalization: the order is `Cancelled` and the payment is rejected
* `ShipOrder(id)``Delivered`
* `CancelOrder(id)` works from `Pending` and `WaitingForPayment` only; invalid transitions throw
`OrderBookLifecycleTests.cs` contains 7 integration tests in an anti-pattern style: **given/when/then/when/then**. Each test walks through several transitions with assertions in between, e.g. `PayThenShip_DeliversTheOrder` finalizes, asserts, pays, ships, and asserts again. The tests pass — they're just testing three behaviors each under a name that describes one.
### Step 1 — Identify the Smell (3 min)
Look at the tests. What makes them hard to work with?
* Multiple act/assert pairs: when one assertion fails, you don't know which transition misbehaved
* Test names describe one behavior while the test body verifies three
* Setup for a later transition is entangled with assertions for an earlier one
### Step 2 — Create the Example Yourself (7 min)
Pick **one** multi-step test and refactor it by hand into focused tests: one transition per test, each test setting up only the state it needs. Keep the exception paths (`Assert.Throws`) in their own tests. Run the tests — the split versions must cover the same behavior as the original.
You cannot show the agent what "done" looks like until you have built it. Notice what decisions you had to make: how to name the split tests, what setup helper to extract, where a transition deserves its own test.
### Step 3 — Use Your Example with the Agent (5 min)
Now show your refactored test to the agent and ask it to apply the same pattern to the remaining tests. Verify with the test run afterward.
**If the agent doesn't get it right:**
* Check whether your example is unambiguous — could it be read another way?
* Compare with round 1: what did the provided example communicate that yours doesn't?
### Why No Example Was Provided
Round 1 showed that examples guide agents better than descriptions. Round 2 practices the harder half of that skill: producing the example. In real work, the pattern you want to propagate usually doesn't exist yet — someone has to write it first, and that someone is you.
## Conclusions — Share Back ## Conclusions — Share Back
Go around and ask: Go around and ask:
@@ -143,6 +188,7 @@ Go around and ask:
1. **Did the agent get it right on the first try? What happened if it didn't?** 1. **Did the agent get it right on the first try? What happened if it didn't?**
2. **What made the example work — what would have made it fail?** 2. **What made the example work — what would have made it fail?**
3. **Do you recognize this pattern in your own tests?** Where could you use "show, don't tell" in your real work? 3. **Do you recognize this pattern in your own tests?** Where could you use "show, don't tell" in your real work?
4. **What was different in round 2, when you had to create the example yourself?** What made that harder — and what did you learn about writing a good example?
This is an [Explain the Main Idea]({% link _activities/conclusions/explain_main_idea.md %}) conclusion. This is an [Explain the Main Idea]({% link _activities/conclusions/explain_main_idea.md %}) conclusion.
@@ -0,0 +1,25 @@
namespace OrderBookDomain;
public enum OrderStatus
{
Pending,
WaitingForPayment,
Fulfilling,
Delivered,
Cancelled
}
public class Order
{
public int Id { get; }
public string Customer { get; }
public OrderStatus Status { get; internal set; }
public DateTimeOffset? FinalizedAt { get; internal set; }
internal Order(int id, string customer)
{
Id = id;
Customer = customer;
Status = OrderStatus.Pending;
}
}
@@ -0,0 +1,69 @@
namespace OrderBookDomain;
public class OrderBook
{
private readonly TimeSpan _paymentWindow;
private readonly Dictionary<int, Order> _ordersById = new();
private int _nextId = 1;
public OrderBook(TimeSpan? paymentWindow = null)
=> _paymentWindow = paymentWindow ?? TimeSpan.FromDays(14);
public IReadOnlyList<Order> Orders => _ordersById.Values.ToList();
public Order OpenOrder(string customer)
{
var order = new Order(_nextId++, customer);
_ordersById[order.Id] = order;
return order;
}
public Order GetOrder(int orderId)
=> _ordersById.TryGetValue(orderId, out var order)
? order
: throw new InvalidOperationException($"Unknown order id {orderId}.");
public void FinalizeOrder(int orderId, DateTimeOffset finalizedAt)
{
var order = GetOrder(orderId);
RequireStatus(order, OrderStatus.Pending, "finalize");
order.Status = OrderStatus.WaitingForPayment;
order.FinalizedAt = finalizedAt;
}
public void PayOrder(int orderId, DateTimeOffset paidAt)
{
var order = GetOrder(orderId);
RequireStatus(order, OrderStatus.WaitingForPayment, "pay");
if (paidAt - order.FinalizedAt > _paymentWindow)
{
order.Status = OrderStatus.Cancelled;
throw new InvalidOperationException(
$"Payment for order {orderId} arrived after the payment window; the order was cancelled.");
}
order.Status = OrderStatus.Fulfilling;
}
public void ShipOrder(int orderId)
{
var order = GetOrder(orderId);
RequireStatus(order, OrderStatus.Fulfilling, "ship");
order.Status = OrderStatus.Delivered;
}
public void CancelOrder(int orderId)
{
var order = GetOrder(orderId);
if (order.Status is not (OrderStatus.Pending or OrderStatus.WaitingForPayment))
throw new InvalidOperationException(
$"Cannot cancel order {orderId} in status {order.Status}.");
order.Status = OrderStatus.Cancelled;
}
private static void RequireStatus(Order order, OrderStatus expected, string action)
{
if (order.Status != expected)
throw new InvalidOperationException(
$"Cannot {action} order {order.Id} in status {order.Status} (expected {expected}).");
}
}
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,114 @@
using NUnit.Framework;
using OrderBookDomain;
namespace OrderBookTests;
public class OrderBookLifecycleTests
{
private static readonly DateTimeOffset Day0 = new(2025, 6, 1, 0, 0, 0, TimeSpan.Zero);
private static DateTimeOffset At(int day) => Day0.AddDays(day);
[Test]
public void FinalizeThenPay_MovesOrderThroughTwoStates()
{
var book = new OrderBook();
var order = book.OpenOrder("anna");
book.FinalizeOrder(order.Id, At(1));
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.WaitingForPayment));
book.PayOrder(order.Id, At(2));
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.Fulfilling));
}
[Test]
public void PayThenShip_DeliversTheOrder()
{
var book = new OrderBook();
var order = book.OpenOrder("anna");
book.FinalizeOrder(order.Id, At(1));
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.WaitingForPayment));
book.PayOrder(order.Id, At(2));
book.ShipOrder(order.Id);
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.Delivered));
}
[Test]
public void PayWithinWindow_ThenLatePayOnSecondOrder_CancelsOnlyThatOrder()
{
var book = new OrderBook();
var first = book.OpenOrder("anna");
var second = book.OpenOrder("bram");
book.FinalizeOrder(first.Id, At(1));
book.FinalizeOrder(second.Id, At(1));
Assert.That(book.Orders, Has.Count.EqualTo(2));
book.PayOrder(first.Id, At(5));
Assert.That(book.GetOrder(first.Id).Status, Is.EqualTo(OrderStatus.Fulfilling));
Assert.Throws<InvalidOperationException>(() => book.PayOrder(second.Id, At(30)));
Assert.That(book.GetOrder(second.Id).Status, Is.EqualTo(OrderStatus.Cancelled));
}
[Test]
public void PayBeforeFinalize_Throws_ThenFinalizeStillWorks()
{
var book = new OrderBook();
var order = book.OpenOrder("anna");
Assert.Throws<InvalidOperationException>(() => book.PayOrder(order.Id, At(1)));
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.Pending));
book.FinalizeOrder(order.Id, At(2));
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.WaitingForPayment));
}
[Test]
public void CancelPendingOrder_ThenPayAfterCancel_ThrowsAgain()
{
var book = new OrderBook();
var order = book.OpenOrder("anna");
book.CancelOrder(order.Id);
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.Cancelled));
Assert.Throws<InvalidOperationException>(() => book.PayOrder(order.Id, At(1)));
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.Cancelled));
}
[Test]
public void ShippedOrder_CannotBeCancelled()
{
var book = new OrderBook();
var order = book.OpenOrder("anna");
book.FinalizeOrder(order.Id, At(1));
book.PayOrder(order.Id, At(2));
book.ShipOrder(order.Id);
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.Delivered));
Assert.Throws<InvalidOperationException>(() => book.CancelOrder(order.Id));
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.Delivered));
}
[Test]
public void TwoCustomers_InterleaveTransitions_Independently()
{
var book = new OrderBook();
var annas = book.OpenOrder("anna");
var brams = book.OpenOrder("bram");
book.FinalizeOrder(brams.Id, At(1));
Assert.That(book.GetOrder(annas.Id).Status, Is.EqualTo(OrderStatus.Pending));
Assert.That(book.GetOrder(brams.Id).Status, Is.EqualTo(OrderStatus.WaitingForPayment));
book.PayOrder(brams.Id, At(2));
book.CancelOrder(annas.Id);
Assert.That(book.GetOrder(annas.Id).Status, Is.EqualTo(OrderStatus.Cancelled));
Assert.That(book.GetOrder(brams.Id).Status, Is.EqualTo(OrderStatus.Fulfilling));
}
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="NUnit" Version="4.6.1" />
<PackageReference Include="NUnit3TestAdapter" Version="6.3.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\OrderBookDomain\OrderBookDomain.csproj" />
</ItemGroup>
</Project>