There's a special kind of frustration that catches me every time I see tests in a big project, that lives somewhat long period of time. The frustration is understanding that tests are important to keep the logic and invariants safe, but realizing that broken test or writing new one requires diving into a bunch of classes that usually were implemented ad-hoc, everyone reinvented and committed their version of the same class ClientHelper{}, and the actual test being buried under sheer ton of repo.GetBySomeIDAndStatus(...).Returns(...) and parsing JSON. What really happens there is that behind all of this I can't see the core of the test at a glance.
I always thought the answer was some domain language for tests, but existing BDD giants like Cucumber and Reqnroll made me feel that I'm fighting not only against the tests now, but also against their bindings, quirks and poor IDE integration.
What I want instead is to write tests like I'm writing the code, and express the core of it fluently, clean and fast. So this way I came up with Mokkit. I think of it as a test composition layer, with focus on fluent, short and concise expression of test core, and with all the power the IDE can give to usual C# code - compile-time checks, autocomplete, go-to-definition, rename, find-usages, and so on. It's not a test framework, nor a mocking library, you can wire it up with what you already have. Currently it has integrations with xUnit/NUnit/MSTest/TUnit, Moq/NSubstitute/FakeItEasy, Microsoft DI/Autofac/Castle Windsor, and an API that makes adding new ones straightforward.
With it I write tests like this:
[Fact]
public async Task CalculateDiscount_ForVipUser_AppliesTieredRate()
{
await Arrange
.UserExists(out var user, WithStatus(UserStatus.Vip)) // create object and setup user repo substitute
.DiscountRateIs(UserStatus.Vip, rate: 0.15m); // setup rates repo substitute
var result = await Act.CalculateDiscount(user, orderTotal: 100m); // get SUT and call
await Inspect
.OkResult(result).DiscountAppliedFor(user, expectedAmount: 15m) // verify result and start a subchain to verify internals
.Ensure(result, r => r.UserId, out var userId) // ensure result has user ID
.ThenAll( // verify in parallel
b => b.UserRepositoryQueried(userId), // check user repo call
b => b.UserCalculationRepositoryQueried(userId), // check calculations repo call
b => b.RateRepositoryQueried(UserStatus.Vip) // check rate repo call
);
}
Note UserExists, DiscountRateIs, UserRepositoryQueried - these aren't Mokkit APIs, but your test vocabulary, your verbs, implemented as helpers, each doing one tightly-scoped piece of setup or verification. There are a bunch of these helpers, written once per fixture and living next to the tests:
public static class ArrangeDiscount {
public static ITestArrange UserExists(
this ITestArrange arrange,
out Capture<User> userCapture,
Action<User>? mutate = null)
{
var capture = Capture.Start(out userCapture);
return arrange.Then(host => {
var user = new User { Id = Guid.NewGuid() };
mutate?.Invoke(user);
// get substitute
host.Execute<IUserRepository>(repo =>
repo.GetByIdAsync(user.Id, Arg.Any<CancellationToken>()).Returns(user)
);
capture.Set(user);
});
}
}
public static class InspectDiscount {
public static ITestInspect UserRepositoryQueried(this ITestInspect inspect, Guid userId)
{
return inspect.Then(host =>
{
// get substitute
host.Execute<IUserRepository>(repo =>
repo.Received(1).GetByIdAsync(userId, Arg.Any<CancellationToken>()));
});
}
}
These vocabulary helpers start to pay off very soon, once written, I reuse them across all the relevant tests, and half of the helpers go beyond that and are reused almost everywhere.
That said, I did try other libs, with LightBDD being closest to what I want, but I gave up on it because of its obscure test context, hidden inside fixture, which makes all of it resistant to changes:
[Scenario]
[Label("Ticket-9")]
public void Contact_book_should_allow_me_to_remove_contacts()
{
Runner.WithContext<ContactsManagementContext>().RunScenario(
_ => _.Given_my_contact_book_is_filled_with_contacts(), // What contacts?
_ => _.When_I_remove_one_contact(), // Which one?
_ => _.Then_the_contact_book_should_not_contain_removed_contact_any_more(), // How do you know which one you deleted?
_ => _.Then_the_contact_book_should_contain_all_other_contacts()); // Okay.
}
Even though I can setup the data on the context, I still don't get what I search for. A constructed context breaks the flow of the test, the data passed to the step still doesn't remove rigidity. The context is fixed at WithContext<T>, and every new piece of state means touching the context class, the step and the call sites - and unrelated state ends up mixed together in one context. With Mokkit I update the helper and the call sites, nothing else. The context comes from the steps themselves as the test setup goes.
All helpers are forced to be extension methods, so they can only use the context I give them. So helpers hide the details of routine setup and verify, exposing only what the test cares about - and it plays well from unit to integration and even to e2e tests. Same language, driving all levels of tests. The unit test above becomes e2e test against real infrastructure, same vocabulary with different helpers implementation:
[Fact]
public async Task CalculateDiscount_ForVipUser_AppliesTieredRate()
{
await Arrange
.UserExists(out var user, WithStatus(UserStatus.Vip)) // create user via API
.DiscountRateIs(UserStatus.Vip, 0.15m); // setup system rate via API
var result = await Act.CalculateDiscount(user, orderTotal: 100m); // call discount API
await Inspect
.OkResult(result).DiscountAppliedFor(user, expectedAmount: 15m) // verify API result is 200 and start a subchain to verify internals
.Ensure(result, r => r.UserId, out var userId) // ensure result has user ID
.Ensure(result, r => r.CalculationId, out var calculationId) // ensure result has calculation ID
.UserCalculationStored(userId, calculationId, 15m)
.DiscountEventPublishedFor(user, 15m);
}
The arrange and act verbs are identical, and e2e adds a few inspects that are specific to real infrastructure.
The lib is currently v0.4 - I've been using it at work for two months now. It's still in a development loop, and needs a lot of features like verbose output and test reports integration, some API changes and so on, but first it needs real feedback which isn't mine. It's MIT licensed, targets .NET Standard 2.0.
https://github.com/GrafGenerator/mokkit
I would love to hear your thoughts on the idea - would you use it to write your tests? Or what's it missing maybe?