Skip to content

Testing high level API

Daniel Frantík edited this page May 13, 2026 · 1 revision

Testing with TikFakeConnection — High-level (O/R mapper) API

⚠️ Alpha APItik4net.testing is in early development. The builder methods and class names may change in future releases.

Package: tik4net.testing (NuGet) — add it to your test project only.
See also: Testing low-level API · Testing mid-level API

TikFakeConnection intercepts at CallCommandSync so the full O/R mapper stack — including TikEntityMetadata reflection, property mapping, boolean/enum conversion, and proplist handling — runs for real. You get accurate coverage of your entity definitions without a live router.

Setup

using tik4net;
using tik4net.Testing;
using tik4net.Objects;         // extension methods: LoadAll, Save, Delete, …
using tik4net.Objects.Ip;      // your entity namespace

Building fake entity instances

Many entity properties are read-only (private setter) because the router sets them — .id, dynamic, actual-interface, etc. The TikFakeEntityExtensions class provides fluent helpers to set these in test setup without raw reflection boilerplate.

WithId

Convenience shortcut for .WithValue(".id", id):

var entry = new FirewallAddressList { List = "BLACKLIST", Address = "10.0.0.1" }
    .WithId("*1");

Assert.AreEqual("*1", entry.Id);

WithValue(tikFieldName, value)

Sets any property by its MikroTik field name (the string in [TikProperty("...")]). Pass the typed C# value — not the wire string — so booleans are true/false, not "yes"/"no":

var addr = new IpAddress { Address = "10.0.0.1/24", Interface = "ether1" }
    .WithId("*1")
    .WithValue("dynamic", true)
    .WithValue("actual-interface", "ether1");

If the field name does not exist on the entity, ArgumentException is thrown with a list of available field names to help diagnose typos.


LoadAll / LoadList

WithEntities<TEntity> matches the entity's load command (/ip/address/print etc.) and auto-serializes the supplied objects through TikEntityMetadata — the same metadata used when reading from a real router:

var conn = new TikFakeConnection()
    .WithEntities(
        new IpAddress { Id = "*1", Address = "10.0.0.1/24", Interface = "ether1", Disabled = false },
        new IpAddress { Id = "*2", Address = "10.0.0.2/24", Interface = "ether2", Disabled = true  });

IEnumerable<IpAddress> addresses = conn.LoadAll<IpAddress>();

Assert.AreEqual(2, addresses.Count());
Assert.AreEqual("10.0.0.1/24", addresses.First().Address);
Assert.IsFalse(addresses.First().Disabled);

LoadSingle / LoadById

var conn = new TikFakeConnection()
    .WithEntities(new SystemIdentity { Name = "MyRouter" }); // singleton entity

SystemIdentity identity = conn.LoadSingle<SystemIdentity>();
Assert.AreEqual("MyRouter", identity.Name);
// LoadById calls /print with ?.id=*1 — register both /print and the filtered response
var conn = new TikFakeConnection()
    .WithEntities(new IpAddress { Id = "*1", Address = "10.0.0.1/24", Interface = "ether1" });

IpAddress addr = conn.LoadById<IpAddress>("*1");
Assert.AreEqual("*1", addr.Id);

Save — insert (new entity)

Save on a new entity (empty Id) calls /addExecuteScalar to get the new id. Register WithScalarResponse for the /add command:

var conn = new TikFakeConnection()
    .WithScalarResponse(rows => rows.First() == "/ip/address/add", "*5");

var newAddr = new IpAddress { Address = "192.168.88.1/24", Interface = "ether1" };
conn.Save(newAddr);

Assert.AreEqual("*5", newAddr.Id); // id written back by Save
conn.AssertWasSent("/ip/address/add");

Save — update (existing entity)

Save on an existing entity calls /print (to diff fields), then /set. Register both:

var existing = new IpAddress { Id = "*1", Address = "10.0.0.1/24", Interface = "ether1", Disabled = false };

var conn = new TikFakeConnection()
    .WithEntities(existing)                                    // /print
    .WithNonQuery(rows => rows.First() == "/ip/address/set"); // /set

existing.Disabled = true;
conn.Save(existing);

conn.AssertWasSent("/ip/address/set");
conn.AssertWasSent(rows => rows.Any(r => r.Contains("disabled=yes")));

Delete

var conn = new TikFakeConnection()
    .WithNonQuery(rows => rows.First() == "/ip/address/remove");

var addr = new IpAddress { Id = "*3", Address = "10.0.0.3/24" };
conn.Delete(addr);

conn.AssertWasSent("/ip/address/remove");

SaveListDifferences

var original = new List<IpAddress>
{
    new IpAddress { Id = "*1", Address = "10.0.0.1/24", Interface = "ether1" },
    new IpAddress { Id = "*2", Address = "10.0.0.2/24", Interface = "ether2" },
};
var backup = original.CloneEntityList();

// Modify: remove *2, add a new one
original.RemoveAt(1);
original.Add(new IpAddress { Address = "10.0.0.99/24", Interface = "ether3" });

var conn = new TikFakeConnection()
    .WithNonQuery(rows => rows.First() == "/ip/address/remove")
    .WithScalarResponse(rows => rows.First() == "/ip/address/add", "*10");

conn.SaveListDifferences(original, backup);

conn.AssertWasSent("/ip/address/remove"); // *2 deleted
conn.AssertWasSent("/ip/address/add");    // new entry added

LoadAsync (streaming)

var received = new List<string>();
var done = new ManualResetEventSlim(false);

var conn = new TikFakeConnection()
    .WithEntities(
        new ToolTorch { TxBps = 1000, RxBps = 500 },
        new ToolTorch { TxBps = 2000, RxBps = 800 });

ITikCommand torchCmd = conn.LoadAsync<ToolTorch>(
    onLoadItemCallback:  item  => received.Add(item.TxBps.ToString()),
    onExceptionCallback: error => Assert.Fail(error.Message),
    conn.CreateParameter("interface", "ether1"),
    conn.CreateParameter("port",      "any"));

done.Wait(TimeSpan.FromSeconds(2));
torchCmd.CancelAndJoin();

CollectionAssert.AreEqual(new[] { "1000", "2000" }, received);

Stateful test — entity store

When you need the fake to reflect changes made by the code under test (load → save → load again):

var store = new List<QueueSimple>
{
    new QueueSimple { Id = "*1", Name = "Q1", MaxLimit = "10M/10M" },
};

var conn = new TikFakeConnection()
    .WithEntities<QueueSimple>(() => store)   // dynamic: re-evaluated on each call
    .WithScalarResponse(rows => rows.First() == "/queue/simple/add", _ =>
    {
        var newId = "*" + (store.Count + 1);
        store.Add(new QueueSimple { Id = newId, Name = "Q2", MaxLimit = "5M/5M" });
        return newId;
    })
    .WithNonQuery(rows => rows.First() == "/queue/simple/set");

// First load — one queue
Assert.AreEqual(1, conn.LoadAll<QueueSimple>().Count());

// Add a new one
conn.Save(new QueueSimple { Name = "Q2", MaxLimit = "5M/5M" });

// Second load — two queues
Assert.AreEqual(2, conn.LoadAll<QueueSimple>().Count());

Verifying calls

// Was the right command sent?
conn.AssertWasSent("/ip/address/add");

// Was a specific parameter included?
conn.AssertWasSent(rows => rows.Any(r => r.Contains("interface=ether1")));

// How many times was /set called?
Assert.AreEqual(1, conn.GetCallCount("/ip/address/set"));

// Full history for custom assertions
foreach (var cmdRows in conn.SentCommands)
    Console.WriteLine(string.Join(" | ", cmdRows));

Clone this wiki locally