CsCheck Random Testing Library

repository·master·Indexed 42 days ago

https://github.com/anthonylloyd/cscheck

A high-performance C# random testing library inspired by QuickCheck. It features the PCG random number generator for fast, parallelized testing and automatic shrinking to find minimal failing test cases. Supports a wide range of testing methodologies including model-based testing via SampleModelBased, metamorphic testing via SampleMetamorphic, specification testing with Spec, and statistical performance verification using the Faster method.

Tokens
17.8K
Snippets
34
Records
67
Agent score
54%

What's inside CsCheck

  1. Understand the benefits of random testing with CsCheck

    master

    CsCheck is a random testing library for C# that provides a more automated and powerful alternative to traditional example-based testing. Instead of manually writing a few test cases, you define generators that allow the library to run hundreds or thousands of examples with varying sizes and edge cases (e.g., empty collections, values summing to zero).

    Key benefits include:

    • Automated Edge Case Discovery: Finds quirky or large examples that developers might overlook.
    • Parallel Execution: Tests run in parallel by default, allowing millions of examples to be processed in a short timeframe (e.g., 60 seconds).
    • Shrinking: When a bug is found, CsCheck automatically shrinks the failing input to the smallest possible example, making reproduction and diagnosis significantly easier.
    • Stronger Guarantees: A test that runs a vast number of generated examples provides a much stronger claim of correctness than a handful of manual examples.
  2. Understand the design philosophy of CsCheck Specification Testing

    master

    CsCheck's Spec testing is designed to address a gap in existing property-based testing (PBT) tools. While most PBT libraries (like Hypothesis, ScalaCheck, or FsCheck) focus on checking properties one transition at a time (per-command postconditions), CsCheck allows for specifications that are not tied to a single transition.

    Key differentiators include:

    • Soundness on Cycles: Unlike some model checkers where 'eventually' properties might fail on cycles, CsCheck incorporates obligations (like response deadlines) into the search state. This ensures that cycles carrying unmet obligations are correctly reported.
    • Unified Specification: A single specification object is shared between the exhaustive engine and the shrinking random engine.
    • Vacuity Detection: CsCheck identifies and reports when a requirement passes simply because its antecedent (the condition required to trigger the check) never occurred. This is presented as a default feature in every run.
    • State-Space Reduction: Symmetry reduction is achieved by defining a custom Equals implementation for your state type S, effectively treating symmetry as a coarser equality.
  3. Optimize model size and performance

    master

    The performance and memory consumption of a specification are primarily determined by the state S.

    Memory Constraints:

    • The binding constraint is memory, not CPU.
    • The default maxStates is 10,000,000, which consumes approximately 2GB to 3GB of RAM depending on the state width.
    • Memory usage is roughly 170-305 bytes per state.

    Performance Optimization (Keep S narrow):

    • Width in bytes: Every guard, transition, and requirement predicate receives S by value. A wide state (many unused fields) increases the cost of every single operation due to copying.
    • Distinct values: A state with many distinct values (wide state space) makes it harder for the engine to close the space.
    • Optimization Tip: If a model is slow, check for unused fields. Packing small fields into a single int (e.g., using bits for booleans) can significantly improve ns/transition speed.
  4. How `Exhaustive` and `Conform` work together

    master

    The CsCheck methodology relies on two distinct but complementary processes:

    1. Exhaustive testing: Proves requirements about the model. It ensures that the logic defined in your specification is internally consistent and meets the intended requirements.
    2. Conform testing: Samples state equality between the model and the actual engine. It finds divergences where the engine's state differs from the model's state.

    Critical Note: Exhaustive does not prove the engine is correct; it only proves the model is correct. Conform does not prove requirements are met; it only proves the engine matches the model's state. A requirement proved in Exhaustive is not automatically true of the engine unless Conform also validates the state transitions.

  5. Use NeverAfter to specify requirements that must not occur after an event

    master

    The NeverAfter requirement is used to specify that once a certain condition or event occurs, a specific subsequent state or action must never happen. This is the logical mirror of the Precedes requirement.

    To ensure that history-carrying requirements (like NeverAfter) are tracked independently for different entities, use the per: option to parameterize the requirement by a domain. This prevents a single global bit of history from causing unrelated entities to discharge each other's obligations.

  6. Understand the four ways to check a specification

    master

    CsCheck uses the Spec abstraction to define the intended behavior of a stateful system. Once a specification is written, you can verify it using four distinct testing modes, each offering different guarantees and costs:

    1. Exhaustive: Enumerates the entire reachable state space. If the state space closes, your requirements are mathematically proved for the model. This is very fast for protocol state machines.
    2. Sample: Performs random walks through the state space with CsCheck shrinking. This is used for models too large to be exhausted, using a standard iter/time budget.
    3. Faults: Injects declared defects into the model and reports which requirement catches them. This acts as mutation testing for your specification to ensure your requirements are actually meaningful and capable of catching errors.
    4. Conform: Drives a real-world implementation through the same state walks generated by the spec to ensure the actual code conforms to the specification.

    Recommended Workflow: Start with Exhaustive to prove the model, but prioritize Faults next. Faults ensures your requirements aren't passing 'for the wrong reason' or passing vacuously (e.g., a requirement that can never fail).

    /* The relationship between testing modes: */
    // spec -> Exhaustive -> (if closed) -> Faults -> Conform
    // spec -> Exhaustive -> (if not closed) -> Boundary -> Exhaustive
    // spec -> Exhaustive -> (if not closed) -> Sample -> SampleFaults -> Conform
  7. Understanding Exhaustive Specification Testing Results

    master

    When running Spec.Exhaustive, the engine performs a breadth-first walk of the reachable state space. A CLOSED state space indicates a formal proof for the abstracted model, meaning every reachable state has been visited and no violations were found.

    Key Metrics in the Report

    • Triggered: The number of times a requirement's condition was met.
      • Crucial Note: If a requirement shows NEVER or a low number, it may mean the 'when' or 'on' trigger was never actually exercised, meaning the requirement hasn't actually been tested.
      • Invariants and Rules without specific triggers apply to every step.
    • Unresolved (Sample mode only): Response obligations that were still outstanding when the trace ended. This does not indicate a failure, but suggests traces should be run longer.
    • deadlock: The number of states with no enabled actions that were not explicitly declared Terminal. In a protocol model, this should ideally be zero. If non-zero, use report.DeadlockTrace to find the path to the dead end.
    • Fired: The count for each (action, argument) case. This is more granular than counting per action. A NEVER here means a specific argument case (e.g., a specific error code in a Recv action) never ran.

    Interpreting Counterexamples

    If the search does not close, the engine provides the shortest path to the violation due to the breadth-first search approach. The trace will show the sequence of actions and the state of requirements (e.g., exp for expected, out for outstanding) leading to the failure.

  8. Use Specification Testing with `Spec`

    master

    Specification testing allows you to define requirements for a state machine (a protocol, rules, or regulations) and verify them. You define a specification using Spec.From(initialState) and then chain several types of definitions:

    • Action: Defines a transition. Includes a predicate for when the action is valid, the transition logic, and an optional weight for sampling.
    • Rule: Defines a requirement that must hold. Uses when (the condition) and then (the expected outcome).
    • Response: Defines a temporal requirement. It specifies a trigger condition and a response that must occur within a certain interval, checked per a specific action (e.g., per: "Tick").
    • Never: Defines a negative requirement (e.g., "this should never happen").
    • Reachable: Defines a goal state that must be reachable from the initial state.

    Once defined, you can run the specification using different modes:

    • Exhaustive: Proves the requirements for the entire reachable state space (breadth-first).
    • Sample: Performs random walks for large models.
    • Faults: Injects defects to see if your requirements actually catch them (mutation testing for specs).
    • Conform: Drives a real implementation through the same traces used by the specification to ensure the implementation matches the model.
    Spec.From(State.Connected)
    .Action("Recv", Inbound, (s, _) => s.Status != Disconnected, (s, m) => s.Inbound(m), weight: 30)
    .Action("Tick", s => s.Status != Disconnected, s => s.Tick(), weight: 20)
    .Rule("SEQ-TOO-LOW-FATAL",
        "MsgSeqNum lower than expected without PossDupFlag set to Y is a fatal error: send a Logout and terminate.",
        when: (b, a) => b.Up && a.RecvSeq == Seq.TooLow,
        then: (b, a) => a.Put(Out.Logout) && a.Status == Disconnected)
    .Response("LOGOUT-COMPLETES",
        "The initiator of a Logout waits for the confirming Logout, and terminates anyway if it does not arrive.",
        trigger:  (b, a) => a.Status == LogoutSent && b.Status != LogoutSent,
        response: (b, a) => a.Status == Disconnected,
        within: Interval + 1, per: "Tick")
    .Never("DISCONNECTED-SILENT", "No message is sent on a terminated connection.",
        (b, a) => b.Status == Disconnected && a.Sent != Out.None)
    .Reachable("CAN-LOG-ON", "A session can reach the logged on state at all.",
        s => s.Status == LoggedOn);
  9. Implement anti-degenerate safety requirements like `LIVE-HOLDER-NEVER-REFUSED`

    master

    Every safety property (which defines what must not happen) should be accompanied by an 'anti-degenerate twin' (which defines what must happen).

    A safety property alone might be satisfied by a system that does nothing (e.g., a resource that rejects every write satisfies 'no two clients write simultaneously'). To prevent this, implement a requirement like LIVE-HOLDER-NEVER-REFUSED, which asserts that a client currently holding a valid lease is never refused service. This ensures the system is actually functional and not just 'safe' by being broken.

  10. Understand the difference between CsCheck and formal proof assistants

    master

    CsCheck provides a middle ground between manual implementation and formal proof assistants (like Lean or Coq).

    • Formal Proofs (Lean/Coq): Prove a model, but the gap between the model and the actual C# code is manual and unchecked. Protocol bugs often live in this gap.
    • CsCheck (Exhaustive + Conform): Exhaustive proves a model written in standard C# (making it reviewable by the team). Conform then mechanically closes the gap by comparing the engine to the model on every CI run.

    While the proof is technically weaker (it holds for the abstraction and bounded counters), the end-to-end claim is stronger because the connection between model and code is automated.

  11. Pick the smallest domain to falsify requirements

    master

    When defining argument domains, do not aim for realism; aim for the smallest set of values that can actually make a requirement false.

    • Avoid single-element domains for logic testing: If a requirement depends on a relationship between two values (like Paid and Refunded), a domain of size 1 (e.g., 0 or 1) might pass even if the logic is flawed. Adding a second element (e.g., [0, 1, 2]) can falsify the requirement.
    • Use integer indices for arguments: Instead of using Gen<T> (generators), declare argument domains as small arrays. Arguments are addressed by an integer index. This allows Exhaustive to enumerate them, Sample to draw from them, and Transition.ArgIndex to recover typed values for Conform.
  12. Customize state equality and symmetry reduction

    master

    The Exhaustive engine determines if it has found a new state by checking equality. You can override Equals and GetHashCode to control what constitutes a distinct state.

    Symmetry Reduction

    You can implement symmetry reduction by canonicalizing interchangeable subjects within your Equals and GetHashCode methods. This collapses multiple states into one, saving significant computation time.

    Warning:

    1. Soundness: If you use symmetry reduction, your requirements must also be symmetric. If a requirement names a specific subject (e.g., "Client One is never refused"), collapsing Client One and Client Two into one state will hide failures, making the proof silently false.
    2. Precision: An Equals implementation that is too coarse will merge genuinely different states, leading to false proofs. An implementation that is too fine will simply increase the state space and slow down the search.
    // Example of symmetry reduction: Two clients that differ only in which is which are treated as one state.
    public bool Equals(State other)
        => Holder == other.Holder && Fenced == other.Fenced
        && (One.Equals(other.One) && Two.Equals(other.Two)
         || One.Equals(other.Two) && Two.Equals(other.One));