Testing — Revision Sheet¶
1. What a test actually is¶
Call a method with known input, check the output matches expectation. Same thing you do manually in Postman, formalized into code that runs itself.
Shape: Arrange, Act, Assert (AAA). Every test you write follows this, unit or integration.
2. Unit test — basic shape¶
Method under test:
public class PricingService
{
private readonly IProductRepository _repo;
public PricingService(IProductRepository repo)
{
_repo = repo;
}
public decimal ApplyDiscount(decimal basePrice, int quantity)
{
if (quantity >= 100)
return basePrice * 0.9m;
return basePrice;
}
public decimal GetDiscountedPrice(int productId, int quantity)
{
var product = _repo.GetById(productId);
return ApplyDiscount(product.BasePrice, quantity);
}
}
Test class:
public class PricingServiceTests
{
private readonly Mock<IProductRepository> _mockRepo;
private readonly PricingService _sut; // System Under Test
public PricingServiceTests()
{
_mockRepo = new Mock<IProductRepository>();
_sut = new PricingService(_mockRepo.Object);
}
[Fact]
public void ApplyDiscount_Returns10PercentOff_WhenQuantityIsOver100()
{
var result = _sut.ApplyDiscount(10m, 150);
Assert.Equal(9m, result);
}
[Fact]
public void ApplyDiscount_ReturnsFullPrice_WhenQuantityIsUnder100()
{
var result = _sut.ApplyDiscount(10m, 50);
Assert.Equal(10m, result);
}
[Fact]
public void ApplyDiscount_AppliesDiscount_AtExactly100()
{
var result = _sut.ApplyDiscount(10m, 100);
Assert.Equal(9m, result);
}
[Fact]
public void GetDiscountedPrice_UsesRepositoryProduct_AndAppliesDiscount()
{
_mockRepo.Setup(r => r.GetById(1))
.Returns(new Product { BasePrice = 10m });
var result = _sut.GetDiscountedPrice(productId: 1, quantity: 150);
Assert.Equal(9m, result);
}
}
Mechanics:
- xUnit creates a new instance of the test class per [Fact] — constructor runs before every test, so state is always fresh, no manual reset needed.
- _sut = System Under Test, naming convention.
- Mock<T> creates a fake implementing the interface, does nothing until .Setup(...).Returns(...) scripts it.
- .Object = the actual fake instance passed into the real class.
Finding test cases — the actual skill:
Not "all possible inputs" (infinite). Find the distinct logic paths + boundaries.
ApplyDiscount has 2 paths (>= 100 / < 100) and 1 boundary (exactly 100, proves >= not >). Three tests, not fifty.
What to mock vs not: Mock things outside your code's boundary — repositories, HTTP clients, message publishers. Never mock the class you're testing (zero signal — you'd just be asserting the mock returns what you told it to).
3. Assertions beyond Equal¶
// Equality / identity
Assert.Equal(expected, actual);
Assert.NotEqual(expected, actual);
Assert.Same(obj1, obj2); // same reference
Assert.NotSame(obj1, obj2);
// Booleans
Assert.True(condition);
Assert.False(condition);
// Null
Assert.Null(result);
Assert.NotNull(result);
// Collections
Assert.Contains(item, collection);
Assert.DoesNotContain(item, collection);
Assert.Empty(collection);
Assert.NotEmpty(collection);
Assert.Single(collection); // exactly one item
// "Except" — no literal Assert.Except, use LINQ then assert
var missing = expectedIds.Except(actualIds).ToList();
Assert.Empty(missing);
// Exceptions
var ex = Assert.Throws<InvalidOperationException>(() => _sut.DoSomething());
Assert.Equal("some message", ex.Message);
await Assert.ThrowsAsync<TimeoutException>(() => _sut.DoSomethingAsync());
// Type checks
Assert.IsType<Product>(result);
Assert.IsAssignableFrom<IProduct>(result);
// Numeric tolerance
Assert.InRange(result, 9.0m, 10.0m);
4. Exception testing¶
[Fact]
public void SubmitInvoice_ThrowsValidationException_WhenUblIsMalformed()
{
var invalidInvoice = new Invoice { /* missing required field */ };
var ex = Assert.Throws<ComplianceValidationException>(
() => _sut.Submit(invalidInvoice));
Assert.Contains("required field", ex.Message);
}
Maps to PEPPOL: layered validation must stop invalid invoices before they reach downstream workflows — this is exactly the test that proves it.
5. Async test¶
[Fact]
public async Task GetPriceAsync_ReturnsDiscountedPrice()
{
var result = await _sut.GetPriceAsync(1, 150);
Assert.Equal(9m, result);
}
async Task, not async void — async void swallows exceptions, the test would false-green even on failure.
6. Theory + InlineData — table-driven tests¶
[Fact] runs once. [Theory] runs the same test body multiple times, once per data row — one method, many cases, no duplication.
[Theory]
[InlineData(50, 10)]
[InlineData(99, 10)]
[InlineData(100, 9)]
[InlineData(150, 9)]
public void ApplyDiscount_ReturnsCorrectPrice_ForVariousQuantities(int quantity, decimal expected)
{
var result = _sut.ApplyDiscount(10m, quantity);
Assert.Equal(expected, result);
}
InlineData values map positionally to method parameters:
| InlineData | quantity | expected | Call |
|---|---|---|---|
(50, 10) |
50 | 10m | ApplyDiscount(10m, 50) → 10m |
(99, 10) |
99 | 10m | ApplyDiscount(10m, 99) → 10m |
(100, 9) |
100 | 9m | ApplyDiscount(10m, 100) → 9m |
(150, 9) |
150 | 9m | ApplyDiscount(10m, 150) → 9m |
xUnit runs the body 4 times, test explorer shows 4 separate pass/fail results.
Rule for what becomes a parameter vs stays hardcoded: does this value affect the branch being tested, or is it just along for the ride? basePrice = 10m stayed hardcoded above because it doesn't decide the branch — quantity does. If basePrice mattered too, parametrize it:
[Theory]
[InlineData(10, 50, 10)]
[InlineData(20, 150, 18)]
public void ApplyDiscount_ReturnsCorrectPrice(decimal basePrice, int quantity, decimal expected)
{
var result = _sut.ApplyDiscount(basePrice, quantity);
Assert.Equal(expected, result);
}
This is the direct mechanism behind "table-drive 50 pricing scenarios in milliseconds" for the LTCP compute layer.
7. Verify — asserting a call happened, not just a return value¶
_sut.ProcessInvoice(invoice);
_mockPublisher.Verify(p => p.Publish(It.Is<InvoiceUploadedEvent>(
e => e.InvoiceId == invoice.Id)), Times.Once);
Use when the thing you care about is "did we call the dependency correctly" rather than a return value — e.g. proving a domain event was published exactly once with the right ID, without a real broker.
8. Test pyramid¶
Rough shape: 70% unit, 20% integration, 10% e2e.
| Scope | Speed | Proves | |
|---|---|---|---|
| Unit | one class, deps mocked | ms | your logic is correct given correct inputs |
| Integration | real DB/broker (TestContainers) | seconds | your logic + real infra actually agree |
| E2E | full system, real HTTP | slow | a user-facing flow works end to end |
Fast tests find bugs, slow tests find integration mistakes — pyramid shape puts most signal at the cheapest layer.
9. LTCP and testability¶
Before LTCP: CalculateActualPrice interleaved DB reads with computation. Testing any branch needed a live DB or mocks at 15 call sites in the right order — fragile, nobody wrote tests, part of why it became "the dragon."
After LTCP: Load (DB-touching, integration-tested) fully separated from Compute (PricingContext in → PriceResult out, zero I/O). Compute becomes trivially unit-testable — construct a context, assert the output, no mocks, no DB. Table-drive 50 scenarios in milliseconds via [Theory].
One-liner: "Separating load from compute didn't just simplify the code, it made the core logic testable without a single mock."
10. Integration testing — TestContainers¶
Core idea: a unit test lies to your code (fake DB, believe whatever I say). An integration test runs your code against the real thing and checks it actually works — catches wrong SQL, wrong EF Core translation, wrong exchange binding that mocks can never catch.
What TestContainers is: a thin wrapper over the Docker API. Needs a running Docker daemon (Docker Desktop locally, already present on GitHub-hosted runners in CI). Spins up a real container (Postgres, RabbitMQ) with a random free port, waits until the service is actually ready to accept connections, tears it down after.
dotnet add package Testcontainers.PostgreSql
dotnet add package Testcontainers.RabbitMq
No Dockerfile, no manual docker-compose up — the library issues the equivalent of docker run for you.
Formal structure¶
public class ProductRepositoryTests : IAsyncLifetime
{
private PostgreSqlContainer _postgresContainer;
private AppDbContext _dbContext;
private ProductRepository _sut;
// Runs once before ALL tests in this class
public async Task InitializeAsync()
{
_postgresContainer = new PostgreSqlBuilder()
.WithImage("postgres:16")
.Build();
await _postgresContainer.StartAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(_postgresContainer.GetConnectionString())
.Options;
_dbContext = new AppDbContext(options);
await _dbContext.Database.MigrateAsync(); // real migrations, real schema
_sut = new ProductRepository(_dbContext);
}
// Runs once after ALL tests in this class
public async Task DisposeAsync()
{
await _postgresContainer.DisposeAsync();
}
[Fact]
public async Task GetById_ReturnsProduct_WhenExists()
{
await _dbContext.Products.AddAsync(new Product { Id = 1, BasePrice = 10m });
await _dbContext.SaveChangesAsync();
var result = await _sut.GetById(1);
Assert.Equal(10m, result.BasePrice);
}
[Fact]
public async Task GetById_ReturnsNull_WhenProductDoesNotExist()
{
var result = await _sut.GetById(999);
Assert.Null(result);
}
}
Mapping to unit test structure:
| Unit test | Integration test | Why |
|---|---|---|
Constructor runs before every [Fact] |
InitializeAsync() runs once before all [Fact]s |
Booting a container takes seconds — pay that cost once per class, not per test |
| No explicit teardown | DisposeAsync() kills the container |
Real resources don't clean themselves up |
Mock<IProductRepository> |
PostgreSqlContainer (real Postgres in Docker) |
Real dependency, disposable, not faked |
IAsyncLifetime not needed |
IAsyncLifetime required |
Starting a container is async; C# constructors can't be async |
AAA in the [Fact] body |
Same AAA shape | Arrange now does real work (real DB insert); Assert checks real infra round-tripped correctly |
How Docker is found: same Docker daemon your Swarm VMs already use — /var/run/docker.sock on Linux/Mac, Docker Desktop's named pipe on Windows. Nothing new to configure.
In CI (GitHub Actions): ubuntu-latest runners ship with Docker preinstalled and running — no setup step needed.
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- run: dotnet test
Gotcha: if Docker isn't running, StartAsync() throws immediately — a clear failure, not a silent skip. Some teams tag integration tests ([Trait("Category", "Integration")]) to filter them out for fast local unit-only runs (dotnet test --filter Category!=Integration), then run everything in CI.
11. Testing a RabbitMQ consumer¶
Step 1 — recognize two separate jobs, two separate test types: 1. "When I get message X, do I do the right thing with it?" — business logic, no broker needed. 2. "Does my consumer actually wire up to RabbitMQ correctly — ack, requeue, DLQ?" — broker-specific behavior, needs a real broker.
Mixing these = slow flaky "unit" tests that boot a broker, or fast tests that never prove retry logic actually works.
Step 2 — extract the handler out of the broker plumbing:
Don't test in this shape:
public class InvoiceUploadedConsumer : IHostedService
{
public async Task ExecuteAsync(CancellationToken ct)
{
channel.BasicConsume(queue: "aver.invoice-uploaded.queue", consumer: consumer);
// inside delivered-message callback:
var message = JsonSerializer.Deserialize<InvoiceUploadedEvent>(body);
await _invoiceService.MarkAsUploaded(message.InvoiceId);
channel.BasicAck(...);
}
}
Extract the handling logic:
public class InvoiceUploadedHandler
{
private readonly IInvoiceService _invoiceService;
public InvoiceUploadedHandler(IInvoiceService invoiceService)
{
_invoiceService = invoiceService;
}
public async Task Handle(InvoiceUploadedEvent message)
{
await _invoiceService.MarkAsUploaded(message.InvoiceId);
}
}
Consumer's job shrinks to: deserialize, call Handle, ack/nack.
Step 3 — unit test the handler, no RabbitMQ at all:
public class InvoiceUploadedHandlerTests
{
private readonly Mock<IInvoiceService> _mockInvoiceService;
private readonly InvoiceUploadedHandler _sut;
public InvoiceUploadedHandlerTests()
{
_mockInvoiceService = new Mock<IInvoiceService>();
_sut = new InvoiceUploadedHandler(_mockInvoiceService.Object);
}
[Fact]
public async Task Handle_MarksInvoiceAsUploaded_WithCorrectId()
{
var message = new InvoiceUploadedEvent { InvoiceId = 42 };
await _sut.Handle(message);
_mockInvoiceService.Verify(s => s.MarkAsUploaded(42), Times.Once);
}
}
Identical shape to every other unit test — Mock, _sut, AAA, milliseconds, business logic bugs caught here.
Step 4 — integration test the RabbitMQ wiring itself, real broker required:
public class InvoiceUploadedConsumerTests : IAsyncLifetime
{
private RabbitMqContainer _rabbitContainer;
private IConnection _connection;
private IModel _channel;
public async Task InitializeAsync()
{
_rabbitContainer = new RabbitMqBuilder().Build();
await _rabbitContainer.StartAsync();
var factory = new ConnectionFactory { Uri = new Uri(_rabbitContainer.GetConnectionString()) };
_connection = factory.CreateConnection();
_channel = _connection.CreateModel();
// declare real topology, matching production
_channel.ExchangeDeclare("domain.events", ExchangeType.Topic);
_channel.QueueDeclare("aver.invoice-uploaded.queue", durable: true, exclusive: false, autoDelete: false);
_channel.QueueBind("aver.invoice-uploaded.queue", "domain.events", "invoice.uploaded");
}
[Fact]
public async Task Consumer_ConsumesAndAcks_WhenMessageIsValid()
{
var message = new InvoiceUploadedEvent { InvoiceId = 42 };
var body = JsonSerializer.SerializeToUtf8Bytes(message);
_channel.BasicPublish("domain.events", "invoice.uploaded", body: body);
var consumer = new InvoiceUploadedConsumer(_channel, /* real handler with mocked service */);
await consumer.StartAsync(CancellationToken.None);
await Task.Delay(500); // let the async consume loop run
var result = _channel.BasicGet("aver.invoice-uploaded.queue", autoAck: false);
Assert.Null(result); // queue empty — message was picked up and acked
}
[Fact]
public async Task Consumer_RoutesToRetryQueue_WhenHandlerThrows()
{
var throwingHandler = new Mock<IInvoiceUploadedHandler>();
throwingHandler.Setup(h => h.Handle(It.IsAny<InvoiceUploadedEvent>()))
.ThrowsAsync(new Exception("simulated failure"));
var message = new InvoiceUploadedEvent { InvoiceId = 42 };
_channel.BasicPublish("domain.events", "invoice.uploaded",
body: JsonSerializer.SerializeToUtf8Bytes(message));
var consumer = new InvoiceUploadedConsumer(_channel, throwingHandler.Object);
await consumer.StartAsync(CancellationToken.None);
await Task.Delay(500);
var retryResult = _channel.BasicGet("aver.invoice-uploaded.retry.queue", autoAck: false);
Assert.NotNull(retryResult); // landed in retry queue, not lost
}
public async Task DisposeAsync()
{
_channel?.Close();
_connection?.Close();
await _rabbitContainer.DisposeAsync();
}
}
Second test directly validates the production retry design: never propagate the exception, host never aborts, message ends up in the retry queue rather than lost.
Summary table:
| Proves | Needs Docker? | |
|---|---|---|
InvoiceUploadedHandlerTests (unit) |
right service call, right arguments | No |
InvoiceUploadedConsumerTests (integration) |
ack, nack, requeue, DLQ routing | Yes — Testcontainers.RabbitMq |
12. One-liners — memorize these for the interview¶
- Test pyramid: "Fast tests find bugs, slow tests find integration mistakes — I want the pyramid shape so failures surface at the cheapest layer."
- What to mock: "I mock at the boundary of my system, not inside my own logic — mocking what you're testing tells you nothing."
- LTCP and testability: "Separating load from compute didn't just simplify the code, it made the core logic testable without a single mock."
- Why integration tests over just mocking everything: "Mocks test that my code calls the interface correctly. They can't tell me if the real implementation behaves the way I assumed — wrong SQL, wrong exchange binding, wrong serialization. Integration tests verify those assumptions against the real thing, in a throwaway Docker container so it's still fast and isolated from prod."
- TestContainers mechanics: "It's a thin wrapper over the Docker API — needs a running daemon, spins up a real container per test class with a random port, waits until the service is ready, tears it down after. Locally that's Docker Desktop; in CI, GitHub's runners already have Docker, no extra setup."
- Testing a RabbitMQ consumer: "I split the consumer into a thin RabbitMQ-facing layer and a testable handler. The handler gets a unit test with a mocked service — that's where business-logic bugs get caught. The consumer wiring itself — ack behavior, and specifically the retry-to-DLQ path — gets an integration test against a real RabbitMQ via TestContainers, because that behavior is broker-specific and a mock can't prove it works."
- Flaky tests: "Usually caused by shared state between tests, real time (
DateTime.Now), or real network calls. Fix: fresh test class instance per test (xUnit already does this), injectIDateTimeProviderinstead of callingDateTime.Nowdirectly, never let unit tests touch real infra."