Skip to main content

Contracts as property tests

Pledge.Testing turns the contracts you already wrote into property-based tests. For each contracted method, CsCheck generates a receiver and arguments, the preconditions throw away the inputs they reject, and the postconditions are the oracle. A failure is shrunk to the smallest input that breaks the contract.

dotnet add package Pledge.Testing --prerelease
using PledgeContracts.Testing;

[Fact]
public void Account_pledges_hold() => PledgeProperties.Check<Account>(iterations: 1_000);

A failure names the contract, the shrunk input and how to replay it:

RoundingAccount.Withdraw(decimal) broke its pledge. Postcondition of RoundingAccount.Withdraw violated: Balance == Old(Balance) - amount
Counterexample:
receiver = new RoundingAccount(542m)
amount = 0.00000000000000000001701m
Replay this input with Seed = "0VtUVmxeZKh1" and Iterations = 1. CsCheck: 3 shrinks, 38 skipped, 100 total.

That bug, a withdrawal rounded to cents, passes every unit test written with whole cents. The property test finds it on the first run.

One test per method​

The xUnit.net v3 and TUnit adapters make each contracted method its own test case:

dotnet add package Pledge.Testing.Xunit --prerelease # or Pledge.Testing.TUnit
using PledgeContracts.Testing.Xunit;

[Theory, PledgedMembers(typeof(Account), Iterations = 1_000)]
public Task Account_pledges_hold(PledgeCase pledge) => pledge.CheckAsync();

With TUnit, use [Test, PledgedMembers(typeof(Account))] from PledgeContracts.Testing.TUnit. A method whose parameters cannot be generated, such as one taking a Span<T>, shows up as a skipped test with the reason.

Generated inputs​

Receivers are built through their widest public constructor. Numbers, strings, booleans, enums, dates, Guid, records, nullable values, arrays and the common collection interfaces are generated out of the box. Supply a generator for anything else, or to aim the inputs:

using CsCheck;

PledgeProperties.Check<Account>(new PledgePropertyOptions { Iterations = 1_000 }.Use(Gen.Decimal[0m, 10_000m]));

Use<T> replaces the generator for every value of type T, constructor arguments included. PledgePropertyOptions also sets the seed, the number of threads, and whether an exception no EnsuresOnThrow declares counts as a failure (it does by default).

Release builds​

The probes the tests run are emitted unless every kind of contract is Ignore, which is the Release default. If your tests run against a Release build of the project under test, set <PledgeRegistry>true</PledgeRegistry> in that project.