Skip to content

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 voidasync 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), inject IDateTimeProvider instead of calling DateTime.Now directly, never let unit tests touch real infra."