Course outline

C# / Intermediate: Composition and Asynchronous Work / Lesson 12

Testing and Debugging Contracts

Derive independent checks from behavior and use controlled failures instead of depending on real damaged resources.

About 5 min readContent reviewed
On this page

Lesson goals

  • Design boundary and failure tests with clear oracles.
  • Inject deterministic dependencies and observe async completion.
  • Use compiler, stack, and debugger evidence within their limits.

Test the promise rather than the implementation shape

A useful test compares behavior with an independent expectation derived from the public contract. For a clamp function, test below the lower bound, exactly at both boundaries, and above the upper bound. For a parser, add invalid syntax and a valid prefix followed by garbage. Executing every source line does not mean those requirements were checked.

Use names that identify the condition and expected behavior. Keep fixtures small and deterministic so a failure explains one property. Repeating the implementation’s expression as the expected result can duplicate its bug. A framework can organize tests, but it cannot supply a meaningful oracle automatically.

A dependency-free boundary test

The first complete console program acts as a tiny self-checking test runner. Four known input/output pairs exercise a clamp policy, and a mismatch throws a stable diagnostic. In a real repository these cases can become framework tests, but this example requires no NuGet test framework or external runner.

Table-driven structure reduces duplicated setup without hiding distinct expectations. Add equal and extreme values where the contract permits them. A passing suite samples behavior; it is not a proof of every path, every race, or every platform. Combine it with reasoning about invariants, lifetimes, and validated ranges.

csharp A small table of independent expectations
using System;

(int Input, int Expected)[] cases = { (-1, 0), (0, 0), (100, 100), (101, 100) };
foreach (var item in cases)
{
    if (Clamp(item.Input) != item.Expected) throw new InvalidOperationException("clamp mismatch");
}
Console.WriteLine("4 checks passed");

static int Clamp(int value) => value < 0 ? 0 : value > 100 ? 100 : value;
Expected output
4 checks passed

Every expected result is explicitly known; success is printed only after all four checks pass.

Test errors without exhausting real resources

A narrow interface lets tests supply a controlled failing dependency. The second example overrides TextWriter.Write to throw a synthetic IOException. The caller checks the documented propagation path instead of filling a disk or contacting a failing service. Keep intentionally failing data local and make the expected failure explicit.

Also verify state after failure: preserved previous values, released owned resources, and no extra side effects. A test accepting any exception can miss a NullReferenceException that occurs before the intended write. Match the relevant category and use messages only when exact wording is part of your own stable contract.

csharp A controlled writer failure
using System;
using System.IO;
using System.Text;

using var writer = new FailingWriter();
try
{
    writer.Write("sample");
    throw new InvalidOperationException("expected write failure");
}
catch (IOException)
{
    Console.WriteLine("expected failure observed");
}

internal sealed class FailingWriter : TextWriter
{
    public override Encoding Encoding => Encoding.UTF8;
    public override void Write(string? value) => throw new IOException("synthetic write failure");
}
Expected output
expected failure observed

The synthetic IOException is observed without damaging any filesystem state.

Await the behavior under test

An asynchronous test should return an observable task and await the operation, so failures after suspension are attributed to the test. Avoid async void tests and arbitrary sleeps. Use TaskCompletionSource with deliberate continuation options when a test needs to control readiness, or a small injectable clock/scheduler abstraction when timing is part of the contract.

Cancellation tests need both a stop request and observed completion. Add a bounded test deadline as a guard against hanging, not as the implementation’s synchronization mechanism. Do not dispose fixtures while their tasks remain active. Parallel tests should not mutate a shared global collection or environment setting without isolation.

Find the first broken invariant

Use the exception stack, locals, and debugger breakpoints to inspect the earliest relevant invalid state. The line throwing can be a symptom of an earlier wrong assignment or disposal. Preserve a minimized synthetic input and exact SDK/project settings. Nullable warnings and analyzers provide useful clues but do not prove that external input or concurrency is safe.

Run checks appropriate to the change and keep regressions for each repaired behavior. Coverage measures execution reach; performance measurements answer a different question. A release build can optimize away some source-level details, so use matching symbols and avoid claiming a debugger’s observed storage layout is a universal language guarantee.

Common errors and fixes

A test passes before an async failure happens.

Why it happens
It failed to await the returned task.
How to fix it
Return/await the operation through the test runner’s supported pattern.
Prevent it next time
Include a failure after suspension.

A failure test accepts the wrong exception.

Why it happens
It treats every throw as the expected result.
How to fix it
Assert the documented exception category and state.
Prevent it next time
Inject a precise synthetic failure.

A flaky test uses longer sleeps as a repair.

Why it happens
Scheduling guesses replace completion coordination.
How to fix it
Use explicit readiness/completion signals.
Prevent it next time
Keep time bounds as safeguards, not synchronization.

Check your understanding

1. Design tests for a full bounded queue whose rejected enqueue must preserve existing content.

Show worked solution

Cover empty-with-space, one-before-full, exactly-full, and invalid input under the contract. Verify status, count, ordering, and previous content after rejection. If elements own resources, verify ownership does not transfer on a rejected insertion. Use synthetic items and explicit completion for concurrent variants rather than a timing-only assertion.

2. A nullable warning is suppressed and all current tests pass. What evidence is still missing before declaring the dereference safe?

Show worked solution

Identify the concrete invariant that guarantees a non-null value on every relevant path, including external/legacy input and asynchronous changes. Add tests for missing construction and absence where meaningful, but do not treat sampled tests as a universal proof. If no invariant exists, fix the contract or add a guard rather than relying on !.

Key takeaways

  • An independent oracle and post-failure state make tests meaningful.
  • Observe asynchronous completion and inject controlled failures.
  • Diagnostics guide investigation; invariants explain why the repair is correct.

Official references