Compare commits

..
6 Commits
Author SHA1 Message Date
mostalive 06a91a582e Make solution folders for warmup and integration 2026-09-03 20:41:33 +01:00
Your Name e8748ee697 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
2026-09-03 13:50:38 +00:00
mostalive 3b7e880717 add mise.toml for dotnet version 2026-09-03 14:16:02 +01:00
mostalive 4c0f1dd541 Let custom matcher provide clear failure messages 2026-09-03 14:06:24 +01:00
mostalive c6c5829748 pin .net version 2026-09-03 14:04:17 +01:00
mostalive 1736cd1abd add test and production code to solution
so that we can build it
2026-09-03 11:29:25 +01:00
13 changed files with 358 additions and 19 deletions
+1
View File
@@ -17,3 +17,4 @@ obj/
## NuGet local config (workaround for locked ~/.nuget) ## NuGet local config (workaround for locked ~/.nuget)
nuget.config nuget.config
*.log
+28
View File
@@ -6,6 +6,14 @@ 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
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Integration", "Integration", "{83A6B86A-4CD0-44BE-9FE0-408AC31E69F8}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Warmup", "Warmup", "{55470487-3CE4-4FD7-BADF-35F7D1473CA5}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -14,4 +22,24 @@ Global
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{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.Deploy.0 = Debug|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.Build.0 = Debug|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
GlobalSection(NestedProjects) = preSolution
{C3D4E5F6-A7B8-9012-CDEF-123456789012} = {83A6B86A-4CD0-44BE-9FE0-408AC31E69F8}
{D4E5F6A7-B8C9-0123-DEFA-234567890123} = {83A6B86A-4CD0-44BE-9FE0-408AC31E69F8}
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890} = {55470487-3CE4-4FD7-BADF-35F7D1473CA5}
{B2C3D4E5-F6A7-8901-BCDE-F12345678901} = {55470487-3CE4-4FD7-BADF-35F7D1473CA5}
EndGlobalSection
EndGlobal EndGlobal
+6
View File
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.400",
"rollForward": "latestFeature"
}
}
+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.
+2
View File
@@ -0,0 +1,2 @@
[tools]
dotnet = "10"
@@ -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>
@@ -12,9 +12,7 @@ namespace OrderTests;
public static class Has public static class Has
{ {
public static IResolveConstraint OrderState(decimal expectedTotal, decimal[] expectedLineValues) public static IResolveConstraint OrderState(decimal expectedTotal, decimal[] expectedLineValues)
{ => new OrderStateConstraint(expectedTotal, expectedLineValues);
return new OrderStateConstraint(expectedTotal, expectedLineValues);
}
} }
public class OrderStateConstraint : Constraint public class OrderStateConstraint : Constraint
@@ -23,27 +21,54 @@ public class OrderStateConstraint : Constraint
private readonly decimal[] _expectedLineValues; private readonly decimal[] _expectedLineValues;
public OrderStateConstraint(decimal expectedTotal, decimal[] expectedLineValues) public OrderStateConstraint(decimal expectedTotal, decimal[] expectedLineValues)
: base($"order with total {expectedTotal} and line values [{string.Join(", ", expectedLineValues)}]")
{ {
_expectedTotal = expectedTotal; _expectedTotal = expectedTotal;
_expectedLineValues = expectedLineValues; _expectedLineValues = expectedLineValues;
} }
public override string Description =>
$"order with total {_expectedTotal} and line values [{string.Join(", ", _expectedLineValues)}]";
public override ConstraintResult ApplyTo<TActual>(TActual actual) public override ConstraintResult ApplyTo<TActual>(TActual actual)
{ {
var order = (Order)(object)actual!; var order = (Order)(object)actual!;
var actualTotal = order.Total; var actualTotal = order.Total;
var actualLineValues = order.Lines.Select(l => l.Value).ToArray(); var actualLineValues = order.Lines.Select(l => l.Value).ToArray();
var totalMatch = actualTotal == _expectedTotal; var details = new List<string>();
var linesMatch = actualLineValues.SequenceEqual(_expectedLineValues); if (actualTotal != _expectedTotal)
details.Add($"Total: expected {_expectedTotal}, but was {actualTotal}");
if (!actualLineValues.SequenceEqual(_expectedLineValues))
details.Add(
$"Line values: expected [{string.Join(", ", _expectedLineValues)}], " +
$"but was [{string.Join(", ", actualLineValues)}]");
var messages = new List<string>(); return new OrderStateResult(this, actual!, details.Count == 0, details);
if (!totalMatch)
messages.Add($"Total: expected {_expectedTotal}, got {actualTotal}");
if (!linesMatch)
messages.Add($"Line values: expected [{string.Join(", ", _expectedLineValues)}], got [{string.Join(", ", actualLineValues)}]");
return new ConstraintResult(this, actual, totalMatch && linesMatch);
} }
}
/// <summary>
/// A ConstraintResult that carries the per-field mismatch details and
/// writes them as additional lines in the failure message, e.g.
///
/// Expected: order with total 210 and line values [160, 50]
/// But was: &lt;Order&gt;
/// Total: expected 210, but was 250
/// Line values: expected [160, 50], but was [200, 50]
/// </summary>
private sealed class OrderStateResult : ConstraintResult
{
private readonly IReadOnlyList<string> _details;
public OrderStateResult(Constraint constraint, object? actual, bool isSuccess, IReadOnlyList<string> details)
: base(constraint, actual, isSuccess)
{
_details = details;
}
public override void WriteAdditionalLinesTo(MessageWriter writer)
{
foreach (var detail in _details)
writer.WriteMessageLine(detail);
}
}
}
@@ -7,8 +7,8 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="NUnit" Version="3.13.3" /> <PackageReference Include="NUnit" Version="4.6.1" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" /> <PackageReference Include="NUnit3TestAdapter" Version="6.3.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\OrderDomain\OrderDomain.csproj" /> <ProjectReference Include="..\..\src\OrderDomain\OrderDomain.csproj" />
@@ -18,7 +18,7 @@ public class OrderTotalTests
Assert.That(order, Has.OrderState( Assert.That(order, Has.OrderState(
expectedTotal: 210m, expectedTotal: 210m,
expectedLineValues: new[] { 160m, 50m })); expectedLineValues: [160m, 50m]));
} }
[Test] [Test]