JustDummies
Just dummies — but seriously powerful ones.
Focused test values through a fluent API, for .NET.
string reference = Any.String()
.AlphaNumeric()
.InUpperCase()
.StartingWith("ORD-")
.WithLengthBetween(8, 20)
.Generate();about 1.2 MB, downloaded only if you ask
.NET CLI
dotnet add package JustDummies --prereleasePackage Manager
Install-Package JustDummies -IncludePrereleaseThe value your test does not care about
It still has to be valid.
Your test probably looks like this
[Fact]
public void A_pending_order_can_be_cancelled() {
// Arrange
OrderReference anyReference = OrderReference.Create("ORD-54XEM4545");
CustomerId anyCustomerId = CustomerId.Create(Guid.NewGuid());
Money anyTotal = Money.Create(42.00m);
Order order = new Order(anyReference, anyCustomerId, anyTotal, OrderStatus.Pending);
// Act
order.Cancel();
// Assert
Assert.Equal(OrderStatus.Cancelled, order.Status);
}What is this test about? A pending order can be cancelled — but you have to go looking to see it. Three of its four arrangement lines build a reference, a customer and a total that the test never mentions again: the constructor demands them, that is all. And they lie: ORD-54XEM4545 and 42.00 read as values somebody chose, when any valid ones would have done. The subject of the test is the last argument of the line that builds the order.
A first tidy-up
[Fact]
public void A_pending_order_can_be_cancelled_with_factories() {
// Arrange
OrderReference anyReference = AnyOrderReference.Generate();
CustomerId anyCustomerId = AnyCustomerId.Generate();
Money anyTotal = AnyMoney.Generate();
Order order = new Order(anyReference, anyCustomerId, anyTotal, OrderStatus.Pending);
// Act
order.Cancel();
// Assert
Assert.Equal(OrderStatus.Cancelled, order.Status);
}
public static class AnyOrderReference {
public static OrderReference Generate() {
return OrderReference.Create("ORD-54XEM4545");
}
}
// ... and AnyCustomerId and AnyMoney, which say the same thingBetter already: the factories say any, the variables say it too, and the arrangement is three lines that announce their intent. A good start — except nothing moved underneath. AnyOrderReference still returns the string it always did: the code announces any and hands you one, always the same one. The lie did not go away, it moved to another file.
Making the factory tell the truth
using JustDummies;
public static class AnyOrderReference {
public static OrderReference Generate() {
return OrderReference.Create(Any.String().Generate());
}
}The factory now calls the library: Any.String() draws a genuinely arbitrary string, different on every run. The name AnyOrderReference stops lying. Drawing at random is surprising, but a value you typed proves only one thing: that the test passes with that one. The domain refuses the string it drew. That string does not start with ORD-, and OrderReference.Create says so as the value is built, not three assertions later.
What the domain is asking for
public static OrderReference Create(string value) {
ArgumentException.ThrowIfNullOrWhiteSpace(value);
if (!value.StartsWith("ORD-", StringComparison.Ordinal)) {
throw new ArgumentException("An order reference must start with ORD-.", nameof(value));
}
if (value.Length < 8) {
throw new ArgumentException("An order reference cannot be shorter than 8 characters.", nameof(value));
}
if (value.Length > 20) {
throw new ArgumentException("An order reference cannot exceed 20 characters.", nameof(value));
}
if (!value[4..].All(character => char.IsAsciiLetterUpper(character) || char.IsAsciiDigit(character))) {
throw new ArgumentException("An order reference holds only uppercase letters and digits after ORD-.", nameof(value));
}
return new OrderReference(value);
}These rules are not exotic, and they are written where they belong. But every one of them has to be satisfied. It is the generator's job to satisfy them, without the test having to mention any of it.
Declare the constraints, not the value
using JustDummies;
public static class AnyOrderReference {
public static OrderReference Generate() {
string reference = Any.String()
.AlphaNumeric()
.InUpperCase()
.StartingWith("ORD-")
.WithLengthBetween(8, 20)
.Generate();
return OrderReference.Create(reference);
}
}Every business rule becomes a call in the chain: starts with ORD-, uppercase alphanumeric after it, between eight and twenty characters long. The value it produces changes on every run, and it is valid every time. This is where drawing at random makes sense. That value was never the subject of the test, it only had to be valid. Any value that satisfies the rules will do. You describe what the value must satisfy, not what you are going to assert.
Install it now
.NET CLI
dotnet add package JustDummies --prereleaseThe xUnit adapter
View on NuGet — The xUnit adapteropens in a new tabdotnet add package JustDummies.Xunit --prereleasePackage Manager
Install-Package JustDummies -IncludePrereleaseThe xUnit adapter
View on NuGet — The xUnit adapteropens in a new tabInstall-Package JustDummies.Xunit -IncludePrereleaseEverything above is the library on its own. If that is what you came for, install it now. Take the adapter with it: it makes your draws replayable, and the page comes back to that further down. What follows shows how all this setup disappears.
Simpler still
A tool reads your type and writes the generator. The file it produces is yours.
What we would like to write
[Fact, Reproducible]
public void A_pending_order_can_be_cancelled() {
Order order = CreateAnyPendingOrder(); // one line, and this is the one we want
order.Cancel();
Assert.Equal(OrderStatus.Cancelled, order.Status);
}CreateAnyPendingOrder() replaces the three lines of arrangement, and the test says only what matters: the order is pending. You can write that helper yourself: it holds the chain of constraints you have just written, in a file of your own test project. The day Order gains a parameter, you are the one reopening that file. What follows is a tool that writes it, and rewrites it.
The tool reads your type and writes the generator
dum generate OrderAnalyzing JustDummies.SnippetValidation.Domain.Order
constructor Order(OrderReference, CustomerId, Money, OrderStatus)
reference OrderReference Any.String().WithLengthBetween(8, 20).As(OrderReference.Create) to verify, factory, guard, unread guards
customerId CustomerId Any.Guid().NonEmpty().As(CustomerId.Create) factory, guard
total Money Any.Decimal().Positive().As(Money.Create) factory, guard
status OrderStatus Any.Enum<OrderStatus>()
✓ AnyOrder.cs — 4 of 4 parameters inferred, 1 to verify.
The file will not compile until you resolve it. That is deliberate.dum is a global .NET tool. You run it once per type. It reads your own source and decides, parameter by parameter, how to draw a value. The last column says what it worked out on its own, and where it stopped.
Generated to help you, and yours to change
// Scaffolded by dum (JustDummies). This file is yours: read it, edit it, commit it.
// `dum generate Order --force` overwrites it. This type is partial, so members you add in a
// neighbouring file survive.
using JustDummies;
namespace JustDummies.SnippetValidation.Domain;
/// <summary>
/// A generator of arbitrary <see cref="Order" /> values. It draws from the ambient random
/// context, so a reproducibility scope pins it; to draw from an isolated
/// <c>Any.WithSeed(...)</c> context, pass that context's generators through the
/// <c>With…</c> overloads.
/// </summary>
public sealed partial class AnyOrder : IAny<Order> {
private readonly IAny<OrderReference> _reference;
private readonly IAny<CustomerId> _customerId;
private readonly IAny<Money> _total;
private readonly IAny<OrderStatus> _status;
/// <summary>Creates the generator with a default recipe for every constructor parameter.</summary>
public AnyOrder()
: this(reference: ReferenceFactory(),
customerId: CustomerIdFactory(),
total: TotalFactory(),
status: StatusFactory()) { }
private static IAny<OrderReference> ReferenceFactory() {
// TODO(dum): 'OrderReference reference' may be guarded by something dum could not read (§9).
// This is dum's best generator for the type; verify it honours the real invariant,
// or replace it, then delete the line below.
_ = TODO_verify_the_generator_for_reference;
return Any.String().WithLengthBetween(8, 20).As(OrderReference.Create);
}
private static IAny<CustomerId> CustomerIdFactory() {
return Any.Guid().NonEmpty().As(CustomerId.Create);
}
private static IAny<Money> TotalFactory() {
return Any.Decimal().Positive().As(Money.Create);
}
private static IAny<OrderStatus> StatusFactory() {
return Any.Enum<OrderStatus>();
}
private AnyOrder(IAny<OrderReference> reference,
IAny<CustomerId> customerId,
IAny<Money> total,
IAny<OrderStatus> status) {
_reference = reference;
_customerId = customerId;
_total = total;
_status = status;
}
/// <summary>Pins <c>reference</c> to a fixed value.</summary>
public AnyOrder WithReference(OrderReference value) {
return WithReference(new FixedValue<OrderReference>(value));
}
/// <summary>Draws <c>reference</c> from <paramref name="generator" />.</summary>
public AnyOrder WithReference(IAny<OrderReference> generator) {
return new AnyOrder(generator, _customerId, _total, _status);
}
/// <summary>Pins <c>customerId</c> to a fixed value.</summary>
public AnyOrder WithCustomerId(CustomerId value) {
return WithCustomerId(new FixedValue<CustomerId>(value));
}
/// <summary>Draws <c>customerId</c> from <paramref name="generator" />.</summary>
public AnyOrder WithCustomerId(IAny<CustomerId> generator) {
return new AnyOrder(_reference, generator, _total, _status);
}
/// <summary>Pins <c>total</c> to a fixed value.</summary>
public AnyOrder WithTotal(Money value) {
return WithTotal(new FixedValue<Money>(value));
}
/// <summary>Draws <c>total</c> from <paramref name="generator" />.</summary>
public AnyOrder WithTotal(IAny<Money> generator) {
return new AnyOrder(_reference, _customerId, generator, _status);
}
/// <summary>Pins <c>status</c> to a fixed value.</summary>
public AnyOrder WithStatus(OrderStatus value) {
return WithStatus(new FixedValue<OrderStatus>(value));
}
/// <summary>Draws <c>status</c> from <paramref name="generator" />.</summary>
public AnyOrder WithStatus(IAny<OrderStatus> generator) {
return new AnyOrder(_reference, _customerId, _total, generator);
}
/// <summary>Produces one arbitrary <see cref="Order" />.</summary>
public Order Generate() {
return new Order(_reference.Generate(),
_customerId.Generate(),
_total.Generate(),
_status.Generate());
}
private sealed class FixedValue<TValue> : IAny<TValue> {
private readonly TValue _value;
public FixedValue(TValue value) {
_value = value;
}
public TValue Generate() {
return _value;
}
}
}The tool writes the whole file: the fields, a factory per parameter, the draw. It cannot read the ORD- prefix rule, so it wrote its best generator rather than guess, and planted a line that does not compile beside it. The file does not build until you have looked. You delete it and add .AlphaNumeric(), .InUpperCase() and .StartingWith("ORD-") to the reference factory, and that is the chain you already wrote, unchanged. The file is yours: read it, edit it, commit it.
A test that is explicit at last, and does not lie
[Fact, Reproducible]
public void A_pending_order_can_be_cancelled() {
Order order = Any.Order().WithStatus(OrderStatus.Pending).Generate();
order.Cancel();
Assert.Equal(OrderStatus.Cancelled, order.Status);
}Same test as before, right down to the assertion. The arrangement is one line, and that line names the only thing the test needs: the order is pending. The rest is drawn on every run, and stays valid. Nothing here is called any while handing back the same value every time.
Install all of it
.NET CLI
dotnet add package JustDummies --prereleaseThe xUnit adapter
View on NuGet — The xUnit adapteropens in a new tabdotnet add package JustDummies.Xunit --prereleaseThe scaffolding tool
View on NuGet — The scaffolding toolopens in a new tabdotnet tool install --global JustDummies.Cli --prereleasePackage Manager
Install-Package JustDummies -IncludePrereleaseThe xUnit adapter
View on NuGet — The xUnit adapteropens in a new tabInstall-Package JustDummies.Xunit -IncludePrereleaseThe scaffolding tool
View on NuGet — The scaffolding toolopens in a new tabA global .NET tool is installed from the command line.
The tool is optional. The library on its own already made all of this possible. The tool only saves you the writing.
A question comes up here on its own: if the values change on every run, how do you get back the one that made a test fail?
A draw you can replay exactly
The values change on every run. The day one of them makes a test fail, you get that exact one back.
Catching a bug before it reaches production
[Fact, Reproducible]
public void A_pending_order_can_be_cancelled() {
Order order = Any.Order().WithStatus(OrderStatus.Pending).Generate();
order.Cancel();
Assert.Equal(OrderStatus.Cancelled, order.Status);
}Your build goes red although nothing changed in the code. The value drawn that day found a case your code does not hold. That is a bug that would have shipped. The draw is not lost: the next two scenes get it back exactly, in one line.
Take an example
[Fact, Reproducible]
public void A_pending_order_can_be_cancelled() {
Order order = new AnyOrder().Generate();
order.Cancel();
Assert.Equal(OrderStatus.Cancelled, order.Status);
}This test is the same, but the status is left arbitrary. Two of the three statuses cannot be cancelled, so it goes red on roughly two runs in three. Nothing is broken. The test has just found out it was not saying what it needed.
The failing test tells you how to replay it
Ordering.Tests.OrderCancellationReplayed.A_pending_order_can_be_cancelled [FAIL]
System.InvalidOperationException : Only a pending order can be cancelled.
Output:
[JustDummies] These arbitrary values were seeded with -1808250554. Reproduce this run with [Reproducible(Seed = -1808250554)].The test that failed writes one line into your build's output. That line carries a number: the seed. That number is all it takes to draw exactly the same values again.
Paste it, and you get the same draw back
[Fact, Reproducible(Seed = -1808250554)]
public void A_pending_order_can_be_cancelled() {
Order order = new AnyOrder().Generate();
order.Cancel();
Assert.Equal(OrderStatus.Cancelled, order.Status);
}Ordering.Tests.OrderCancellationReplayed.A_pending_order_can_be_cancelled [FAIL]
System.InvalidOperationException : Only a pending order can be cancelled.
Output:
[JustDummies] These arbitrary values were seeded with -1808250554. Reproduce this run with [Reproducible(Seed = -1808250554)].You paste the seed your build reported into the test, and the failure comes back on your machine. These are the values that failed, not values that resemble them. Each test case draws its own seed, so a suite running in parallel hands you back the seed of the case that failed.
Want to try it?
Three packages. None of them is large, and you have seen what each one does.
.NET CLI
dotnet add package JustDummies --prereleaseThe xUnit adapter
View on NuGet — The xUnit adapteropens in a new tabdotnet add package JustDummies.Xunit --prereleaseThe scaffolding tool
View on NuGet — The scaffolding toolopens in a new tabdotnet tool install --global JustDummies.Cli --prereleasePackage Manager
Install-Package JustDummies -IncludePrereleaseThe xUnit adapter
View on NuGet — The xUnit adapteropens in a new tabInstall-Package JustDummies.Xunit -IncludePrereleaseThe scaffolding tool
View on NuGet — The scaffolding toolopens in a new tabA global .NET tool is installed from the command line.
The adapter turns a red test into a draw you can replay. It is the smallest of the three packages. All three are here.