Testing C# events means checking the notification contract, not merely checking that an event can be subscribed to. Useful assertions establish which payload is delivered, how many times it arrives, whether removal changes later behavior, and what happens when a synchronous subscriber fails. These observations are more informative than a console message announcing that a handler ran.
Asynchronous reactions introduce a second question: what represents completion? Sleeping for an arbitrary interval cannot answer that reliably. A test needs an observable completion boundary or a controlled dependency. It also needs to distinguish success, cancellation, and failure instead of treating any returned task as proof that the intended reaction finished.
Documentation was checked online on October 11, 2026, against the C# specification and .NET 10 API reference. The three complete console examples use the standard library only, with explicit assertions that throw on failure. They are documented examples, not compiled programs or executed test results; no test framework or package version is implied.
Test the contract before choosing a test helper
Start with a short description of observable behavior. For a revision notification, a successful change might send one argument object carrying the new revision. Invalid input might throw before changing state or notifying. No subscribers might be valid. Removal might prevent inclusion in a later raise.
Those are distinct assertions. A test that only increments a counter can prove invocation count but not payload correctness. A test that only compares the payload cannot detect duplicate delivery. A test that subscribes after performing the operation may miss the notification entirely while still inspecting valid publisher state.
The event specification establishes the language subscription boundary. It does not supply the business contract under test. An event is a delegate-typed member, not a delegate type, and a notification's name does not establish exactly when it must be raised.
The decoupling explanation gives the architectural context for subscribers reacting without the publisher naming their concrete classes. Tests should preserve that boundary by observing public behavior. They do not need a public getter exposing the publisher's entire backing delegate list.
| Behavior | Useful observation | What that observation does not prove |
|---|---|---|
| Payload | Captured values match the operation | Every subscriber received them |
| Count | Exact number of callbacks | Deep immutability of arguments |
| Removal | A subsequent raise excludes the handler | Previously dispatched work stopped |
| Failure | Caller receives specified exception | Publisher state was rolled back |
| Async completion | Explicit task or signal completes | Automatic background-thread execution |
| Lifetime | Registration ownership is balanced | Actual garbage-collection timing |
The table helps keep a test's conclusion proportional to its evidence. A narrowly scoped test can be valuable without proving every property of an event system. Problems arise when its result is described as a universal lifetime, delivery, or concurrency guarantee.
Assert payload, count, sender, and later removal
The first example checks a synchronous counter notification. The publisher increments before notifying and considers zero listeners valid. The test observes public state and the callback's arguments, then removes the exact handler before another change. No reflection or access to the backing event field is required.
#nullable enable
using System;
var counter = new ObservableCounter();
int calls = 0;
object? observedSender = null;
CountChangedArgs? observedArgs = null;
bool stateWasUpdatedDuringCallback = false;
EventHandler<CountChangedArgs> handler = (sender, args) =>
{
calls++;
observedSender = sender;
observedArgs = args;
stateWasUpdatedDuringCallback = counter.Value == args.Value;
};
counter.Changed += handler;
try
{
counter.Increment();
Require(calls == 1, "One increment must send one notification.");
Require(ReferenceEquals(observedSender, counter), "Sender must be the counter.");
Require(observedArgs?.Value == 1, "Payload must carry the new count.");
Require(stateWasUpdatedDuringCallback, "State must reflect the change while the callback runs.");
Require(counter.Value == 1, "The increment must update public state.");
}
finally
{
counter.Changed -= handler;
}
counter.Increment();
Require(calls == 1, "A later raise must not include the removed handler.");
Require(counter.Value == 2, "No subscribers must not prevent the increment.");
static void Require(bool condition, string message)
{
if (!condition)
{
throw new InvalidOperationException(message);
}
}
public sealed class CountChangedArgs : EventArgs
{
public CountChangedArgs(int value)
{
Value = value;
}
public int Value { get; }
}
public sealed class ObservableCounter
{
public int Value { get; private set; }
public event EventHandler<CountChangedArgs>? Changed;
public void Increment()
{
Value++;
var handlers = Changed;
handlers?.Invoke(this, new CountChangedArgs(Value));
}
}
Each assertion explains the intended contract. The sender assertion tests a convention deliberately implemented by this publisher, not a requirement of every C# event. The count assertion catches duplicate delivery. Capturing state during the callback distinguishes updating before notification from updating only before the operation returns. The separate post-call assertion checks the final state. The second increment checks both removal and the no-subscriber behavior without assuming that the operation must have an observer.
The assertion function makes failures visible instead of printing a success-shaped message regardless of results. It is intentionally not a reusable testing framework. A real project's framework can express these same observations with its version-appropriate APIs; this article avoids pretending an unverified framework overload is available.
The finally block limits the registration's useful lifetime within this example. Microsoft's subscription documentation recommends retaining anonymous handlers when later removal is needed. That also makes test cleanup independent of reconstructing a lambda with supposedly identical behavior.
Additional payload assertions should be chosen from the actual contract. If an argument includes an identifier and a version, both may matter. If it contains a mutable object, capturing that reference and inspecting it much later might observe later mutation rather than the original notification. Capturing relevant facts during the callback makes the observation boundary clearer.
Test synchronous failure without inventing rollback
Ordinary multicast invocation calls entries synchronously in invocation-list order. The delegate specification states that an uncaught exception prevents later entries from being invoked. A publisher using direct delegate invocation inherits that behavior unless it adds another dispatch policy.
The next example asserts the behavior it actually implements: the state transition occurs first, the first subscriber records its call, the second throws, and the third is not invoked. It deliberately does not claim that a thrown notification reverses the state transition.
#nullable enable
using System;
using System.Collections.Generic;
var source = new FailureSource();
var trace = new List<string>();
var expected = new InvalidOperationException("Observer failed.");
EventHandler first = (_, _) => trace.Add("first");
EventHandler failing = (_, _) => throw expected;
EventHandler third = (_, _) => trace.Add("third");
source.Changed += first;
source.Changed += failing;
source.Changed += third;
bool caughtExpected = false;
try
{
source.Change();
}
catch (InvalidOperationException exception)
{
caughtExpected = ReferenceEquals(exception, expected);
}
finally
{
source.Changed -= first;
source.Changed -= failing;
source.Changed -= third;
}
Require(caughtExpected, "The synchronous observer exception must reach the caller.");
Require(trace.Count == 1 && trace[0] == "first", "Later handlers must be skipped.");
Require(source.Version == 1, "Notification failure does not undo this state change.");
static void Require(bool condition, string message)
{
if (!condition)
{
throw new InvalidOperationException(message);
}
}
public sealed class FailureSource
{
public int Version { get; private set; }
public event EventHandler? Changed;
public void Change()
{
Version++;
var handlers = Changed;
handlers?.Invoke(this, EventArgs.Empty);
}
}
The exception identity assertion checks the specific failure instance supplied by this test, rather than accepting any exception as proof. The trace checks a contractually relevant sequence. The state assertion prevents a reader from interpreting the caller's exception as evidence that nothing happened.
Do not assert subscription order everywhere just because delegate invocation has an order. An API might later change its dispatch implementation or intentionally expose no application-level ordering promise. Assert order when the publisher's stated contract requires it, as this direct-invocation example does, not merely because a particular implementation happens to produce one.
A custom dispatcher that continues after failures needs other tests. Those tests should examine its failure-reporting policy, which subscribers still run, and how results are represented. A broad catch with no observable report cannot be tested as honest successful completion. The useful assertion should identify what the caller actually receives.
The flexible-reaction discussion explains why several handlers can be registered. This failure example adds the narrower evidence boundary: being registered does not guarantee being invoked after an earlier synchronous failure.
Cover duplicate identity and snapshots explicitly
If the application can register the same handler more than once, test that policy directly. Ordinary field-like events allow duplicate delegate entries. One -= removes the last matching occurrence, so a test that adds twice and removes once should expect one remaining registration, not silently label the consumer fully detached.
The .NET Delegate.Remove reference defines the last matching invocation-list occurrence. Custom accessors can implement different semantics. A removal test should be tied to the actual implementation rather than transferring ordinary field-like rules into an unrelated custom helper.
Snapshots deserve a separate observation too. A delegate instance retains its invocation list; later combination or removal produces another instance. If the publisher captures the handlers and an early callback removes a later callback, that later callback can still belong to the captured raise. No threads are necessary to demonstrate this membership distinction.
Testing a subsequent raise verifies changed registration storage. Testing a captured raise verifies membership that was already selected. Neither result proves that an already-started asynchronous operation was canceled. The distinction prevents cleanup tests from making stronger shutdown claims than the API supports.
The event lifetime guide discusses retained subscribers. Test lifecycle ownership separately from actual collection timing. A test using forced GC would not automatically establish production cleanup behavior, and a registration count is not proof that no other references exist.
Use controlled completion instead of sleeps
For async behavior, identify a completion object you can await. A Task-returning core operation is often directly testable even when a framework event requires an async-void adapter. That tests the operation's completion and outcome without pretending the conventional event caller receives a task.
The async return-type documentation explains why async void cannot be awaited by its caller. A test that awaits a Task-returning core does not prove the adapter catches errors correctly or retains registration identity; those are separate contracts requiring separate observations.
TaskCompletionSource<T> provides a producer-controlled task. A test can use it as a gate to arrange a known unfinished dependency, then release that dependency explicitly. The .NET 10 reference describes the producer and consumer sides. No elapsed-time assumption is needed to establish the gate's state.
using System;
using System.Threading;
using System.Threading.Tasks;
var gate = new TaskCompletionSource<bool>();
Task pending = HandleAsync(gate.Task);
Require(!pending.IsCompleted, "The controlled dependency must keep this call incomplete.");
gate.SetResult(true);
await pending;
Require(pending.IsCompletedSuccessfully, "Released work must complete successfully.");
var failure = new InvalidOperationException("Work failed.");
Task faulted = HandleAsync(Task.FromException(failure));
bool sawFailure = false;
try
{
await faulted;
}
catch (InvalidOperationException exception)
{
sawFailure = ReferenceEquals(exception, failure);
}
Require(sawFailure && faulted.IsFaulted, "A fault must remain observable.");
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
Task canceled = HandleAsync(Task.FromCanceled(cancellation.Token));
bool sawCancellation = false;
try
{
await canceled;
}
catch (OperationCanceledException exception)
{
sawCancellation = exception.CancellationToken == cancellation.Token;
}
Require(sawCancellation && canceled.IsCanceled, "Cancellation must not become success.");
static async Task HandleAsync(Task dependency)
{
await dependency;
}
static void Require(bool condition, string message)
{
if (!condition)
{
throw new InvalidOperationException(message);
}
}
This is deliberately a core-operation example, not an event dispatcher or async-void wrapper. The pending-state assertion is based on a gate that the example has not released, not on hoping a scheduler takes longer than a chosen interval. Awaiting after release observes completion rather than polling or sleeping.
The fault and cancellation branches use already-completed tasks to specify exact outcomes. Task.FromException produces a faulted task. Task.FromCanceled requires a token for which cancellation has already been requested, so the source is canceled before that call. The checks distinguish canceled and faulted states from successful completion.
These are documentation-backed expectations, not an executed claim about this program. They also do not prove that every application handles cancellation identically. A production operation may attach business meaning to expected cancellation or report it through another boundary, but it should not accidentally erase that distinction in a blanket catch.
Bound the claim an async test can make
A combined Func<Task> does not automatically represent every handler's completion: direct multicast invocation exposes only the final task if invocation reaches the final entry. The existing task-returning event exploration covers dispatch policies. Tests for that code need to observe individual operations, not just await the last returned task.
A conventional async-void adapter has different obligations. The subscriber-side helper discussion covers error handling and retained wrappers. Testing a successful core operation alone does not prove that the error sink cannot throw or that the adapter is removable.
Cancellation requests are cooperative. A test can verify that an operation reacts to a supplied token, but cannot conclude that ignored tokens forcibly stop work. Similarly, a timeout may bound how long a test waits for missing progress; it is not evidence that the production operation obeys an execution deadline.
Avoid sleeps, forced GC, and scheduler-sensitive ordering as shortcuts. Prefer explicit controlled inputs and completion signals, while keeping any test-runner timeout a failure bound rather than the mechanism that makes the test pass. The examples here need no timing threshold to reach their documented states.
Name the missing observation before expanding a test
When a test fails, separate notification behavior from unrelated subscriber effects. If a payload assertion fails, inspect the values captured at callback time. If an invocation count fails, inspect registration and operation count. If a completion assertion fails, identify the exact task or signal that was supposed to represent completion. This keeps the investigation tied to observable contracts.
The same separation helps when adding coverage. A payload test does not need a network request just because a production subscriber sends one. A registration-removal test does not need an actual garbage-collection experiment. Replacing irrelevant external work with a controlled observation keeps the question narrow, while still exercising the publisher's real public notification path.
An integration test has a different purpose when wiring itself is the contract. It can verify that the real application modules register and invoke the intended consumer. That evidence complements isolated publisher tests rather than replacing them. The isolated test explains what the publisher promises; the integration test checks that the application connects the promised behavior to the right participant.
Describe these limits in test names and reports. "Removed handler is absent from the next raise" is a supported behavioral claim. "All event lifetime issues are solved" is not. Precision makes a small suite useful without encouraging readers to infer more than its assertions actually establish.
Frequently asked questions
What should a basic event test assert?
Assert the notification count and relevant payload values, plus sender or ordering only when the contract promises them. Include no-subscriber and removal behavior when appropriate. Checking that some handler ran is weaker than checking the operation's stated notification semantics.
Can an event be tested without exposing its backing delegate?
Yes. Subscribe through the public event, invoke the public operation, and record callback observations. The examples inspect public state and arguments. Publishing the backing delegate would change encapsulation and is not necessary for these behavioral checks.
Should event tests use Thread.Sleep?
Not as evidence of completion. A sleep assumes the desired work finishes within an arbitrary interval. Use a returned task, controlled dependency, or explicit completion signal instead. A separate failure timeout can bound a stalled test without defining successful behavior.
Does testing a Task-returning core prove an async-void adapter works?
No. It proves only the core behavior observed by that test. The adapter's signature, failure reporting, registration identity, and lifecycle are separate concerns. Its event caller still does not receive the core task merely because the core is independently awaitable.
How should cancellation be distinguished from failure?
Observe the documented outcome rather than treating every exception as the same event. The final example checks a faulted task for a known failure and a canceled task for its requested token. Neither outcome is reported as successful completion.
Does an unsubscribe test prove freedom from memory leaks?
No. It can prove that a later notification excludes the removed registration under the tested contract. It does not establish absence of other reference paths or actual collection timing. Lifetime profiling and reachability analysis answer different questions from notification-count assertions.
Keep testing C# events proportional to the evidence
Strong event tests are precise about observable contracts. Payloads, counts, state boundaries, removal, and failure can be asserted without exposing internal storage. Async work needs a genuine completion boundary, not a hopeful pause.
The same discipline applies to reporting results. Source-reviewed examples are not completed test runs, and a passing narrow test would not establish universal delivery or lifetime guarantees. Clear observations make both the code and its claimed behavior easier to trust.




