Clean Architecture
Testing Without Frameworks
The payoff for the rule — business logic tested with no web server, database, or UI in the room.
Every card in this notebook has been paying toward the same bill. Here is the receipt: BasketService.TransferBasketAsync, the use case that merges an anonymous cart into a signed-in buyer’s, tested in TransferBasket.cs with nothing running but the test runner.
A real business rule, tested directly
public class TransferBasket
{
private readonly string _nonexistentAnonymousBasketBuyerId = "nonexistent-anonymous-basket-buyer-id";
private readonly string _existentAnonymousBasketBuyerId = "existent-anonymous-basket-buyer-id";
private readonly string _existentUserBasketBuyerId = "testuser@microsoft.com";
private readonly IRepository<Basket> _mockBasketRepo = Substitute.For<IRepository<Basket>>();
private readonly IAppLogger<BasketService> _mockLogger = Substitute.For<IAppLogger<BasketService>>();The test fakes IRepository, not EfRepository. No CatalogContext has to start.
public class Results<T>
{
private readonly Queue<Func<T>> _values = new();
public Results(T result) { _values.Enqueue(() => result); }
public Results<T> Then(T value) { return Then(() => value); }
public Results<T> Then(Func<T> value)
{
_values.Enqueue(value);
return this;
}
public T Next() { return _values.Dequeue()(); }
}
[Fact]
public async Task TransferAnonymousBasketItemsWhilePreservingExistingUserBasketItems()
{
var anonymousBasket = new Basket(_existentAnonymousBasketBuyerId);
anonymousBasket.AddItem(1, 10, 1);
anonymousBasket.AddItem(3, 55, 7);
var userBasket = new Basket(_existentUserBasketBuyerId);
userBasket.AddItem(1, 10, 4);
userBasket.AddItem(2, 99, 3);The baskets are real entities with real AddItem rules. Only the store is fake.
var results = new Results<Basket>(anonymousBasket)
.Then(userBasket);
_mockBasketRepo.FirstOrDefaultAsync(Arg.Any<BasketWithItemsSpecification>(), Arg.Any<CancellationToken>()).Returns(x => results.Next());
var basketService = new BasketService(_mockBasketRepo, _mockLogger);new BasketService with those fakes is the whole setup — no WebApplicationFactory.
await basketService.TransferBasketAsync(_nonexistentAnonymousBasketBuyerId, _existentUserBasketBuyerId);
await _mockBasketRepo.Received().UpdateAsync(userBasket, Arg.Any<CancellationToken>());
Assert.Equal(3, userBasket.Items.Count);
Assert.Contains(userBasket.Items, x => x.CatalogItemId == 1 && x.UnitPrice == 10 && x.Quantity == 5);
Assert.Contains(userBasket.Items, x => x.CatalogItemId == 2 && x.UnitPrice == 99 && x.Quantity == 3);
Assert.Contains(userBasket.Items, x => x.CatalogItemId == 3 && x.UnitPrice == 55 && x.Quantity == 7);The assertion is the merged cart a shopper would see, proven in a method call.
}
}
new BasketService(_mockBasketRepo, _mockLogger) is the whole setup. No WebApplicationFactory, no appsettings.json, no connection string. The test builds two real Basket entities, hands the service a fake of IRepository<Basket>, and asserts on the merged quantities directly — the same behaviour a shopper triggers by logging in with items already in an anonymous cart, run here in a method call.
What never had to start
_mockBasketRepo is a fake of IRepository<Basket>, the interface The Dependency Rule first pointed at — not EfRepository<T>. So nothing here opens a CatalogContext, nothing runs a migration, nothing needs SQL Server installed on the machine running the test. The whole file’s dependencies are xunit and NSubstitute; ASP.NET Core never enters the process.
eShopOnWeb also has IntegrationTests, PublicApiIntegrationTests, and EndToEndTests projects, and they earn their cost: they are the ones that prove EfRepository<T> really talks to SQL Server and CatalogItemGetByIdEndpoint really returns JSON. But the business rule — merge these items, keep these quantities, drop the old basket — does not need any of that machinery to be proven correct. It only needed ApplicationCore to stay ignorant of it in the first place.