Testing
Dahlke.TwinCAT.Ads.Testing packages what every consumer previously rebuilt: a started pool with
simulated targets, seeded, ready to inject — plus a record of what the code under test wrote. For
the simulation layer it builds on, see Simulation.
dotnet add package Dahlke.TwinCAT.Ads.Testing
It depends on the core package and on no test framework, so it works from xunit, NUnit, MSTest or a plain console harness.
await using var plc = await TestPlc.Create()
.WithTarget("plc1", seed => seed["GVL.Temp"] = 21.5f)
.StartAsync();
var sut = new TempService(plc.Pool);
plc.Target("plc1").Write("GVL.Temp", 30f); // drive the system under test
await sut.ProcessAsync();
plc.Target("plc1").AssertWritten("GVL.Setpoint", 23.5f);
plc.Pool is an ordinary IAdsConnectionPool — inject it exactly as the application would.
WithTarget(plcId) (no seed) adds an empty target; ConfigureTarget(plcId, configure) reaches
PlcTargetOptions directly for anything else — timeouts, display name. It is a separate method
rather than a second WithTarget overload, since two overloads differing only in delegate type
would leave WithTarget("plc1", x => …) depending on how the compiler resolves an
implicitly-typed lambda between them. Mode stays forced to Simulated regardless of what
ConfigureTarget sets — a TestPlc never touches hardware.
Seed, write, and what got recorded
Three verbs that look similar and are not:
| Fires subscriptions | Recorded in Writes |
|
|---|---|---|
Target(id).Seed(path, value) |
no | no |
Target(id).Write(path, value) |
yes | no |
| a write by the code under test | yes | yes |
Seed is fixture setup. Write drives the PLC side — an input changing, a sensor moving —
so it fires subscriptions, or a system under test that subscribes would never react.
A harness Write is deliberately excluded from the log. The log answers "what did the code
under test write", and a harness write is not that. Without the exclusion, a test that primes
GVL.Setpoint and then asserts the code under test wrote it would pass while testing nothing.
Assertions
var target = plc.Target("plc1");
target.AssertWritten("GVL.Setpoint"); // written at all
target.AssertWritten("GVL.Setpoint", 23.5f); // written with this value
target.AssertNotWritten("GVL.Estop");
target.AssertWriteCount("GVL.Setpoint", 2);
target.Writes; // every recorded write, oldest first
target.WritesTo("GVL.Setpoint"); // just this path
target.ClearWrites();
Failures throw PlcAssertionException listing every write actually recorded for that path,
with CLR types:
Expected a write of 23.5 (Single) to "GVL.Setpoint" on plc1, but 2 write(s) were recorded:
[0] 23.5 (Double)
[1] 24 (Double)
The types are there because comparison is Equals-based and therefore type-sensitive — a boxed
float 23.5 does not equal a boxed double 23.5. That is the same rule the simulated connection
uses to decide whether a write is a change, and it is the likeliest reason a correct-looking
assertion fails.
Reaching further
Target(id).Simulated is the live SimulatedAdsConnection — use it for enum metadata, ADS state,
or to observe every write including the harness's own via ValueWritten. For a connection that
must fail in a specific way, TestPlc is the wrong tool: see
Hand-written doubles — AdsConnectionBase.
Hand-written doubles — AdsConnectionBase
SimulatedAdsConnection is the right first answer for a test: it is a working connection with a
real value store, real subscriptions and real RPC seeding. What it deliberately does not do is
fail — a specific AdsErrorCode, a timeout on the third call, a symbol that disappears
mid-run — and that is where a hand-written double comes from.
IAdsConnection has over two dozen members, so writing one by hand used to mean a screenful of
throwing stubs around the two that matter. Derive from AdsConnectionBase instead and override
only what the code under test reaches:
// A working simulated connection that times out on the third read and nowhere else.
private sealed class FlakyConnection(IAdsConnection inner) : AdsConnectionBase
{
private int _reads;
public override string PlcId => inner.PlcId;
public override Task<T> ReadValueAsync<T>(string symbolPath, CancellationToken ct = default)
=> Interlocked.Increment(ref _reads) == 3
? throw new AdsErrorException("no answer", AdsErrorCode.ClientSyncTimeOut)
: inner.ReadValueAsync<T>(symbolPath, ct);
}
Every member you do not override throws NotSupportedException naming your type and the member —
FlakyConnection.SearchSymbolsAsync is not implemented — rather than answering with a plausible
null or an empty list that would let the test pass while exercising a path you never specified.
Note that a double built this way forwards only what it declares: the example above delegates
typed reads and nothing else, so a service that also browses symbols needs that override too.
Three members have working defaults, because throwing there would cost overrides that have nothing to do with what you are testing:
| Member | Default |
|---|---|
State / IsConnected |
Starts Connected. SetConnectionState(…) (protected) moves it and raises ConnectionStateChanged in one call, so a double can simulate an outage. |
ConnectionStateChanged |
Raised by SetConnectionState and nothing else. |
WithTimeout |
Validates the argument, then returns itself — a double does no I/O, so it has no bound to change. |
PlcId and DisplayName still throw: there is no honest default for an identity, and a double
reporting the wrong one is how a routing test passes while testing nothing. Both are a one-line
override.
public sealed class OutageDouble : AdsConnectionBase
{
public override string PlcId => "plc1";
public void GoOffline() => SetConnectionState(ConnectionState.Disconnected);
}
Note this softens — but does not remove — the cost of adding a member to IAdsConnection: a
double deriving from the base keeps compiling and picks up a throwing default, while one
implementing the interface directly does not.