Instructions for writing unit tests for new public APIs on WinForms controls and components. Covers test project structure, naming conventions, property tests, event tests, OnXxx method tests, SubControl patterns, data attributes, and handle-state verification.
67
80%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Fix and improve this skill with Tessl
tessl review fix ./.github/skills/control-api-tests/SKILL.mdThese rules apply when writing unit tests for new public properties, methods,
events, and virtual methods on WinForms controls or components. For the API
implementation itself, see the new-control-api skill.
Golden rule: Every new public API member needs tests that verify default values, get/set round-trips, event firing, event idempotency, and behavior both with and without a native window handle.
Control tests live under:
src\test\unit\System.Windows.Forms\System\Windows\Forms\The test project file is:
src\test\unit\System.Windows.Forms\System.Windows.Forms.Tests.csprojEach control has its own test file (or set of partial files):
| Control | Test file(s) |
|---|---|
Button | ButtonTests.cs |
ButtonBase | ButtonBaseTests.cs |
Control | ControlTests.cs, ControlTests.Handlers.cs |
Form | FormTests.cs |
TextBox | TextBoxTests.cs |
When adding new API tests, add them to the existing test file for that
control. If the file is already very large, use a new partial file named
{Control}Tests.{Feature}.cs.
The project uses xUnit with FluentAssertions. Key attributes:
| Attribute | Purpose |
|---|---|
[WinFormsFact] | Single test case (STA-thread-aware [Fact]) |
[WinFormsTheory] | Parameterized test (STA-thread-aware [Theory]) |
These are custom xUnit attributes that ensure tests run on an STA thread, which WinForms requires for COM interop and UI operations.
#nullableThe repository runs xUnit v3 and enforces the relevant analyzers as errors under the CI
build (build.cmd). Two pitfalls fail CI even though a plain dotnet build may not flag them:
CA2016 / xUnit1051 — always pass a CancellationToken to async calls. Methods such as
Task.Delay must receive a token so a cancelled test run stops promptly. In xUnit v3 use
TestContext.Current.CancellationToken:
await Task.Delay(25, TestContext.Current.CancellationToken);When you receive a CancellationToken ct (e.g. in a callback), forward it rather than dropping it.
CS8632 — nullable annotations need a #nullable context. If a test file uses ? reference
annotations (e.g. object? sender) but the project does not enable nullable, add #nullable enable
at the top of the file (or remove the annotation). Match the surrounding files' convention.
Verify with
build.cmd(CI parity) — see thebuilding-codeskill's build tenet. A plain single-projectdotnet buildcan report these as 0 warnings while CI fails them as errors.
Follow the pattern:
{ControlName}_{MemberName}_{Scenario}Examples:
Button_DialogResult_Set_GetReturnsExpected
Control_OnAutoSizeChanged_Invoke_CallsAutoSizeChanged
ButtonBase_Command_SetWithHandler_CallsCommandChanged
Control_DataContext_AmbientBehaviorTestAlways use using declarations to ensure controls are properly disposed,
releasing native window handles and GDI resources:
[WinFormsFact]
public void Button_MyProperty_DefaultValue()
{
using Button control = new();
Assert.Equal(expectedDefault, control.MyProperty);
}Protected members (OnXxx methods, protected properties) cannot be called
directly in tests. Create a private nested subclass inside the test class
that exposes them using new:
private class SubButton : Button
{
// Expose protected virtual methods for direct invocation
public new void OnMyPropertyChanged(EventArgs e)
=> base.OnMyPropertyChanged(e);
// Expose protected properties
public new bool CanEnableIme => base.CanEnableIme;
}Rules:
private and nested inside the test class.public new to re-expose protected base members.Sub{ControlName} (e.g., SubButton, SubControl).private members, use TestAccessor:
this.TestAccessor.Dynamic.PrivateMethod().Use built-in test data attributes to avoid hand-coding value sets:
| Attribute | Generates |
|---|---|
[BoolData] | true, false |
[EnumData<TEnum>] | All values of the enum |
[NewAndDefaultData<EventArgs>] | new EventArgs(), EventArgs.Empty |
[InlineData(...)] | Explicit inline values |
[MemberData(nameof(...))] | Values from a static property/method |
[WinFormsTheory]
[EnumData<DialogResult>]
public void Button_DialogResult_Set_GetReturnsExpected(DialogResult value)
{
using Button control = new() { DialogResult = value };
Assert.Equal(value, control.DialogResult);
}For every new public property, provide tests in these categories:
Verify the property returns its expected default immediately after construction — before any handle is created:
[WinFormsFact]
public void MyControl_MyProperty_DefaultValue()
{
using MyControl control = new();
Assert.Equal(expectedDefault, control.MyProperty);
Assert.False(control.IsHandleCreated);
}[WinFormsTheory]
[InlineData(1)]
[InlineData(42)]
public void MyControl_MyProperty_Set_GetReturnsExpected(int value)
{
using MyControl control = new() { MyProperty = value };
Assert.Equal(value, control.MyProperty);
Assert.False(control.IsHandleCreated);
// Set same value again — must be idempotent.
control.MyProperty = value;
Assert.Equal(value, control.MyProperty);
Assert.False(control.IsHandleCreated);
}Force handle creation and verify no unexpected side-effect events:
[WinFormsTheory]
[InlineData(1)]
[InlineData(42)]
public void MyControl_MyProperty_SetWithHandle_GetReturnsExpected(int value)
{
using MyControl control = new();
Assert.NotEqual(IntPtr.Zero, control.Handle);
int invalidatedCallCount = 0;
control.Invalidated += (sender, e) => invalidatedCallCount++;
int styleChangedCallCount = 0;
control.StyleChanged += (sender, e) => styleChangedCallCount++;
int createdCallCount = 0;
control.HandleCreated += (sender, e) => createdCallCount++;
control.MyProperty = value;
Assert.Equal(value, control.MyProperty);
Assert.True(control.IsHandleCreated);
Assert.Equal(0, invalidatedCallCount);
Assert.Equal(0, styleChangedCallCount);
Assert.Equal(0, createdCallCount);
}Verify the [Property]Changed event fires when the value changes, does
not fire when the same value is set, and does not fire after the handler
is removed:
[WinFormsFact]
public void MyControl_MyProperty_SetWithHandler_CallsMyPropertyChanged()
{
using MyControl control = new();
int callCount = 0;
EventHandler handler = (sender, e) =>
{
Assert.Same(control, sender);
Assert.Same(EventArgs.Empty, e);
callCount++;
};
control.MyPropertyChanged += handler;
// Set different value — event fires.
control.MyProperty = newValue1;
Assert.Equal(newValue1, control.MyProperty);
Assert.Equal(1, callCount);
// Set same value — event does NOT fire.
control.MyProperty = newValue1;
Assert.Equal(1, callCount);
// Set another different value — event fires again.
control.MyProperty = newValue2;
Assert.Equal(2, callCount);
// Remove handler — event no longer fires.
control.MyPropertyChanged -= handler;
control.MyProperty = newValue1;
Assert.Equal(2, callCount);
}Test the On[Property]Changed method directly via the SubControl, verifying
it raises the event and can be unsubscribed:
[WinFormsTheory]
[NewAndDefaultData<EventArgs>]
public void MyControl_OnMyPropertyChanged_Invoke_CallsMyPropertyChanged(EventArgs eventArgs)
{
using SubMyControl control = new();
int callCount = 0;
EventHandler handler = (sender, e) =>
{
Assert.Same(control, sender);
Assert.Same(eventArgs, e);
callCount++;
};
// Call with handler subscribed.
control.MyPropertyChanged += handler;
control.OnMyPropertyChanged(eventArgs);
Assert.Equal(1, callCount);
// Remove handler — still callable, but handler not invoked.
control.MyPropertyChanged -= handler;
control.OnMyPropertyChanged(eventArgs);
Assert.Equal(1, callCount);
}If the On method triggers visual changes (invalidation, style changes),
test with a handle:
[WinFormsTheory]
[NewAndDefaultData<EventArgs>]
public void MyControl_OnMyPropertyChanged_InvokeWithHandle_CallsMyPropertyChanged(EventArgs eventArgs)
{
using SubMyControl control = new();
Assert.NotEqual(IntPtr.Zero, control.Handle);
int invalidatedCallCount = 0;
control.Invalidated += (sender, e) => invalidatedCallCount++;
int callCount = 0;
EventHandler handler = (sender, e) =>
{
Assert.Same(control, sender);
Assert.Same(eventArgs, e);
callCount++;
};
control.MyPropertyChanged += handler;
control.OnMyPropertyChanged(eventArgs);
Assert.Equal(1, callCount);
Assert.True(control.IsHandleCreated);
// Adjust expected counts based on whether the property triggers Invalidate().
}When the API introduces a dedicated EventArgs subclass and delegate:
[WinFormsFact]
public void MyControl_OnMyAction_Invoke_CallsMyAction()
{
using SubMyControl control = new();
MyActionEventArgs expectedArgs = new("test detail");
int callCount = 0;
MyActionEventHandler handler = (sender, e) =>
{
Assert.Same(control, sender);
Assert.Same(expectedArgs, e);
Assert.Equal("test detail", e.Detail);
callCount++;
};
control.MyAction += handler;
control.OnMyAction(expectedArgs);
Assert.Equal(1, callCount);
}If the new API involves ICommand binding, test the full lifecycle:
[WinFormsFact]
public void MyControl_BasicCommandBinding()
{
using SubMyControl control = new();
CommandViewModel viewModel = new() { TestCommandExecutionAbility = true };
int callCount = 0;
EventHandler handler = (sender, e) =>
{
Assert.Same(control, sender);
Assert.Same(EventArgs.Empty, e);
callCount++;
};
// Bind command.
control.CommandChanged += handler;
control.Command = viewModel.TestCommand;
Assert.Equal(1, callCount);
// Set parameter.
control.CommandParameterChanged += handler;
control.CommandParameter = "TestParam";
Assert.Equal(2, callCount);
// Execute.
control.OnClick(EventArgs.Empty);
Assert.Equal("TestParam", viewModel.CommandExecuteResult);
// Disable command.
viewModel.TestCommandExecutionAbility = false;
Assert.False(control.Enabled);
}using declarations for all control instancesAssert.False(control.IsHandleCreated) where handle should not be forced4ba3b30
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.