Define the observable contract
A stateful contract describes more than the last value returned by a method. It identifies the state before a command, whether the command is accepted, and the state afterward. The Message model starts in Draft, can become Ready through approval, and can become Sent only from Ready. Rejected commands return false without advancing the state. Writing those transitions as a small table reveals cases that a single happy-path sequence would miss, including an early send and a repeated send after the operation has already reached its terminal state.
Walk through the real program
The first Send occurs in Draft and prints early-send:False. Approve then moves Draft to Ready and prints true. Send now moves Ready to Sent and prints true. Calling Send again is rejected because the stage is no longer Ready, producing repeat-send:False. Finally, the model prints Sent. Each result is predictable from the immediately preceding state. The sample uses one deliberate sequence to show the lifecycle; it is not a concurrent delivery service and sends no network message or durable transaction merely because its command is named Send.
Why the design is testable
A transition test should observe both the result and the state effect. A defective implementation might return false but still mutate the stage, or return true without performing the required transition. Separate cases can arrange a fresh initial model for each transition so an earlier failure does not confuse a later observation. Sequence checks remain useful when the sequence itself is the contract, but avoid making unrelated tests depend on one shared mutable object whose prior commands are hidden elsewhere in a suite.
A boundary that matters
The default numeric value of this enum corresponds to Draft, which is why a newly constructed Message begins there. If the enum layout changes, the initial-state policy should remain explicit rather than accidentally switching meaning. An explicit constructor assignment to Draft can document that starting policy and protect the model from a later enum reordering. Also distinguish a logical terminal state from disposing every object that can reference the message. A Sent instance can still be read and tested. This model does not specify retries, acknowledgments, rollback, or access control; adding those behaviors changes the state machine and requires additional transitions instead of undocumented conditionals scattered across callers.
Practice: isolate one behavior
First invoke Approve twice before sending. Predict true for the first approval, false for the second, and a stage that remains Ready until Send succeeds. Hint: write the before-and-after stage for both commands, not only their booleans. Then test a rejected early Send followed by a valid approval and send, proving that the rejection did not corrupt later progress. Use a fresh Message for each independent scenario when translating these observations into individual framework tests.
Practice: challenge another assumption
For another extension, add an explicit Cancel transition that is allowed from Draft or Ready but not from Sent. Describe its result and final state for every source stage before coding it. Hint: derive a transition matrix rather than testing only one successful cancellation. Add a check that every rejected command preserves the previous stage. Finally consider a service that performs a real external send: decide how its in-memory stage relates to a failed network operation instead of assuming the current synchronous transition already provides delivery guarantees.
Review the evidence
The baseline shows five observations tied to one clear lifecycle. A reviewer should be able to replay that sequence on paper and identify the state that justifies each outcome. Reliable state-machine tests combine accepted paths, rejected paths, and invariants that survive both. Keep command acceptance, state mutation, external effects, and concurrency guarantees distinct. As the model grows, explicit transitions and fresh scenario setup make the suite more informative than a long script whose only assertion inspects the final state after many hidden operations.
- Every accepted command has a named source and destination state.
- A rejected command must preserve the model according to this contract.
- The original final state is Sent after the explicit successful sequence.
A working example
using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
var message = new Message();
Console.WriteLine($"early-send:{message.Send()}");
Console.WriteLine($"approve:{message.Approve()}");
Console.WriteLine($"send:{message.Send()}");
Console.WriteLine($"repeat-send:{message.Send()}");
Console.WriteLine(message.Stage);
enum Stage { Draft, Ready, Sent }
sealed class Message
{
public Stage Stage { get; private set; }
public bool Approve() { if (Stage != Stage.Draft) return false; Stage = Stage.Ready; return true; }
public bool Send() { if (Stage != Stage.Ready) return false; Stage = Stage.Sent; return true; }
}
Expected output
early-send:False approve:True send:True repeat-send:False Sent
Name the initial state, command, result and final state for each transition.